关于 testing-lazy-lists
面向 Jetpack Compose 测试编写者的技能指南,讲解 Lazy 列表和网格的滚动、可见性断言及状态检查。它推荐通过测试标签和语义滚动定位项目,并使用 layoutInfo 获取布局状态。
主要功能
- 01为容器和项目设置测试标签
- 02按索引或键执行语义滚动
- 03用断言检查项目是否可见
- 04通过 layoutInfo 检查列表状态
- 05确定性测试项目位置动画
适用场景
- 01验证 LazyColumn 中项目的可见性
- 02按键定位重排列表中的项目
- 03检查可见项目及其布局信息
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill testing-lazy-lists安装后在 Claude Code 中输入/testing-lazy-lists确认已加载。
详细介绍
测试懒加载列表——标记容器、通过语义滚动、检查 layoutInfo
LazyColumn / LazyRow / LazyVerticalGrid 只会组合位于或曾位于视口附近的项。这会打破简单的“按文本查找并断言”流程,因为屏幕外的项可能不存在、可能作为已脱离的语义节点存在,也可能存在但边界错误。本技能总结了 androidx 自己使用的模式——标记容器,通过语义操作或 testTag + 滑动手势进行滚动,通过 assertIsDisplayed 验证,并读取 LazyListState.layoutInfo 获取实际状态。
何时使用本技能
- 开发者正在为任何
Lazy*可组合项(LazyColumn、LazyRow、LazyVerticalGrid、LazyHorizontalGrid、LazyVerticalStaggeredGrid)编写测试。 - 测试断言某个项“存在”,但开发者无法判断它是否在屏幕上。
- 开发者想以编程方式滚动到索引 7、键
"key_10",或按偏移量滚动。 - 测试对每项的索引 / 偏移量进行断言,需要标准的
state.layoutInfo结构。 - 开发者正在测试
Modifier.animateItem()或任何项放置动画。 - 代码审查者询问为什么
assertExists不适用于懒加载项。
何时不使用本技能
- 被测列表是非懒加载的
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"
- 每个项都带有
Modifier.testTag(it),其中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. 使用
assertIsDisplayed而不是assertExists验证可见性。 惰性条目可能已组合但位于屏幕外,也可能因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()
强制规则
- 必须在生产源代码中将容器标记为
Modifier.testTag(...),并将其声明为const val(skydoves 指令 #1)。禁止通过可见文本查找懒加载项。 - 必须使用
assertIsDisplayed/assertIsNotDisplayed验证可见性。禁止使用assertExists/assertDoesNotExist证明某项显示在屏幕上。 - 必须对基于索引或键的滚动使用语义滚动操作:
performScrollToIndex(...)、performScrollToKey(...)。语义操作是LazyListState.scrollToItem通过Modifier.semantics暴露的功能。 - 必须在
rule.runOnIdle { … }中读取state.layoutInfo,绝不能直接从测试线程读取。 - 必须在任何断言列表项放置动画中间帧的测试之前设置
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