SuperAPI AI Directory

testing-lazy-lists

Testing & quality

View repository

Updated 10-16 02:02

About this skill

Testing Lazy Lists — Tag the Container, Scroll by Semantics, Probe layoutInfo

LazyColumn / LazyRow / LazyVerticalGrid only compose items that are or were near the viewport. That breaks the naive "find by text and assert" flow because off-screen items may not exist, may exist as detached semantic nodes, or may exist with wrong bounds. This skill encodes the patterns androidx itself uses — tag the container, scroll via the semantic action or via testTag + a swipe gesture, verify with assertIsDisplayed, and read LazyListState.layoutInfo for ground truth.

When to use this skill

  • The developer is writing a test for any Lazy* composable (LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid, LazyVerticalStaggeredGrid).
  • A test asserts an item is "present" but the developer cannot tell whether it is on-screen.
  • The developer wants to programmatically scroll to index 7, key "key_10", or by an offset.
  • A test asserts on a per-item index/offset and needs the canonical state.layoutInfo shape.
  • The developer is testing Modifier.animateItem() or any item placement animation.
  • A reviewer asks why assertExists is wrong for a lazy item.

When NOT to use this skill

  • The list under test is a non-lazy Column/Row/FlowRow. Use ../structuring-a-compose-test/SKILL.md plus ../../assertions/asserting-node-state-and-text/SKILL.md.
  • The class skeleton itself is wrong; fix ../structuring-a-compose-test/SKILL.md first.
  • The scroll action is a custom Modifier.scrollable (not LazyListState-backed); use ../../actions/injecting-touch-gestures/SKILL.md.
  • The test is for animation timing; layer ../../synchronization/testing-animations-deterministically/SKILL.md.

Prerequisites

  • Class skeleton from ../structuring-a-compose-test/SKILL.md (@MediumTest + @RunWith(AndroidJUnit4::class) + createComposeRule(StandardTestDispatcher())).
  • A const tag for the container in production source (skydoves directive #1):
const val LazyListTag = "LazyListTag"
  • Each item carries Modifier.testTag(it) where it is the stable item key (matches compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:285-296).

Workflow

  • 1. Tag the container, tag each item by key. Tags belong in production source.
// production
LazyColumn(modifier = Modifier.testTag(LazyListTag), state = state) {
    items(snacks, key = { it.id }) { snack ->
        Row(Modifier.testTag(snack.id.toString())) { /* … */ }
    }
}
  • 2. Scroll to an index via the semantic action. PREFERRED for tests that do not care which Lazy* composable is under test.
rule.onNode(hasScrollAction()).performScrollToIndex(7)
  • 3. Scroll via the container tag. Use this when the test must disambiguate among multiple scrollable nodes.
rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
  • 4. Scroll to a key. This is the most stable scrolling primitive — items can be added or reordered without breaking the test.
rule.onNode(hasScrollToKeyAction()).performScrollToKey("key_10")

Cited from compose/ui/ui-test/src/androidDeviceTest/kotlin/androidx/compose/ui/test/actions/ScrollToKeyTest.kt:70-80.

  • 5. Scroll by raw gesture when the container has no scroll semantics. Fall back to a touch swipe.
rule.onRoot().performTouchInput { swipeUp() }
// or, scoped to the list container:
rule.onNodeWithTag(LazyListTag).performTouchInput { swipeUp() }
  • 6. Verify visibility with assertIsDisplayed, not assertExists. Lazy items can be composed but off-screen, or removed from composition entirely depending on beyondBoundsItemCount. assertExists only proves the node is in the semantics tree.

  • 7. Count visible children with .onChildren().assertCountEquals(N). This counts what the merged tree currently surfaces, NOT every backed item.

rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(3)
  • 8. For ground truth, read state.layoutInfo inside runOnIdle. This is the highest-signal probe.
rule.runOnIdle {
    val keys = state.layoutInfo.visibleItemsInfo.map { it.key }
    assertThat(keys).isEqualTo(listOf(0))
}

Cited from compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListRequestScrollTest.kt:155-184 (the requestScrollToItem test reads state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset, and state.visibleKeys).

  • 9. For animated item placement, set mainClock.autoAdvance = false and step frames manually. See skydoves directive #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)
        }
    }
}

Cited from compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:120-148, 1724-1738. Detail in ../../synchronization/testing-animations-deterministically/SKILL.md.

  • 10. Parameterize vertical vs horizontal with a base class. Mirror BaseLazyListTestWithOrientation to share scrolling helpers between LazyColumn and LazyRow tests.
@RunWith(Parameterized::class)
class MyLazyTest(orientation: Orientation) : BaseLazyListTestWithOrientation(orientation) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{0}")
        fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
    }
}

Cited from compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt:55-114.

Patterns

Pattern: assertExists vs 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()
}

Pattern: counting items — onChildren() vs 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()
    }
}

Pattern: scroll by tag for disambiguation

// 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)

Pattern: find by tag, never by item text

// 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()

Mandatory rules

  • MUST tag the container with Modifier.testTag(...) declared as a const val in production source (skydoves directive #1). MUST NOT find lazy items by their visible text.
  • MUST verify visibility with assertIsDisplayed / assertIsNotDisplayed. MUST NOT use assertExists / assertDoesNotExist to prove that an item is on-screen.
  • MUST use the semantic scroll action for index- or key-based scrolls: performScrollToIndex(...), performScrollToKey(...). The semantic action is what LazyListState.scrollToItem exposes via Modifier.semantics.
  • MUST read state.layoutInfo inside rule.runOnIdle { … }, never directly from the test thread.
  • MUST set rule.mainClock.autoAdvance = false before any test that asserts on intermediate frames of an item-placement animation. See skydoves directive #3 and ../../synchronization/testing-animations-deterministically/SKILL.md.
  • MUST NOT call rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(totalItems) to verify the total count — only composed items appear as children.
  • PREFERRED: scroll by key (performScrollToKey) when testing reorderable / paginated content; the test survives data churn.
  • PREFERRED: parameterize Orientation.Vertical / Orientation.Horizontal with a base class (BaseLazyListTestWithOrientation-style) instead of duplicating tests.

Verification

  • The container has Modifier.testTag(LazyListTag); each item has Modifier.testTag(<stable key>).
  • Every visibility assertion uses assertIsDisplayed / assertIsNotDisplayed. No assertExists is used as a stand-in for "on-screen".
  • Scrolling uses performScrollToIndex / performScrollToKey (preferred) or performTouchInput { swipeUp() } (fallback when no semantics).
  • Per-item index / offset assertions read state.layoutInfo.visibleItemsInfo inside runOnIdle.
  • Tests for item-placement animations set rule.mainClock.autoAdvance = false (typically in @Before) and step frames manually.
  • No onNodeWithText("…") is used to find a list item.

References

  • Compose testing cheat sheet — finders & lazy lists: https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • Lists and grids: https://developer.android.com/develop/ui/compose/lists
  • Canonical scroll-to-key test: compose/ui/ui-test/src/androidDeviceTest/kotlin/androidx/compose/ui/test/actions/ScrollToKeyTest.kt:50-95
  • Canonical layoutInfo probe: compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListRequestScrollTest.kt:155-184
  • Per-item test tags + scroll: compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:141, 285-296
  • Item-placement animation harness: compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt:120-148, 1724-1738
  • Vertical/horizontal base class: compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt:55-114
  • hasScrollToKeyAction matcher: compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt