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.layoutInfoshape. - The developer is testing
Modifier.animateItem()or any item placement animation. - A reviewer asks why
assertExistsis 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.mdplus../../assertions/asserting-node-state-and-text/SKILL.md. - The class skeleton itself is wrong; fix
../structuring-a-compose-test/SKILL.mdfirst. - The scroll action is a custom
Modifier.scrollable(notLazyListState-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)whereitis the stable item key (matchescompose/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, notassertExists. Lazy items can be composed but off-screen, or removed from composition entirely depending onbeyondBoundsItemCount.assertExistsonly 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.layoutInfoinsiderunOnIdle. 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 = falseand 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
BaseLazyListTestWithOrientationto share scrolling helpers betweenLazyColumnandLazyRowtests.
@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 aconst valin production source (skydoves directive #1). MUST NOT find lazy items by their visible text. - MUST verify visibility with
assertIsDisplayed/assertIsNotDisplayed. MUST NOT useassertExists/assertDoesNotExistto 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 whatLazyListState.scrollToItemexposes viaModifier.semantics. - MUST read
state.layoutInfoinsiderule.runOnIdle { … }, never directly from the test thread. - MUST set
rule.mainClock.autoAdvance = falsebefore 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.Horizontalwith a base class (BaseLazyListTestWithOrientation-style) instead of duplicating tests.
Verification
- The container has
Modifier.testTag(LazyListTag); each item hasModifier.testTag(<stable key>). - Every visibility assertion uses
assertIsDisplayed/assertIsNotDisplayed. NoassertExistsis used as a stand-in for "on-screen". - Scrolling uses
performScrollToIndex/performScrollToKey(preferred) orperformTouchInput { swipeUp() }(fallback when no semantics). - Per-item index / offset assertions read
state.layoutInfo.visibleItemsInfoinsiderunOnIdle. - 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 hasScrollToKeyActionmatcher:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt