mcpField
T
testing-lazy-lists

testing-lazy-lists

github.com/skydoves测试与质量33960 次安装22 周前更新
代码仓库

关于 testing-lazy-lists

面向 Jetpack Compose 测试编写者的技能指南,讲解 Lazy 列表和网格的滚动、可见性断言及状态检查。它推荐通过测试标签和语义滚动定位项目,并使用 layoutInfo 获取布局状态。

主要功能

  1. 01为容器和项目设置测试标签
  2. 02按索引或键执行语义滚动
  3. 03用断言检查项目是否可见
  4. 04通过 layoutInfo 检查列表状态
  5. 05确定性测试项目位置动画

适用场景

  1. 01验证 LazyColumn 中项目的可见性
  2. 02按键定位重排列表中的项目
  3. 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

评论

支持 Markdown