testing-lazy-lists について
Jetpack Compose のテスト作成者向けに、Lazy リストやグリッドのスクロール、表示状態のアサーション、状態確認を解説するスキルです。タグとセマンティクスによるスクロールで項目を特定し、layoutInfo でレイアウト状態を調べます。
主な機能
- 01コンテナと項目へのテストタグ付け
- 02インデックスやキーによるセマンティックスクロール
- 03項目の表示状態のアサーション
- 04layoutInfo によるリスト状態の確認
- 05項目配置アニメーションの決定的なテスト
活用シーン
- 01LazyColumn 内の項目表示を検証
- 02並べ替え後にキーで項目を特定
- 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