Testing State Restoration
Communityskydoves
Use this skill to test `rememberSaveable` round-trips with `StateRestorationTester`, the only supported tool for proving Compose state survives process death and configuration change. Covers the constructor (`StateRestorationTester(rule: ComposeContentTestRule)`), why `restorationTester.setContent { }` MUST replace `rule.setContent { }`, the `state = null` between phases trick that proves restoration actually happened, the 1 MB Bundle cap, and what is NOT exercised (Activity lifecycle, configuration changes, plain `remember`). Use when the developer asks "how do I test rememberSaveable", "test state survives rotation", "state is lost after restore", "Bundle exceeds maximum size", or shows a test that re-reads the same state reference after `emulateSavedInstanceStateRestore` and is confused why nothing changed.
Overview
- Repository
- skydoves/android-testing-skills
- Publisher
- skydoves
- Stars
- 347
- Forks
- 14
- Bundled files
- 0
- License
- Apache-2.0
- Updated
- May 25, 2026
Installation
tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/android-testing-skills.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/compose/patterns/testing-state-restoration" ~/.claude/skills/testing-state-restoration
rm -rf "$tmp"tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/android-testing-skills.git "$tmp"
mkdir -p ~/.codex/skills
cp -R "$tmp/compose/patterns/testing-state-restoration" ~/.codex/skills/testing-state-restoration
rm -rf "$tmp"Install the Testing State Restoration agent skill from https://github.com/skydoves/android-testing-skills/tree/main/compose/patterns/testing-state-restoration into this project's skills folder.Instructions
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
FAQ
What does Testing State Restoration do?
Use this skill to test `rememberSaveable` round-trips with `StateRestorationTester`, the only supported tool for proving Compose state survives process death and configuration change. Covers the constructor (`StateRestorationTester(rule: ComposeContentTestRule)`), why `restorationTester.setContent { }` MUST replace `rule.setContent { }`, the `state = null` between phases trick that proves restoration actually happened, the 1 MB Bundle cap, and what is NOT exercised (Activity lifecycle, configuration changes, plain `remember`). Use when the developer asks "how do I test rememberSaveable", "test state survives rotation", "state is lost after restore", "Bundle exceeds maximum size", or shows a test that re-reads the same state reference after `emulateSavedInstanceStateRestore` and is confused why nothing changed.
Who made Testing State Restoration?
skydoves (a community member) publishes it in the skydoves/android-testing-skills repository on GitHub.
Is Testing State Restoration free?
Yes. Testing State Restoration is open source, published by skydoves under the Apache-2.0 license.
What comes with Testing State Restoration?
Just its instructions. Everything the agent needs is in one SKILL.md file.
Which agents can use Testing State Restoration?
Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.
What are agent skills?
An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.
How are agent skills different from MCP servers?
An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.
How can my agent test my Android app?
Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.