关于 testing-state-restoration
这项 Jetpack Compose 测试技能介绍如何用 StateRestorationTester 检查保存与恢复。它说明测试覆盖重新组合后的 rememberSaveable 状态,不涵盖 Activity 重建或进程死亡。
主要功能
- 01执行 StateRestorationTester 往返测试
- 02验证 rememberSaveable 状态
- 03使用保存状态重新组合
- 04遵守 1 MB Bundle 限制
适用场景
- 01测试列表位置的恢复
- 02检查文本框内容能否恢复
- 03为保存与恢复行为添加回归测试
- 04排查保存状态的 Bundle 超限
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill testing-state-restoration安装后在 Claude Code 中输入/testing-state-restoration确认已加载。
详细介绍
测试状态恢复 — StateRestorationTester 仅对 rememberSaveable 执行往返测试
StateRestorationTester 是唯一由官方提供的方式,可用于验证 Composable 的状态能否在保存/恢复周期后保留。它的适用范围有限:它会注入一个 LocalSaveableStateRegistry,为通过它注册的内容创建快照,丢弃当前组合,然后使用恢复的快照重新组合。它不会重启 Activity,不会触发 Application.onCreate,并且序列化后的 Bundle 有严格的 1 MB 上限。误解其适用范围会导致测试看似通过,却无法证明任何问题。
何时使用此技能
- 开发者要验证
rememberSaveable能否保留状态,例如LazyListState、TextField 的值或屏幕开关等。 - 开发者询问“如何在单元测试中测试
rememberSaveable?” - 测试调用了
emulateSavedInstanceStateRestore(),但状态看起来没有变化或为 null。 - 开发者遇到
IllegalStateException: Bundle exceeds maximum size (1 MB)。 - 审查者要求为保存/恢复错误修复添加回归测试。
何时不要使用此技能
- 被测问题与 Activity 重建 有关(旋转会重新创建 Activity、触发回调、重新绑定 ViewModel)。
StateRestorationTester不会覆盖这条路径;如果两阶段都需要覆盖,请使用 Espresso 的ActivityScenario.recreate()并结合此技能。 - 状态位于
ViewModel中,且开发者想要测试SavedStateHandle。请使用 AndroidX ViewModel 测试工具。 - 类的基本结构有误——请先修复
../structuring-a-compose-test/SKILL.md。
前提条件
- 在
androidTestImplementation中添加androidx.compose.ui:ui-test-junit4。StateRestorationTester位于包androidx.compose.ui.test.junit4中。 - 一个
ComposeContentTestRule(即createComposeRule()/createAndroidComposeRule<A>()之一)。createEmptyComposeRule()不兼容,因为它返回的是没有setContent的ComposeTestRule。 - 被测 Composable 使用
rememberSaveable { … }存储状态(或通过SaveableStateRegistry注册)。普通的remember无法在往返后保留状态——KDoc 明确指出:“通过常规的 state() 或 remember() 存储的状态会丢失。”(引自compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/StateRestorationTester.android.kt:67-68)。
工作流
- 1. 根据规则构造测试器。
import androidx.compose.ui.test.junit4.StateRestorationTester
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
val restorationTester = StateRestorationTester(rule)
构造函数签名为 class StateRestorationTester(private val composeTestRule: ComposeContentTestRule),位于文件 StateRestorationTester.android.kt:43。
-
2. 使用
restorationTester.setContent { },而不是rule.setContent { }。 这是硬性要求:测试器会替换包装内容的LocalSaveableStateRegistry。直接调用rule.setContent会绕过注入,并导致emulateSavedInstanceStateRestore失败,报错"setContent should be called first!"(引用自StateRestorationTester.android.kt:71)。 -
3. 在
setContent上方将状态引用声明为可空的var。 标准写法如下,参见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. 将状态设为非默认值。 断言默认值的测试无法证明发生了恢复。滚动列表、在 TextField 中输入内容,或切换开关。
-
5. 捕获要进行比较的值。 在
runOnIdle { }中读取状态并将其存储到val中。 -
6. 在捕获值之后、调用
emulateSavedInstanceStateRestore()之前,将state = null。 这是关键技巧。第一次组合为state赋了值;将其置为 null 会强制第二次对state!!的访问读取第二次组合所赋的值,从而证明第二次组合确实发生了。如果不置为 null,测试断言的会是原始实例,即使往返过程悄无声息地没有恢复任何内容,测试也会通过。 -
7. 调用
emulateSavedInstanceStateRestore()。 内部流程(引用自StateRestorationTester.android.kt:70-77)由三个runOnIdle块组成:saveStateAndDisposeChildren、emitChildrenWithRestoredState,然后是一个用于等待发射完成的空操作块。 -
8. 在
runOnIdle中重新读取状态,并断言其与捕获的值相等。
模式
模式:remember 与 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) }
}
模式:rule.setContent 与 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()
}
模式:重新读取同一实例与在不同阶段之间置为 null
// 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) }
}
模式:Bundle 的 1 MB 上限
// 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()
}
强制规则
- 必须为使往返恢复正常工作而调用
restorationTester.setContent { }(而不是rule.setContent { })。 - 必须将状态引用声明为可空的
var ... = null,并在捕获值之后、调用emulateSavedInstanceStateRestore()之前重新赋值为null。这一赋值会强制第二次!!访问读取恢复后组合所赋的值。 - 必须对任何预期能够保留的状态使用
rememberSaveable { … }(或其他SaveableStateRegistry集成方式)。普通的remember和未通过rememberSaveable保存的mutableStateOf会丢失。引用自StateRestorationTester.android.kt:67-68。 - 必须将保存的 Bundle 控制在 1 MB 以内。检查位于
StateRestorationTester.android.kt:163。 - 不得将
emulateSavedInstanceStateRestore()当作 Activity 重建、配置更改或进程终止的替代方案。KDoc 明确指出:“它不会测试与其他任何生命周期或 Activity 回调的集成。”(StateRestorationTester.android.kt:40-41)。 - 不得将此技能与
createEmptyComposeRule()结合使用——该规则是ComposeTestRule,而不是ComposeContentTestRule,因此构造函数将无法编译。 - 首选: 在捕获值之前将状态设为非默认值(滚动、输入内容或切换开关)——断言默认值的测试无法证明发生了恢复。
- 首选: 通过
rule.runOnIdle { … }读取捕获的值,并断言相等,而不是断言为同一对象。
验证
- 测试只构造一次
StateRestorationTester(rule),并且只调用restorationTester.setContent { … }。 - 提升的状态为
var state: T? = null,而不是lateinit var或非空的var state: T。 - 测试在捕获前将状态设为非默认值。
- 在
rule.runOnIdle { … }中捕获,并将结果存储在val中。 -
state = null位于捕获与restorationTester.emulateSavedInstanceStateRestore()之间。 - 所有持久化状态都使用
rememberSaveable { … }。不要指望普通的remember能保留下来。 - 保存内容的总大小远低于 1 MB。排除大型数据块;只保存 ID/键。
- 测试也不会假定 Activity 重建行为(回调、ViewModel 重新绑定等)。
参考资料
StateRestorationTester源码:compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/StateRestorationTester.android.kt:43-77, 128-169LazyListState往返恢复规范测试:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:890-913Switch可保存状态持有器回归测试:compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:283-310- 保存界面状态指南:https://developer.android.com/topic/libraries/architecture/saving-states
rememberSaveable参考文档:https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/package-summary#rememberSaveable- Compose 测试概述:https://developer.android.com/develop/ui/compose/testing