Composables

Testing State Restoration

Community

skydoves

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"

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 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

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.

You're subscribed

The next lesson lands in on Wednesday.