关于 clicking-and-scrolling
面向 Jetpack Compose UI 测试编写者的操作技能,介绍通过语义节点执行点击、滚动、聚焦和辅助功能操作。它覆盖懒加载容器定位及文本链接点击,帮助避免将操作施加到错误节点。
主要功能
- 01执行节点点击与滚动
- 02按索引、键或匹配项定位
- 03请求节点焦点
- 04触发自定义语义操作
- 05点击文本中的链接
适用场景
- 01滚动到屏幕外的内容再断言
- 02定位 LazyColumn 中的指定项目
- 03测试自定义辅助功能操作
- 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。
工作流
-
选择正确的接收节点。 这是最常见的失败原因。
performClick/performScrollTo/performFirstLinkClick作用于内容节点;performScrollToIndex/performScrollToKey/performScrollToNode作用于可滚动容器。如不确定,请查看../../debug/printing-the-semantics-tree/SKILL.md。 -
点击节点 —
performClick()。在 Android 上(包括设备测试和 Robolectric 主机测试),它会通过 Androidactual将操作委托给performTouchInput { click() }(Actions.android.kt:22-24)。鼠标点击路径位于 JetBrains 的 Compose Multiplatform 桌面分支中。通用 API 位于 Actions.kt:49-62。
rule.onNodeWithTag(SubmitButtonTag).performClick()
- 断言前先显示内容节点 —
performScrollTo()。它会沿树向上查找最近一个带有SemanticsActions.ScrollBy的父节点,并滚动所需的最小距离,使节点完全进入视口(Actions.kt:78-87)。如果不存在滚动父节点,则抛出AssertionError。
rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
- 将惰性容器滚动到指定索引 —
performScrollToIndex(index)。接收节点必须具有SemanticsActions.ScrollToIndex(Actions.kt:159-172)。接收节点必须是容器(即LazyColumn本身),而不是某个条目。
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
- 滚动到指定键的条目 —
performScrollToKey(key)。需要同时具备IndexForKey和ScrollToIndex(Actions.kt:188-203)。它会匹配LazyColumn/LazyRow条目中的key = { … }参数。
rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
- 滚动,直到匹配某个匹配器 —
performScrollToNode(matcher)。它会在惰性容器中从头到尾逐个视口查找。对于非惰性可滚动容器,则在节点完成组合后回退到performScrollTo(Actions.kt:233-268)。如果滚动到内容末尾仍未匹配,则抛出异常。
rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
- 将焦点移至节点 —
requestFocus()。这是performSemanticsAction(SemanticsActions.RequestFocus)的简写(Actions.kt:600-601)。在performIndirectPointerInput前必须调用;对于绕过performTextInput自动聚焦路径的BasicTextField设置,也很有用。
rule.onNodeWithTag(InputTag).requestFocus()
- 调用自定义
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) }
- 点击
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)通过。
参考资料
- Compose 测试概览:https://developer.android.com/develop/ui/compose/testing
- Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Compose 中的语义:https://developer.android.com/develop/ui/compose/accessibility/semantics
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt—performClick(Actions.kt:58)、performScrollTo(Actions.kt:78)、performScrollToIndex(Actions.kt:159)、performScrollToKey(Actions.kt:188)、performScrollToNode(Actions.kt:233)、requestFocus(Actions.kt:600)、performSemanticsAction(Actions.kt:631 / Actions.kt:668)、performFirstLinkClick(Actions.kt:777)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt—hasScrollAction、hasScrollToIndexAction、hasScrollToKeyAction、hasScrollToNodeAction。compose/foundation/foundation/integration-tests/lazy-tests/.../LazyListTest.kt—performScrollToIndex/performScrollToKey的典型用法。compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt—performClick、performTouchInput { longClick() }。- skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills