mcpField
T
testing-state-restoration

testing-state-restoration

github.com/skydoves测试与质量33959 次安装22 周前更新
代码仓库

关于 testing-state-restoration

这项 Jetpack Compose 测试技能介绍如何用 StateRestorationTester 检查保存与恢复。它说明测试覆盖重新组合后的 rememberSaveable 状态,不涵盖 Activity 重建或进程死亡。

主要功能

  1. 01执行 StateRestorationTester 往返测试
  2. 02验证 rememberSaveable 状态
  3. 03使用保存状态重新组合
  4. 04遵守 1 MB Bundle 限制

适用场景

  1. 01测试列表位置的恢复
  2. 02检查文本框内容能否恢复
  3. 03为保存与恢复行为添加回归测试
  4. 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 重新绑定等)。

参考资料

评论

支持 Markdown