mcpField
T
testing-lazy-lists

testing-lazy-lists

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

testing-lazy-lists について

Jetpack Compose のテスト作成者向けに、Lazy リストやグリッドのスクロール、表示状態のアサーション、状態確認を解説するスキルです。タグとセマンティクスによるスクロールで項目を特定し、layoutInfo でレイアウト状態を調べます。

主な機能

  1. 01コンテナと項目へのテストタグ付け
  2. 02インデックスやキーによるセマンティックスクロール
  3. 03項目の表示状態のアサーション
  4. 04layoutInfo によるリスト状態の確認
  5. 05項目配置アニメーションの決定的なテスト

活用シーン

  1. 01LazyColumn 内の項目表示を検証
  2. 02並べ替え後にキーで項目を特定
  3. 03表示項目とレイアウト情報を確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill testing-lazy-lists

インストール後、Claude Code で/testing-lazy-listsを入力して読み込まれたか確認します。

詳細

Lazy リストのテスト — コンテナーにタグを付け、セマンティクスでスクロールし、layoutInfo を調べる

LazyColumn/LazyRow/LazyVerticalGrid は、ビューポート付近にある、または以前あったアイテムのみをコンポーズします。そのため、「テキストで検索してアサートする」という単純な流れでは問題が生じます。画面外のアイテムは存在しない場合も、切り離されたセマンティクスノードとして存在する場合も、境界が誤っている状態で存在する場合もあります。このスキルでは、androidx 自身が使用しているパターンをまとめています。コンテナーにタグを付け、セマンティクスアクション、または testTag とスワイプジェスチャーでスクロールし、assertIsDisplayed で確認して、LazyListState.layoutInfo から正確な情報を読み取ります。

このスキルを使う場面

  • 開発者が、任意の Lazy* コンポーザブル(LazyColumn、LazyRow、LazyVerticalGrid、LazyHorizontalGrid、LazyVerticalStaggeredGrid)のテストを書いている。
  • アイテムが「存在する」ことをテストでアサートしているものの、画面上に表示されているかどうかを判断できない。
  • 開発者が、インデックス 7、キー "key_10"、またはオフセットを指定してプログラムからスクロールしたい。
  • アイテムごとのインデックス/オフセットをテストでアサートしており、標準的な state.layoutInfo の形式が必要。
  • 開発者が Modifier.animateItem() またはアイテム配置アニメーションをテストしている。
  • レビュアーから、lazy アイテムに assertExists を使うのがなぜ誤りなのか質問されている。

このスキルを使わない場面

  • テスト対象のリストが lazy ではない Column/Row/FlowRow である。../structuring-a-compose-test/SKILL.md と ../../assertions/asserting-node-state-and-text/SKILL.md を使ってください。
  • クラスのスケルトン自体に誤りがある。まず ../structuring-a-compose-test/SKILL.md を修正してください。
  • スクロール操作が独自の Modifier.scrollable(LazyListState に基づかない)である。../../actions/injecting-touch-gestures/SKILL.md を使ってください。
  • アニメーションのタイミングをテストする。../../synchronization/testing-animations-deterministically/SKILL.md を重ねて使ってください。

前提条件

  • ../structuring-a-compose-test/SKILL.md のクラススケルトン(@MediumTest + @RunWith(AndroidJUnit4::class) + createComposeRule(StandardTestDispatcher()))。
  • 本番ソースにコンテナー用の const タグがあること(skydoves ディレクティブ #1):
const val LazyListTag = "LazyListTag"
  • 各アイテムに、安定したアイテムキーである it を指定した Modifier.testTag(it) が付いていること(compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:285-296 と一致)。

ワークフロー

  • 1. コンテナーにタグを付け、各アイテムにはキーでタグを付けます。 タグは本番ソースに含めます。
// production
LazyColumn(modifier = Modifier.testTag(LazyListTag), state = state) {
    items(snacks, key = { it.id }) { snack ->
        Row(Modifier.testTag(snack.id.toString())) { /* … */ }
    }
}
  • 2. セマンティックアクションでインデックスまでスクロールします。 テスト対象の Lazy* コンポーザブルの種類を問わないテストでは、こちらを優先します。
rule.onNode(hasScrollAction()).performScrollToIndex(7)
  • 3. コンテナーのタグでスクロールします。 複数のスクロール可能なノードをテストで区別する必要がある場合に使用します。
rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
  • 4. キーまでスクロールします。 最も安定したスクロールの基本操作です。アイテムを追加したり並べ替えたりしても、テストは壊れません。
rule.onNode(hasScrollToKeyAction()).performScrollToKey("key_10")

compose/ui/ui-test/src/androidDeviceTest/kotlin/androidx/compose/ui/test/actions/ScrollToKeyTest.kt:70-80 より引用。

  • 5. コンテナーにスクロールのセマンティクスがない場合は、生のジェスチャーでスクロールします。 タッチスワイプに切り替えます。
rule.onRoot().performTouchInput { swipeUp() }
// or, scoped to the list container:
rule.onNodeWithTag(LazyListTag).performTouchInput { swipeUp() }
  • 6. assertExists ではなく assertIsDisplayed で表示状態を確認します。 Lazy アイテムはコンポーズされていても画面外にある場合や、beyondBoundsItemCount に応じてコンポジションから完全に削除される場合があります。assertExists で確認できるのは、ノードがセマンティクスツリーに存在することだけです。

  • 7. .onChildren().assertCountEquals(N) で表示中の子を数えます。 これは現在マージ済みツリーに表示されているものを数えるものであり、バックエンドにあるすべてのアイテムを数えるわけではありません。

rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(3)
  • 8. 実際の状態を確認するには、runOnIdle 内で state.layoutInfo を読み取ります。 これは最も信頼性の高い調査方法です。
rule.runOnIdle {
    val keys = state.layoutInfo.visibleItemsInfo.map { it.key }
    assertThat(keys).isEqualTo(listOf(0))
}

compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListRequestScrollTest.kt:155-184 より引用(requestScrollToItem テストでは、state.firstVisibleItemIndex、state.firstVisibleItemScrollOffset、state.visibleKeys を読み取ります)。

  • 9. アイテム配置のアニメーションでは、mainClock.autoAdvance = false を設定し、フレームを手動で進めます。 skydoves のディレクティブ #3 を参照してください。
@Before
fun before() {
    rule.mainClock.autoAdvance = false
}

@Test
fun reorderTwoItems() {
    var list by mutableStateOf(listOf(0, 1))
    rule.setContent { LazyList { items(list, key = { it }) { Item(it) } } }

    assertPositions(0 to 0f, 1 to itemSize)
    rule.runOnUiThread { list = listOf(1, 0) }

    onAnimationFrame { fraction ->
        assertPositions(
            0 to 0 + itemSize * fraction,
            1 to itemSize - itemSize * fraction,
            fraction = fraction,
        )
    }
}

private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
    require(duration.mod(FrameDuration) == 0L)
    rule.waitForIdle()
    rule.mainClock.advanceTimeByFrame()
    var expectedTime = rule.mainClock.currentTime
    for (i in 0..duration step FrameDuration) {
        val fraction = i / duration.toFloat()
        onFrame(fraction)
        if (i < duration) {
            rule.mainClock.advanceTimeBy(FrameDuration)
            expectedTime += FrameDuration
            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
        }
    }
}

compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:120-148, 1724-1738 より引用。詳細は ../../synchronization/testing-animations-deterministically/SKILL.md を参照してください。

  • 10. 基底クラスを使って、縦方向と横方向をパラメーター化します。 BaseLazyListTestWithOrientation を参考に、LazyColumn と LazyRow のテストでスクロールヘルパーを共有します。
@RunWith(Parameterized::class)
class MyLazyTest(orientation: Orientation) : BaseLazyListTestWithOrientation(orientation) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{0}")
        fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
    }
}

compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt:55-114 より引用。

パターン

パターン:assertExists と assertIsDisplayed の比較
// WRONG
@Test
fun item7Visible() {
    rule.setContent { LazyColumn(Modifier.testTag(LazyListTag)) { items(100) { Item("item-$it") } } }
    rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
    rule.onNodeWithTag("item-7").assertExists()
}
// WRONG because: assertExists only proves the node is in the semantics tree. Lazy lists can
// keep prefetched / detached items in semantics; an item composed out of viewport will pass
// assertExists yet not be on-screen.
// RIGHT
@Test
fun item7Visible() {
    rule.setContent { LazyColumn(Modifier.testTag(LazyListTag)) { items(100) { Item("item-$it") } } }
    rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
    rule.onNodeWithTag("item-7").assertIsDisplayed()
}
パターン:アイテムのカウント — onChildren() と state.layoutInfo の比較
// WRONG
@Test
fun has100Items() {
    rule.setContent { LazyColumn(Modifier.testTag(LazyListTag)) { items(100) { … } } }
    rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(100)
}
// WRONG because: only the items currently composed appear as children. The viewport may
// contain three. The total item count lives on LazyListState, not the semantics tree.
// RIGHT
@Test
fun has100Items() {
    lateinit var state: LazyListState
    rule.setContent {
        state = rememberLazyListState()
        LazyColumn(Modifier.testTag(LazyListTag), state = state) { items(100) { Item(it) } }
    }
    rule.runOnIdle {
        assertThat(state.layoutInfo.totalItemsCount).isEqualTo(100)
        assertThat(state.layoutInfo.visibleItemsInfo).isNotEmpty()
    }
}
パターン:区別するにはタグでスクロールする
// WRONG
@Test
fun scrollList() {
    rule.setContent {
        Column {
            LazyColumn { items(20) { … } }                          // list A
            LazyColumn(Modifier.testTag(LazyListTag)) { items(20) { … } } // list B
        }
    }
    rule.onNode(hasScrollAction()).performScrollToIndex(5)
}
// WRONG because: hasScrollAction matches both lists, so onNode throws "found 2 nodes". Use the
// container's tag to disambiguate.
// RIGHT
rule.onNodeWithTag(LazyListTag).performScrollToIndex(5)
パターン:アイテムのテキストではなく、タグで検索する
// WRONG
rule.onNodeWithText("Snack #7").assertIsDisplayed()
// WRONG because: copy edits, plurals, and i18n break every test. Skydoves directive #1.
// RIGHT — tag each item by its stable key in production
items(snacks, key = { it.id }) { snack ->
    Row(Modifier.testTag(snack.id.toString())) { Text(snack.name) }
}
// then, in the test:
rule.onNodeWithTag(snack.id.toString()).assertIsDisplayed()

必須ルール

  • 本番ソースで const val として宣言した Modifier.testTag(...) を使って、コンテナにタグを付ける必要があります(skydoves の指針 #1)。表示されているテキストで遅延リストの項目を特定してはいけません。
  • 表示状態の確認には assertIsDisplayed/assertIsNotDisplayed を使う必要があります。項目が画面上にあることの証明に assertExists/assertDoesNotExist を使ってはいけません。
  • インデックスまたはキーに基づくスクロールには、セマンティクスのスクロールアクションである performScrollToIndex(...)/performScrollToKey(...) を使う必要があります。セマンティクスアクションは、Modifier.semantics を通じて LazyListState.scrollToItem を公開します。
  • state.layoutInfo は rule.runOnIdle { … } 内で読み取る必要があります。テストスレッドから直接読み取ってはいけません。
  • 項目配置アニメーションの中間フレームを検証するテストでは、必ず事前に rule.mainClock.autoAdvance = false を設定する必要があります。skydoves の指針 #3 と ../../synchronization/testing-animations-deterministically/SKILL.md を参照してください。
  • 合計数の確認に rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(totalItems) を呼び出してはいけません。子として表示されるのは、合成済みの項目だけです。
  • 推奨: 並べ替え可能またはページネーションされたコンテンツをテストする場合は、キーでスクロールします(performScrollToKey)。データが変化してもテストが機能します。
  • 推奨: テストを重複させる代わりに、基底クラス(BaseLazyListTestWithOrientation 形式)で Orientation.Vertical/Orientation.Horizontal をパラメーター化します。

検証

  • コンテナに Modifier.testTag(LazyListTag) があり、各項目に Modifier.testTag(<stable key>) があります。
  • 表示状態の確認にはすべて assertIsDisplayed/assertIsNotDisplayed を使っています。「画面上にある」ことを示す代わりに assertExists を使っていません。
  • スクロールには performScrollToIndex/performScrollToKey(推奨)、またはセマンティクスがない場合のフォールバックとして performTouchInput { swipeUp() } を使っています。
  • 項目ごとのインデックス/オフセットの検証では、runOnIdle 内で state.layoutInfo.visibleItemsInfo を読み取っています。
  • 項目配置アニメーションのテストでは、rule.mainClock.autoAdvance = false を設定し(通常は @Before 内)、フレームを手動で進めています。
  • リスト項目の特定に onNodeWithText("…") を使っていません。

参考資料

  • Compose テストのチートシート — ファインダーと遅延リスト:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • リストとグリッド:https://developer.android.com/develop/ui/compose/lists
  • キーを指定したスクロールの標準テスト:compose/ui/ui-test/src/androidDeviceTest/kotlin/androidx/compose/ui/test/actions/ScrollToKeyTest.kt:50-95
  • layoutInfo の標準プローブ:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListRequestScrollTest.kt:155-184
  • 項目ごとのテストタグとスクロール:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:141, 285-296
  • 項目配置アニメーションのテストハーネス:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:120-148, 1724-1738
  • 垂直/水平の基底クラス:compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt:55-114
  • hasScrollToKeyAction マッチャー:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt

コメント