SuperAPI AI Directory

testing-state-restoration

Testing & quality

View repository

Updated 10-16 02:31

About this skill

Testing State Restoration — StateRestorationTester Round-Trips Only rememberSaveable

StateRestorationTester is the only first-party way to verify that a Composable's state survives a save/restore cycle. It is narrow — it injects a LocalSaveableStateRegistry, snapshots whatever registered through it, throws away the composition, and re-composes with the snapshot restored. It does not restart the Activity, it does not trigger Application.onCreate, and it has a hard 1 MB cap on the serialized Bundle. Misunderstanding the scope produces tests that look passing but prove nothing.

When to use this skill

  • The developer is verifying that rememberSaveable survives — for a LazyListState, a TextField value, a screen toggle, etc.
  • The developer asks "how do I test rememberSaveable in a unit test?"
  • A test calls emulateSavedInstanceStateRestore() and the state appears unchanged or null.
  • The developer hits IllegalStateException: Bundle exceeds maximum size (1 MB).
  • A reviewer asks for a regression test for a save/restore bug fix.

When NOT to use this skill

  • The bug under test is an Activity recreation issue (rotation re-creates the Activity, callbacks fire, ViewModels rebind). StateRestorationTester does not exercise that path; use Espresso ActivityScenario.recreate() plus this skill if both phases need coverage.
  • The state is in a ViewModel and the developer wants to test SavedStateHandle. Use the AndroidX ViewModel testing tools.
  • The class skeleton is wrong — fix ../structuring-a-compose-test/SKILL.md first.

Prerequisites

  • androidx.compose.ui:ui-test-junit4 on androidTestImplementation. StateRestorationTester is in package androidx.compose.ui.test.junit4.
  • A ComposeContentTestRule (i.e. one of createComposeRule() / createAndroidComposeRule<A>()). createEmptyComposeRule() is NOT compatible because it returns ComposeTestRule, which has no setContent.
  • The Composable under test stores its state with rememberSaveable { … } (or registers via SaveableStateRegistry). Plain remember does NOT survive the round-trip — the KDoc states it explicitly: "the state stored via regular state() or remember() will be lost." (cited at compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/StateRestorationTester.android.kt:67-68).

Workflow

  • 1. Construct the tester from the rule.
import androidx.compose.ui.test.junit4.StateRestorationTester

@get:Rule val rule = createComposeRule(StandardTestDispatcher())
val restorationTester = StateRestorationTester(rule)

The constructor signature is class StateRestorationTester(private val composeTestRule: ComposeContentTestRule), file StateRestorationTester.android.kt:43.

  • 2. Use restorationTester.setContent { } instead of rule.setContent { }. This is non-negotiable: the tester replaces the LocalSaveableStateRegistry for the wrapped content. Calling rule.setContent directly bypasses the injection and emulateSavedInstanceStateRestore will fail with "setContent should be called first!" (cited at StateRestorationTester.android.kt:71).

  • 3. Hoist the state reference as a nullable var declared above setContent. This is the canonical shape — see compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:890-913:

@Test
fun stateIsRestored() {
    val restorationTester = StateRestorationTester(rule)
    var state: LazyListState? = null

    restorationTester.setContent {
        state = rememberLazyListState()
        LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag), state = state!!) {
            items(20) { Spacer(Modifier.requiredSize(20.dp).testTag("$it")) }
        }
    }

    rule.onNodeWithTag(LazyListTag).performScrollToIndex(2)

    val (index, scrollOffset) = rule.runOnIdle {
        state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
    }

    state = null                                              // (*) clear the reference

    restorationTester.emulateSavedInstanceStateRestore()      // save → dispose → restore

    rule.runOnIdle {
        assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
        assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
    }
}
  • 4. Drive the state to a non-default value. A test that asserts a default cannot prove restoration occurred. Scroll the list, type into the TextField, toggle the switch.

  • 5. Capture the values to compare against. Read the state inside runOnIdle { } and store them in vals.

  • 6. Set state = null between the capture and emulateSavedInstanceStateRestore(). This is the load-bearing trick. The first composition assigned a value to state; nulling it forces the second state!! access to read whatever the second composition assigns — proving the second composition really happened. Without nulling, the test asserts on the original instance and would pass even if the round-trip silently restored nothing.

  • 7. Call emulateSavedInstanceStateRestore(). The internal flow (cited at StateRestorationTester.android.kt:70-77) is three runOnIdle blocks: saveStateAndDisposeChildren, emitChildrenWithRestoredState, then a no-op block to wait for emission.

  • 8. Re-read the state inside runOnIdle and assert equality with the captured values.

Patterns

Pattern: remember vs rememberSaveable

// WRONG
@Test
fun counterIsRestored() {
    val tester = StateRestorationTester(rule)
    var counter: MutableState<Int>? = null
    tester.setContent {
        counter = remember { mutableStateOf(0) }              // <-- not saveable
        Text("${counter!!.value}")
    }
    rule.runOnIdle { counter!!.value = 7 }
    counter = null
    tester.emulateSavedInstanceStateRestore()
    rule.runOnIdle { assertThat(counter!!.value).isEqualTo(7) }   // FAILS: 0
}
// WRONG because: plain remember is dropped on dispose. The KDoc states "state stored via
// regular state() or remember() will be lost" (StateRestorationTester.android.kt:67-68).
// RIGHT
@Test
fun counterIsRestored() {
    val tester = StateRestorationTester(rule)
    var counter: MutableState<Int>? = null
    tester.setContent {
        counter = rememberSaveable { mutableStateOf(0) }      // <-- saveable
        Text("${counter!!.value}")
    }
    rule.runOnIdle { counter!!.value = 7 }
    counter = null
    tester.emulateSavedInstanceStateRestore()
    rule.runOnIdle { assertThat(counter!!.value).isEqualTo(7) }
}

Pattern: rule.setContent vs restorationTester.setContent

// WRONG
@Test
fun stateIsRestored() {
    val tester = StateRestorationTester(rule)
    rule.setContent { /* … */ }                               // <-- bypasses the tester
    tester.emulateSavedInstanceStateRestore()                 // throws
}
// WRONG because: emulateSavedInstanceStateRestore checks `registry != null` and otherwise
// throws IllegalStateException("setContent should be called first!")
// (StateRestorationTester.android.kt:71). The injection only happens via tester.setContent.
// RIGHT
@Test
fun stateIsRestored() {
    val tester = StateRestorationTester(rule)
    tester.setContent { /* … */ }
    tester.emulateSavedInstanceStateRestore()
}

Pattern: re-reading the same instance vs nulling between phases

// WRONG
@Test
fun listScrollIsRestored() {
    val tester = StateRestorationTester(rule)
    lateinit var state: LazyListState
    tester.setContent { state = rememberLazyListState(); /* list */ }
    rule.onNodeWithTag(LazyListTag).performScrollToIndex(5)
    val captured = rule.runOnIdle { state.firstVisibleItemIndex }
    tester.emulateSavedInstanceStateRestore()
    rule.runOnIdle { assertThat(state.firstVisibleItemIndex).isEqualTo(captured) }
}
// WRONG because: `state` still points at the FIRST composition's instance. The assertion
// passes even if the second composition silently created a fresh state and restored nothing.
// Use a nullable var and null it between phases to force the second access to read from the
// post-restoration composition.
// RIGHT
@Test
fun listScrollIsRestored() {
    val tester = StateRestorationTester(rule)
    var state: LazyListState? = null
    tester.setContent { state = rememberLazyListState(); /* list */ }
    rule.onNodeWithTag(LazyListTag).performScrollToIndex(5)
    val captured = rule.runOnIdle { state!!.firstVisibleItemIndex }
    state = null                                              // <-- forces re-read
    tester.emulateSavedInstanceStateRestore()
    rule.runOnIdle { assertThat(state!!.firstVisibleItemIndex).isEqualTo(captured) }
}

Pattern: 1 MB Bundle cap

// WRONG
@Test
fun megaListSurvives() {
    val tester = StateRestorationTester(rule)
    tester.setContent {
        rememberSaveable { ByteArray(2 * 1024 * 1024) }       // 2 MB
    }
    tester.emulateSavedInstanceStateRestore()                 // throws IllegalStateException
}
// WRONG because: platformEncodeDecode in StateRestorationTester.android.kt:163 enforces
// `check(bytes.size <= 1024 * 1024) { "Bundle exceeds maximum size (1 MB): ${bytes.size} bytes." }`
// The same cap applies on real devices via Binder transaction limits.
// RIGHT — keep saved state small. Persist large data via Room/DataStore, save only an id.
@Test
fun megaListSurvives() {
    val tester = StateRestorationTester(rule)
    tester.setContent {
        rememberSaveable { largeRowsId }                       // a stable key, not the data
    }
    tester.emulateSavedInstanceStateRestore()
}

Mandatory rules

  • MUST call restorationTester.setContent { } (NOT rule.setContent { }) for the round-trip to work.
  • MUST declare the state reference as a nullable var ... = null and reassign to null between the capture and emulateSavedInstanceStateRestore(). The assignment forces the second !! access to read the post-restoration composition.
  • MUST use rememberSaveable { … } (or another SaveableStateRegistry integration) for any state expected to survive. Plain remember and mutableStateOf not behind rememberSaveable ARE LOST. Cited at StateRestorationTester.android.kt:67-68.
  • MUST keep the saved Bundle under 1 MB. The check is at StateRestorationTester.android.kt:163.
  • MUST NOT treat emulateSavedInstanceStateRestore() as a stand-in for Activity recreation, configuration change, or process death. The KDoc explicitly says "It is not testing the integration with any other life cycles or Activity callbacks." (StateRestorationTester.android.kt:40-41).
  • MUST NOT combine this skill with createEmptyComposeRule() — that rule is ComposeTestRule, not ComposeContentTestRule, so the constructor will not compile.
  • PREFERRED: drive the state to a non-default value (scroll, type, toggle) before capturing — a test that asserts on the default value cannot prove restoration occurred.
  • PREFERRED: read the captured values via rule.runOnIdle { … } and assert equality, not identity.

Verification

  • The test constructs StateRestorationTester(rule) once and calls only restorationTester.setContent { … }.
  • The hoisted state is var state: T? = null, NOT lateinit var or non-null var state: T.
  • The test drives the state to a non-default value before capturing.
  • Capture happens inside rule.runOnIdle { … } and the result is stored in a val.
  • state = null appears between the capture and restorationTester.emulateSavedInstanceStateRestore().
  • All persisted state uses rememberSaveable { … }. No plain remember is expected to survive.
  • The total saved size is comfortably under 1 MB. Large blobs are excluded; only ids/keys are saved.
  • The test does NOT also assume Activity recreation behaviors (callbacks, ViewModel rebind, etc.).

References