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
rememberSaveablesurvives — for aLazyListState, a TextField value, a screen toggle, etc. - The developer asks "how do I test
rememberSaveablein 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).
StateRestorationTesterdoes not exercise that path; use EspressoActivityScenario.recreate()plus this skill if both phases need coverage. - The state is in a
ViewModeland the developer wants to testSavedStateHandle. Use the AndroidX ViewModel testing tools. - The class skeleton is wrong — fix
../structuring-a-compose-test/SKILL.mdfirst.
Prerequisites
androidx.compose.ui:ui-test-junit4onandroidTestImplementation.StateRestorationTesteris in packageandroidx.compose.ui.test.junit4.- A
ComposeContentTestRule(i.e. one ofcreateComposeRule()/createAndroidComposeRule<A>()).createEmptyComposeRule()is NOT compatible because it returnsComposeTestRule, which has nosetContent. - The Composable under test stores its state with
rememberSaveable { … }(or registers viaSaveableStateRegistry). Plainrememberdoes NOT survive the round-trip — the KDoc states it explicitly: "the state stored via regular state() or remember() will be lost." (cited atcompose/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 ofrule.setContent { }. This is non-negotiable: the tester replaces theLocalSaveableStateRegistryfor the wrapped content. Callingrule.setContentdirectly bypasses the injection andemulateSavedInstanceStateRestorewill fail with"setContent should be called first!"(cited atStateRestorationTester.android.kt:71).3. Hoist the state reference as a nullable
vardeclared abovesetContent. This is the canonical shape — seecompose/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 invals.6. Set
state = nullbetween the capture andemulateSavedInstanceStateRestore(). This is the load-bearing trick. The first composition assigned a value tostate; nulling it forces the secondstate!!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 atStateRestorationTester.android.kt:70-77) is threerunOnIdleblocks:saveStateAndDisposeChildren,emitChildrenWithRestoredState, then a no-op block to wait for emission.8. Re-read the state inside
runOnIdleand 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 { }(NOTrule.setContent { }) for the round-trip to work. - MUST declare the state reference as a nullable
var ... = nulland reassign tonullbetween the capture andemulateSavedInstanceStateRestore(). The assignment forces the second!!access to read the post-restoration composition. - MUST use
rememberSaveable { … }(or anotherSaveableStateRegistryintegration) for any state expected to survive. PlainrememberandmutableStateOfnot behindrememberSaveableARE LOST. Cited atStateRestorationTester.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 isComposeTestRule, notComposeContentTestRule, 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 onlyrestorationTester.setContent { … }. - The hoisted state is
var state: T? = null, NOTlateinit varor non-nullvar 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 aval. -
state = nullappears between the capture andrestorationTester.emulateSavedInstanceStateRestore(). - All persisted state uses
rememberSaveable { … }. No plainrememberis 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
StateRestorationTestersource:compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/StateRestorationTester.android.kt:43-77, 128-169LazyListStateround-trip canonical test:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:890-913Switchsaveable-state-holder regression test:compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:283-310- Save your UI state guide: https://developer.android.com/topic/libraries/architecture/saving-states
rememberSaveablereference: https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/package-summary#rememberSaveable- Compose testing overview: https://developer.android.com/develop/ui/compose/testing