SuperAPI AI 工具集

testing-lazy-lists

测试与质量

查看项目

更新于 10-16 02:02

技能介绍

用途

本技能用于正确测试 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。

工作流程

  1. 为容器和每个条目打标签,标签属于生产源码:
// production
LazyColumn(modifier = Modifier.testTag(LazyListTag), state = state) {
    items(snacks, key = { it.id }) { snack ->
        Row(Modifier.testTag(snack.id.toString())) { /* … */ }
    }
}
  1. 通过语义动作滚动到索引(不关心具体是哪个 Lazy* 组件时首选):
rule.onNode(hasScrollAction()).performScrollToIndex(7)
  1. 通过容器标签滚动(需要在多个可滚动节点中区分时使用):
rule.onNodeWithTag(LazyListTag).performScrollToIndex(7)
  1. 滚动到 key(最稳定的滚动原语,条目增删或重排不会破坏测试):
rule.onNode(hasScrollToKeyAction()).performScrollToKey("key_10")
  1. 当容器没有滚动语义时,用原始手势回退:
rule.onRoot().performTouchInput { swipeUp() }
// 或限定在列表容器上:
rule.onNodeWithTag(LazyListTag).performTouchInput { swipeUp() }
  1. 用 assertIsDisplayed 而非 assertExists 验证可见性。惰性条目可能已组合但在屏幕外,或因 beyondBoundsItemCount 被移出组合。assertExists 只证明节点在语义树中。

  2. 用 .onChildren().assertCountEquals(N) 统计可见子项,它统计的是当前合并树呈现的内容,并非所有后备条目:

rule.onNodeWithTag(LazyListTag).onChildren().assertCountEquals(3)
  1. 获取真实数据时,在 runOnIdle 内读取 state.layoutInfo(信号最强的探针):
rule.runOnIdle {
    val keys = state.layoutInfo.visibleItemsInfo.map { it.key }
    assertThat(keys).isEqualTo(listOf(0))
}
  1. 测试条目放置动画时,设置 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)
        }
    }
}
  1. 用基类参数化垂直与水平方向,仿照 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