mcpField
T
testing-state-restoration

testing-state-restoration

github.com/skydovesテスト・品質33959 回インストール22 週間前に更新
リポジトリ

testing-state-restoration について

StateRestorationTester を使って保存と復元を検証する Compose テストスキルです。再コンポーズ後の rememberSaveable の状態が対象で、Activity の再生成やプロセス終了は対象外であることを説明します。

主な機能

  1. 01StateRestorationTester の往復テスト
  2. 02rememberSaveable の状態検証
  3. 03保存状態を使った再コンポーズ
  4. 041 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 は、コンポーザブルの状態が保存/復元サイクルを経ても維持されることを検証する、唯一のファーストパーティの方法です。対象範囲は限定的です。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 の再バインドなど)も想定しないでください。

参考資料

コメント