7、列表测试:LazyColumn测试、滚动测试、列表项查找
列表,是移动应用中最常见的UI组件之一。你想想看,从朋友圈到商品列表,从聊天记录到设置页面,几乎每个App都离不开列表。在Compose中,我们用LazyColumn来构建高效的长列表。但问题来了——列表项那么多,怎么测?滚动到底部怎么验证?动态加载的数据怎么定位?
这一章,我们就来聊聊列表测试的那些事儿。我会结合我自己的踩坑经历,把LazyColumn的测试方法掰开揉碎讲清楚。
7.1 LazyColumn的测试基础
先说说LazyColumn和普通Column的区别。普通Column会把所有子项一次性全部组合(compose)出来,而LazyColumn只组合当前可见区域内的项。这意味着,你在测试中不能简单地用onNodeWithText("第100项")去直接查找——因为第100项可能根本没被渲染。
嗯,这里要注意:测试框架只能找到当前已经组合到视图树中的节点。这是LazyColumn测试的核心难点。
核心原则:测试LazyColumn时,必须先确保目标项已经进入可见区域,然后再进行查找和断言。
来看一个简单的例子。假设我们有这样一个列表:
@Composable
fun NumberList() {
LazyColumn {
items(100) { index ->
Text(text = "第 ${index + 1} 项")
}
}
}
测试代码怎么写?我个人的习惯是先用performScrollToIndex滚动到目标位置:
@Test
fun testScrollToItem() {
composeTestRule.setContent {
NumberList()
}
// 滚动到第50项
composeTestRule.onNodeWithTag("numberList")
.performScrollToIndex(49)
// 现在第50项已经可见,可以查找了
composeTestRule.onNodeWithText("第 50 项")
.assertIsDisplayed()
}
这里我给LazyColumn加了一个testTag,方便定位。记得在Composable中加上:
LazyColumn(modifier = Modifier.testTag("numberList")) {
// ...
}
小技巧:如果你不想给LazyColumn加testTag,也可以用onAllNodes(hasScrollToIndexAction())来找到可滚动的节点。但我建议还是加tag,更清晰,也更稳定。
7.2 滚动测试:不只是滚到目标位置
滚动测试听起来简单,但实际项目中坑不少。我在项目中遇到过一个问题:列表项是异步加载的,滚动到某个位置时,数据还没加载完,测试就断言失败了。
为什么会这样?因为performScrollToIndex只是触发了滚动动作,它不会等待异步数据加载完成。你需要配合waitUntil或waitForIdle来等待。
来看一个更完整的滚动测试示例:
@Test
fun testScrollAndWaitForData() {
composeTestRule.setContent {
MyAsyncList()
}
// 滚动到底部
composeTestRule.onNodeWithTag("myList")
.performScrollToIndex(99)
// 等待数据加载完成
composeTestRule.waitUntil(5000) {
composeTestRule
.onAllNodesWithText("第 100 项")
.fetchSemanticsNodes()
.isNotEmpty()
}
// 断言底部项可见
composeTestRule.onNodeWithText("第 100 项")
.assertIsDisplayed()
}
除了performScrollToIndex,还有几个常用的滚动操作:
| 操作 | 说明 | 适用场景 |
|---|---|---|
performScrollToIndex(index) |
滚动到指定索引 | 已知目标项的位置 |
performScrollToKey(key) |
滚动到指定key | 使用key标识列表项时 |
performScrollToNode(matcher) |
滚动到匹配的节点 | 不知道索引,只知道内容 |
performTouchInput { swipeUp() } |
模拟手势滑动 | 需要精确控制滑动距离 |
注意:performScrollToNode虽然方便,但它的实现原理是遍历所有可见项,找到匹配的节点后滚动过去。如果目标项不在当前可见区域内,它可能会先触发滚动,再查找。性能上不如performScrollToIndex直接。
7.3 列表项查找:多种匹配策略
查找列表项,说白了就是找到你关心的那个节点。但列表项往往结构复杂,可能包含图片、文字、按钮等多种元素。怎么精准定位?
我总结了三种常用的查找策略:
策略一:按文本查找
最简单直接,适合列表项中有唯一文本的场景:
composeTestRule.onNodeWithText("张三")
.assertIsDisplayed()
但如果列表中有多个"张三"呢?比如一个联系人列表,名字相同但电话号码不同。这时候就需要更精确的匹配。
策略二:按父节点+子节点组合查找
利用hasParent和hasChild语义匹配器,可以构建层级关系:
// 查找父节点包含"张三",子节点包含"138xxxx"的列表项
composeTestRule.onNode(
hasParent(
hasText("张三")
) and hasChild(
hasText("138xxxx")
)
).assertIsDisplayed()
这种写法虽然有点长,但非常精确。我在测试复杂列表时经常用。
策略三:按自定义语义属性查找
如果你给列表项加了自定义的testTag,那就更简单了:
// 在Composable中
ListItem(
modifier = Modifier.testTag("contact_item_${contact.id}")
)
// 在测试中
composeTestRule.onNodeWithTag("contact_item_123")
.assertIsDisplayed()
我的建议:给每个列表项加上唯一的testTag,尤其是那些需要频繁测试的项。虽然多写几行代码,但测试的稳定性和可读性会大幅提升。
7.4 实战:测试一个带加载更多的列表
好了,理论讲完了,我们来个实战。假设我们有一个聊天列表,每次滚动到底部会加载更多历史消息。测试要验证:滚动到底部后,新消息是否加载并显示。
先看Composable的简化代码:
@Composable
fun ChatList(messages: List<String>, onLoadMore: () -> Unit) {
val listState = rememberLazyListState()
LaunchedEffect(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index) {
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
if (lastVisibleIndex >= messages.size - 2) {
onLoadMore()
}
}
LazyColumn(state = listState, modifier = Modifier.testTag("chatList")) {
items(messages) { msg ->
Text(text = msg, modifier = Modifier.testTag("msg_$msg"))
}
}
}
测试代码:
@Test
fun testLoadMoreOnScrollToBottom() {
// 初始数据:20条消息
val messages = mutableListOf<String>()
for (i in 1..20) {
messages.add("消息 $i")
}
composeTestRule.setContent {
ChatList(
messages = messages,
onLoadMore = {
// 模拟加载更多:追加10条
for (i in 21..30) {
messages.add("消息 $i")
}
}
)
}
// 滚动到最后一条可见消息
composeTestRule.onNodeWithTag("chatList")
.performScrollToIndex(19)
// 等待加载更多完成
composeTestRule.waitForIdle()
// 验证新加载的消息出现了
composeTestRule.onNodeWithTag("msg_消息 21")
.assertIsDisplayed()
// 再滚动到新加载的最后一条
composeTestRule.onNodeWithTag("chatList")
.performScrollToIndex(29)
composeTestRule.onNodeWithTag("msg_消息 30")
.assertIsDisplayed()
}
这个测试覆盖了三个关键点:
- 滚动触发加载更多逻辑
- 等待异步数据更新
- 验证新数据正确显示
我曾经踩过的坑:一开始我用performTouchInput { swipeUp() }来模拟滑动,但滑动距离很难控制,有时候滑过了,有时候没滑到位。后来改用performScrollToIndex,测试稳定多了。除非你要测试手势交互本身,否则优先用索引滚动。
7.5 知识体系总览
说了这么多,我们来梳理一下列表测试的核心脉络。下面这张图可以帮你快速回顾:
这张图把列表测试的三个核心维度串起来了:滚动操作让你能到达目标位置,查找策略帮你精准定位目标节点,等待策略确保异步操作完成。三者缺一不可。
7.6 写在最后
列表测试,说白了就是解决「看不见就测不到」的问题。你只要记住一个口诀:先滚动,再等待,最后查。这个顺序别搞反了,不然测试就会不稳定。
我在项目中见过不少同事,写列表测试时直接onNodeWithText一把梭,结果测试跑起来一会儿过一会儿不过,排查半天才发现是滚动没到位。嗯,这种坑踩过一次就记住了。
下一章我们会聊聊更高级的测试技巧——自定义语义匹配器和性能测试。不过那是后话了,先把列表测试练熟再说。
公众号:蓝海资料掘金营,微信deep3321