用途
本技能用于正确测试 Compose 的惰性列表组件:LazyColumn、LazyRow、LazyVerticalGrid、LazyHorizontalGrid、LazyVerticalStaggeredGrid。由于惰性列表只组合位于或曾位于视口附近的项目,朴素地“按文本查找并断言”会失败——屏幕外的条目可能不存在、可能以分离的语义节点存在,或边界不正确。本技能编码了 androidx 自身使用的模式:为容器打标记、通过语义动作或 testTag 加滑动手势滚动、使用 assertIsDisplayed 验证,并读取 LazyListState.layoutInfo 获取真实情况。
何时使用
- 为任何 Lazy* 可组合项编写测试。
- 测试断言某条目“存在”,但无法判断它是否在屏幕上。
- 需要以编程方式滚动到索引 7、key "key_10" 或按偏移滚动。
- 需要断言每个条目的索引/偏移,并使用规范的 state.layoutInfo 结构。
- 测试 Modifier.animateItem() 或任何条目放置动画。
- 需要解释为什么对惰性条目使用 assertExists 是错误的。
何时不使用
- 被测列表是非惰性的 Column/Row/FlowRow。
- 类骨架本身有误,应先修复测试结构。
- 滚动动作是自定义的 Modifier.scrollable(非 LazyListState 支持)。
- 测试针对动画时序,应叠加确定性的动画测试方案。
前置条件
- 来自测试结构技能的类骨架:@MediumTest + @RunWith(AndroidJUnit4::class) + createComposeRule(StandardTestDispatcher())。
- 生产源码中为容器定义常量标签:
const val LazyListTag = "LazyListTag"
- 每个条目携带 Modifier.testTag(it),其中 it 是稳定的条目 key。
工作流程
- 为容器和每个条目打标签,标签属于生产源码:
// production
LazyColumn(modifier = Modifier.testTag(LazyListTag), state = state) {
items(snacks, key = { it.id }) { snack ->
Row(Modifier.testTag(snack.id.toString())) { /* … */ }
}
}
- 通过语义动作滚动到索引(不关心具体是哪个 Lazy* 组件时首选):
rule.onNode(hasScrollAction()).performScrollToIndex(7)
- 通过容器标签滚动(需要在多个可滚动节点中区分时使用):
rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
- 滚动到 key(最稳定的滚动原语,条目增删或重排不会破坏测试):
rule.onNode(hasScrollToKeyAction()).performScrollToKey("key_10")
- 当容器没有滚动语义时,用原始手势回退:
rule.onRoot().performTouchInput { swipeUp() }
// 或限定在列表容器上:
rule.onNodeWithTag(LazyListTag).performTouchInput { swipeUp() }
-
用 assertIsDisplayed 而非 assertExists 验证可见性。惰性条目可能已组合但在屏幕外,或因 beyondBoundsItemCount 被移出组合。assertExists 只证明节点在语义树中。
-
用 .onChildren().assertCountEquals(N) 统计可见子项,它统计的是当前合并树呈现的内容,并非所有后备条目:
rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(3)
- 获取真实数据时,在 runOnIdle 内读取 state.layoutInfo(信号最强的探针):
rule.runOnIdle {
val keys = state.layoutInfo.visibleItemsInfo.map { it.key }
assertThat(keys).isEqualTo(listOf(0))
}
- 测试条目放置动画时,设置 mainClock.autoAdvance = false 并手动推进帧:
@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)
}
}
}
- 用基类参数化垂直与水平方向,仿照 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)
}
}
常见模式
assertExists 与 assertIsDisplayed:错误做法是用 assertExists 证明条目在屏幕上,因为惰性列表可能保留预取或分离的条目在语义中,组合在视口外的条目能通过 assertExists 却不在屏幕上。正确做法是滚动后使用 assertIsDisplayed。
计数:onChildren() 与 state.layoutInfo:用 onChildren().assertCountEquals(100) 验证总数是错误的,因为只有当前组合的条目才会作为子项出现,总数存放在 LazyListState 而非语义树。正确做法是在 runOnIdle 内断言 state.layoutInfo.totalItemsCount。
按标签滚动以消除歧义:当页面有多个可滚动列表时,hasScrollAction() 会匹配到多个节点并抛出“found 2 nodes”。应使用容器标签进行区分。
按标签查找,绝不按条目文本:文案修改、复数形式与国际化会破坏每一个测试。应在生产源码中按稳定 key 为每个条目打标签,测试中使用该标签。
强制规则
- 必须用生产源码中声明为 const val 的 Modifier.testTag(...) 标记容器;不得按可见文本查找惰性条目。
- 必须用 assertIsDisplayed / assertIsNotDisplayed 验证可见性;不得用 assertExists / assertDoesNotExist 证明条目在屏幕上。
- 基于索引或 key 的滚动必须使用语义滚动动作 performScrollToIndex(...)、performScrollToKey(...)。
- 必须在 rule.runOnIdle { … } 内读取 state.layoutInfo,绝不直接从测试线程读取。
- 任何断言条目放置动画中间帧的测试,必须在之前设置 rule.mainClock.autoAdvance = false。
- 不得用 rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(totalItems) 验证总数——只有已组合的条目才会作为子项出现。
- 首选:测试可重排/分页内容时按 key 滚动(performScrollToKey),测试可承受数据变动。
- 首选:用基类参数化 Orientation.Vertical / Orientation.Horizontal,而不是复制测试。
验证清单
- 容器有 Modifier.testTag(LazyListTag);每个条目有 Modifier.testTag(<稳定 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