testing-state-restoration について
StateRestorationTester を使って保存と復元を検証する Compose テストスキルです。再コンポーズ後の rememberSaveable の状態が対象で、Activity の再生成やプロセス終了は対象外であることを説明します。
主な機能
- 01StateRestorationTester の往復テスト
- 02rememberSaveable の状態検証
- 03保存状態を使った再コンポーズ
- 041 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 は、コンポーザブルの状態が保存/復元サイクルを経ても維持されることを検証する、唯一のファーストパーティの方法です。対象範囲は限定的です。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を修正してください。
前提条件
androidx.compose.ui:ui-test-junit4がandroidTestImplementationに指定されていること。StateRestorationTesterはパッケージandroidx.compose.ui.test.junit4にあります。ComposeContentTestRule(つまり、createComposeRule()またはcreateAndroidComposeRule<A>()のいずれか)があること。createEmptyComposeRule()はsetContentを持たないComposeTestRuleを返すため、互換性がありません。- テスト対象のコンポーザブルが
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.
rule.setContent { }の代わりにrestorationTester.setContent { }を使用します。 これは必須です。テスターは、ラップされたコンテンツのLocalSaveableStateRegistryを置き換えます。rule.setContentを直接呼び出すとインジェクションがバイパスされ、emulateSavedInstanceStateRestoreは"setContent should be called first!"というエラーで失敗します(StateRestorationTester.android.kt:71で引用)。 -
3.
setContentの前に、状態参照を nullable な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 にすることで、2 回目のstate!!アクセスでは、2 回目のコンポジションが代入した値が読み取られます。これにより、2 回目のコンポジションが実際に行われたことを証明できます。null にしないと、テストは元のインスタンスをアサートするため、ラウンドトリップで何も復元されなくてもパスしてしまいます。 -
7.
emulateSavedInstanceStateRestore()を呼び出します。 内部のフロー(StateRestorationTester.android.kt:70-77で引用)は 3 つの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 { }ではなく)を呼び出します。 - 必須: 状態参照を nullable な
var ... = nullとして宣言し、値の取得後、emulateSavedInstanceStateRestore()の呼び出し前にnullを再代入します。この代入により、2 回目の!!アクセスで復元後のコンポジションの値が読み取られます。 - 必須: 存続させる状態には、
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や null 非許容の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の saveable-state-holder 回帰テスト:compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:283-310- UI の状態を保存するガイド: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