mcpField
C
clicking-and-scrolling

clicking-and-scrolling

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

关于 clicking-and-scrolling

面向 Jetpack Compose UI 测试编写者的操作技能,介绍通过语义节点执行点击、滚动、聚焦和辅助功能操作。它覆盖懒加载容器定位及文本链接点击,帮助避免将操作施加到错误节点。

主要功能

  1. 01执行节点点击与滚动
  2. 02按索引、键或匹配项定位
  3. 03请求节点焦点
  4. 04触发自定义语义操作
  5. 05点击文本中的链接

适用场景

  1. 01滚动到屏幕外的内容再断言
  2. 02定位 LazyColumn 中的指定项目
  3. 03测试自定义辅助功能操作
  4. 04验证文本链接的点击行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill clicking-and-scrolling

安装后在 Claude Code 中输入/clicking-and-scrolling确认已加载。

详细介绍

点击和滚动 — 无需手势构造器即可操控 UI

Compose 的高级操作会解析为适合平台的基础操作,或解析为可组合项已公开的语义操作。只要测试不需要对手势进行像素级控制,就应使用这些操作。只有在高级操作无法表达意图时,才使用 performTouchInput { … }(参见 ../injecting-touch-gestures/SKILL.md)。

何时使用此技能

  • 开发者询问“如何在测试中点击 Button / IconButton / 可点击的 Box”。
  • 测试必须先显示某个节点才能对其进行断言(例如当前位于首屏下方的条目)。
  • 测试必须将 LazyColumn / LazyRow 滚动到特定索引、键或匹配项。
  • 测试必须触发焦点变化,或调用通过 Modifier.semantics { … } 声明的 AccessibilityAction<T>。
  • 测试必须点击 Text 中的 LinkAnnotation。

何时不使用此技能

  • 测试需要部分手势、多点触控、速度可控的快速滑动,或跨重组拆分的触摸序列。请使用 ../injecting-touch-gestures/SKILL.md。
  • 测试涉及悬停 / 滚轮 / 右键操作流程。请使用 ../injecting-mouse-and-keyboard/SKILL.md。
  • 测试需要输入文本或触发 IME 操作。请使用 ../entering-text/SKILL.md。
  • 节点查找持续失败。请先通过 ../../finders/finding-nodes-by-tag-text-content/SKILL.md 和 ../../debug/printing-the-semantics-tree/SKILL.md 验证匹配器。

前置条件

  • 按 ../../setup/configuring-test-dependencies/SKILL.md 配置 androidx.compose.ui:ui-test-junit4(或用于 runComposeUiTest 的 androidx.compose.ui:ui-test)。
  • 目标节点在生产代码中带有 Modifier.testTag("…")——skydoves 的热辣观点 #1。
  • 对于 performScrollToIndex / performScrollToKey:容器必须实现相应的语义操作。LazyColumn / LazyRow 支持这些操作;普通的 Modifier.verticalScroll 不支持。
  • 对于 performFirstLinkClick:Text 必须使用带有 LinkAnnotation 的 AnnotatedString。

工作流

  1. 选择正确的接收节点。 这是最常见的失败原因。performClick / performScrollTo / performFirstLinkClick 作用于内容节点;performScrollToIndex / performScrollToKey / performScrollToNode 作用于可滚动容器。如不确定,请查看 ../../debug/printing-the-semantics-tree/SKILL.md。

  2. 点击节点 — performClick()。在 Android 上(包括设备测试和 Robolectric 主机测试),它会通过 Android actual 将操作委托给 performTouchInput { click() }(Actions.android.kt:22-24)。鼠标点击路径位于 JetBrains 的 Compose Multiplatform 桌面分支中。通用 API 位于 Actions.kt:49-62。

rule.onNodeWithTag(SubmitButtonTag).performClick()
  1. 断言前先显示内容节点 — performScrollTo()。它会沿树向上查找最近一个带有 SemanticsActions.ScrollBy 的父节点,并滚动所需的最小距离,使节点完全进入视口(Actions.kt:78-87)。如果不存在滚动父节点,则抛出 AssertionError。
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
  1. 将惰性容器滚动到指定索引 — performScrollToIndex(index)。接收节点必须具有 SemanticsActions.ScrollToIndex(Actions.kt:159-172)。接收节点必须是容器(即 LazyColumn 本身),而不是某个条目。
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
  1. 滚动到指定键的条目 — performScrollToKey(key)。需要同时具备 IndexForKey 和 ScrollToIndex(Actions.kt:188-203)。它会匹配 LazyColumn / LazyRow 条目中的 key = { … } 参数。
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
  1. 滚动,直到匹配某个匹配器 — performScrollToNode(matcher)。它会在惰性容器中从头到尾逐个视口查找。对于非惰性可滚动容器,则在节点完成组合后回退到 performScrollTo(Actions.kt:233-268)。如果滚动到内容末尾仍未匹配,则抛出异常。
rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
  1. 将焦点移至节点 — requestFocus()。这是 performSemanticsAction(SemanticsActions.RequestFocus) 的简写(Actions.kt:600-601)。在 performIndirectPointerInput 前必须调用;对于绕过 performTextInput 自动聚焦路径的 BasicTextField 设置,也很有用。
rule.onNodeWithTag(InputTag).requestFocus()
  1. 调用自定义 AccessibilityAction<T> — 对于带参数的操作,使用 performSemanticsAction(key, invocation);对于无参数操作,使用 performSemanticsAction(key)(Actions.kt:631-672)。必须通过 Modifier.semantics { … } 在节点上声明该操作,否则会抛出 AssertionError。
val MyAction = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("MyAction")
// production:
Modifier.semantics { this[MyAction] = AccessibilityAction("Bump") { delta -> bump(delta); true } }
// test:
rule.onNodeWithTag(Tag).performSemanticsAction(MyAction) { it(3) }
  1. 点击 Text 中的 LinkAnnotation — performFirstLinkClick(predicate)(Actions.kt:777-804)。默认谓词 { true } 会点击第一个链接。如果接收节点没有文本,或没有链接满足谓词,则抛出异常。
rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }

模式

模式:滚动 LazyColumn — 选择正确的接收节点
// WRONG
rule.onNodeWithTag("item-7").performScrollToIndex(7)
// WRONG because: performScrollToIndex requires the ScrollToIndex semantics action,
// which lives on the LazyColumn container, not on its items. AssertionError:
// "Failed to scroll to index 7, the node is missing [ScrollToIndex]".
// RIGHT
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(7)
rule.onNodeWithTag("item-7").assertIsDisplayed()

匹配器 hasScrollToIndexAction() 定义在 Filters.kt 中,用于识别公开 SemanticsActions.ScrollToIndex 的任意容器。如果屏幕上有多个惰性列表,也要为容器添加标签。

模式:断言前显示非惰性条目
// WRONG
rule.onNodeWithTag(BottomBannerTag).assertIsDisplayed()
// WRONG because: a Column inside Modifier.verticalScroll renders all children, but
// children outside the viewport are clipped — assertIsDisplayed fails because
// the visible bounds intersect the viewport at zero pixels.
// RIGHT
rule.onNodeWithTag(BottomBannerTag).performScrollTo().assertIsDisplayed()

performScrollTo 会向上查找最近的 hasScrollAction() 父节点,并滚动所需的最小距离(Actions.kt:95-141)。对于惰性列表,优先使用 performScrollToNode,因为目标条目可能尚未组合。

模式:对任意内容使用 performScrollToNode
// RIGHT
rule.onNode(hasScrollToNodeAction()).performScrollToNode(
    hasText("Privacy Policy", substring = true)
)

匹配器 hasScrollToNodeAction() 同时支持惰性和非惰性可滚动容器(Filters.kt)。对于惰性容器,performScrollToNode 会先回到索引 0,然后按视口大小逐步滚动,直到匹配器命中。

模式:触发自定义语义操作

生产代码:

val Bump = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("Bump")

@Composable
fun Counter(value: Int, onBump: (Int) -> Unit) {
    Box(
        Modifier
            .testTag("counter")
            .semantics {
                this[Bump] = AccessibilityAction("Bump") { delta ->
                    onBump(delta); true
                }
            }
    ) { Text(value.toString()) }
}

测试代码:

@Test fun bumpAction_increments() {
    var value by mutableIntStateOf(0)
    rule.setContent { Counter(value) { value += it } }

    rule.onNodeWithTag("counter").performSemanticsAction(Bump) { it(5) }

    rule.runOnIdle { assertEquals(5, value) }
}

这是测试没有内置操作(例如 OnClick)的逻辑的最简洁方法——无需伪造触摸坐标,也不必依赖布局可点击。

模式:点击 Text 中的第一个链接
// RIGHT
@Test fun privacyLink_navigates() {
    var clicked = false
    rule.setContent {
        Text(
            buildAnnotatedString {
                append("Read our ")
                withLink(LinkAnnotation.Url("https://example.com/privacy") {
                    clicked = true
                }) { append("Privacy Policy") }
            },
            modifier = Modifier.testTag(BodyTag),
        )
    }

    rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }

    rule.runOnIdle { assertTrue(clicked) }
}

performFirstLinkClick 首先断言节点包含文本,然后收集 AnnotatedString 中的所有 LinkAnnotation,选出第一个满足谓词的链接,接着在对应的链接子节点上分发 OnClick(Actions.kt:777-804)。

模式:在间接指针输入前调用 requestFocus
// RIGHT
rule.onNodeWithTag(SurfaceTag).requestFocus()
rule.performIndirectPointerInput(                  // extension on SemanticsNodeInteractionsProvider, not on a node
    indirectPointerEventPrimaryDirectionalMotionAxis = Vertical,
    inputDeviceSize = IntSize(1000, 1000),
) {
    // events go to the focused tree
}

公开的 performIndirectPointerInput 扩展方法挂载在 SemanticsNodeInteractionsProvider 上(Actions.kt:862)——也就是说,挂载在规则(或 ComposeUiTest)本身上。SemanticsNodeInteraction 上同名的重载为 internal(Actions.kt:942),因此消费者代码中无法编译 rule.onRoot().performIndirectPointerInput(...)。

间接指针输入会通过焦点路径分发,因此必须显式调用 requestFocus(Actions.kt:807-875)。对于普通触摸测试,不需要焦点。

强制规则

  • 必须在可滚动容器上调用 performScrollToIndex、performScrollToKey 和 performScrollToNode,而不是在项目上调用——语义操作位于容器上。否则会出现 AssertionError: the node is missing [ScrollToIndex]。
  • 必须在内容节点上调用 performScrollTo,而不是在容器上调用——performScrollTo 会向上查找滚动父节点。
  • 必须通过 Modifier.testTag("…") 匹配节点,且其值必须是生产源代码中定义的常量——skydoves 的热评 #1。文本和内容描述查找器容易受国际化影响。
  • 必须通过操作修改状态后,使用 runOnIdle { … }(skydoves 的热评 #5)。直接在测试线程读取 state 会与 recomposer 产生竞态。
  • 不得在 LazyColumn 项目上调用 performScrollTo——该项目可能根本尚未组合。应在容器上使用 performScrollToIndex / performScrollToKey / performScrollToNode。
  • 不得依赖 performClick 来模拟悬停、右键单击或滚轮滚动。请使用 ../injecting-mouse-and-keyboard/SKILL.md 中特定输入方式的作用域。
  • **优先选择:**当屏幕上只有一个此类容器时,通过 hasScrollAction() / hasScrollToIndexAction() / hasScrollToKeyAction() / hasScrollToNodeAction()(Filters.kt)选择滚动容器,而不是为其添加多余的测试标记。

验证

  • 每次调用 performScrollToIndex / performScrollToKey / performScrollToNode 时,目标都是容器,而不是项目;并且通过标记或 hasScrollToIndexAction() 匹配器选择该容器。
  • 未使用 performClick 模拟悬停、右键单击或滚轮滚动。
  • 每次调用 performSemanticsAction 时,所引用的 SemanticsPropertyKey 都由生产代码通过 Modifier.semantics { … } 安装。
  • 操作后的状态断言在 rule.runOnIdle { … } 内读取状态。
  • 针对正在修改的测试,./gradlew :app:connectedDebugAndroidTest(Robolectric 测试则运行 :app:testDebugUnitTest)通过。

参考资料

评论

支持 Markdown