关于 traversing-the-semantics-tree
这项 Jetpack Compose 测试技能用于在单个查找器无法定位节点时遍历语义树。内容涵盖父、子、兄弟和祖先选择器、集合筛选,以及 LazyColumn 可见性限制。
主要功能
- 01遍历父、子及兄弟节点
- 02筛选节点集合
- 03沿用合并树设置
- 04处理惰性列表快照
适用场景
- 01查找没有稳定 testTag 的节点
- 02断言节点之间的结构关系
- 03筛选出唯一匹配节点
- 04滚动后测试 LazyColumn 中的项目
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill traversing-the-semantics-tree安装后在 Claude Code 中输入/traversing-the-semantics-tree确认已加载。
详细介绍
遍历语义树 — 单个查找器无法到达目标节点时
单个查找器快捷方式(onNodeWithTag 等)可覆盖约 95% 的测试查询。其余情况需要遍历树:“这个 Text 的父节点”“这个 Row 的第三个子节点”“任意一个已启用的兄弟节点”。本技能介绍这些导航操作,指出 LazyColumn 快照的注意事项,并说明何时不该使用遍历。
何时使用本技能
- 目标节点没有稳定的
testTag,且无法在生产代码中添加(第三方可组合项、动态生成的子节点)。 - 测试要验证结构关系(“该行的第二个子节点是图标”)。
- 开发者链式调用
.onChildren()[i].onChildAt(j),并想知道这样的写法是否合适。 - 开发者提到
onChildren、onChild、onParent、onSibling、onSiblings、onAncestors、filterToOne、onFirst、onLast或[index]。 LazyColumn测试因项目位于屏幕外而漏掉项目。
何时不使用本技能
- 可以向目标节点添加
Modifier.testTag(...)时——添加标签几乎总比使用遍历链更简洁。请参阅../finding-nodes-by-tag-text-content/SKILL.md。 - 关系是“任意上方”或“任意下方”时——优先使用基于匹配器的
hasAnyAncestor/hasAnyDescendant(请参阅../composing-semantics-matchers/SKILL.md)。 - 查询的是按 key 定位的 LazyColumn 项目时——请改用
performScrollToKey(请参阅../../actions/clicking-and-scrolling/SKILL.md、../../patterns/testing-lazy-lists/SKILL.md)。
先决条件
- 可用的
ComposeTestRule/ComposeUiTest。请参阅../../setup/configuring-test-dependencies/SKILL.md。 - 熟悉合并树与未合并树的区别。请参阅
../finding-nodes-by-tag-text-content/SKILL.md。
工作流
-
1. 根据关系类型选择导航器。 每个操作符都会返回
SemanticsNodeInteraction(单个节点——匹配 0 个或多个节点时会失败)或SemanticsNodeInteractionCollection(多个节点——匹配 0 个节点时不会失败)。从单个节点前往…… API 返回值 文件:行号 父节点 onParent()单个节点 Selectors.kt:36-42唯一的子节点 onChild()单个节点 Selectors.kt:71-77指定索引处的子节点 onChildAt(index)单个节点 Selectors.kt:85所有当前已组合的子节点 onChildren()集合 Selectors.kt:53-59唯一的同级节点 onSibling()单个节点 Selectors.kt:119-125所有同级节点 onSiblings()集合 Selectors.kt:101-107一直到根节点的所有祖先 onAncestors()集合 Selectors.kt:140-146从集合缩小到…… API 返回值 文件:行号 第一个 onFirst()(=[0])单个节点 Selectors.kt:156-158最后一个 onLast()单个节点 Selectors.kt:168-170第 n 个 [index]单个节点 SemanticsNodeInteraction.kt:261-267筛选出子集合 filter(matcher)集合 Selectors.kt:178-186筛选出唯一一个 filterToOne(matcher)单个节点 Selectors.kt:198-206 -
2. 不要查找
onAncestor(单数)——它并不存在。 API 只提供复数形式的onAncestors()(Selectors.kt:140-146)。它按[parent, grandparent, …, root]的顺序返回节点。要断言“直接父节点满足 X”,请使用onParent().assert(matcherX)或hasParent(matcherX)谓词。 -
3. 记住
useUnmergedTree标志会一直传递。 每个导航器都沿用源SemanticsNodeInteraction的useUnmergedTree值(SemanticsNodeInteraction.kt:42-49;每个Selectors.kt构造函数都会原样传递useUnmergedTree)。使用onNodeWithTag(tag, useUnmergedTree = true)创建的查找器,在调用onChild/onParent/filter后仍会保留该标志。Skydoves 的犀利观点 #2:默认使用合并树,仅在需要定位组合细节时切换为未合并树。 -
4.
onChildren()会在调用时生成快照。 它会返回“当前存在于语义树中的”节点(Selectors.kt:46-52)。对于LazyColumn或LazyRow,只会显示屏幕上可见的条目。要访问屏幕外的条目,请先使用performScrollToIndex/performScrollToKey/performScrollToNode滚动(参见../../actions/clicking-and-scrolling/SKILL.md)。 -
5. 通常重新按标签查找比导航更可靠。 如果树结构可能随 Compose 版本变化或翻译轮换而改变,那么在目标节点上设置稳定标签,比使用
[2].onChildAt(0)链更持久。请在生产代码中添加标签。Skydoves 的犀利观点 #1。 -
6. 当数量断言隐含在操作中时,使用
filterToOne(matcher)。filterToOne会在获取时遇到 0 个或多个匹配项时抛出异常,与onNode(matcher)一样(Selectors.kt:198-206)。它相当于onNode(matcher)的集合缩小版本。
模式
模式:优先使用标签,而非深层 [index] 链
// WRONG
@Test
fun second_avatar_isVisible() {
rule.setContent { ProfileGrid(profiles = profiles) }
rule.onNodeWithTag("ProfileGrid")
.onChildren()[2]
.onChildren()[0]
.assertIsDisplayed()
}
// WRONG because: layout shuffling (a header inserted, an extra wrapper, a Spacer added)
// silently changes which node is being asserted. The test passes for the wrong reason.
// RIGHT
// production:
@Composable
fun ProfileGrid(profiles: List<Profile>) {
LazyColumn(modifier = Modifier.testTag(ProfileGridTag)) {
itemsIndexed(profiles) { index, profile ->
Row(modifier = Modifier.testTag("$AvatarTagPrefix$index")) {
Avatar(profile, modifier = Modifier.testTag("$AvatarImageTagPrefix$index"))
}
}
}
}
// test:
rule.onNodeWithTag("$AvatarImageTagPrefix${1}").assertIsDisplayed()
模式:没有可用标签时进行遍历
@Test
fun row_third_child_isIcon() {
rule.setContent {
Row(modifier = Modifier.testTag("toolbar")) {
Text("Title")
Spacer(Modifier.weight(1f))
Icon(Icons.Default.Share, contentDescription = "Share")
}
}
rule.onNodeWithTag("toolbar")
.onChildren()
.assertCountEquals(3)
rule.onNodeWithTag("toolbar")
.onChildAt(2)
.assertContentDescriptionEquals("Share")
}
onChildAt(index) 与 onChildren()[index] 完全相同(Selectors.kt:85)。如果索引超出范围,或叶节点解析出的节点数不恰好为 1,两者都会失败。
模式:使用 filterToOne,而非 [i]
// WRONG
rule.onAllNodesWithTag(RowTag).onChildren().filter(hasClickAction())[0]
.assertHasClickAction()
// WRONG because: indexing into a filtered collection silently passes when the filter returns
// many. The test asserts only "at least one is clickable", not "exactly one".
// RIGHT
rule.onAllNodesWithTag(RowTag).onChildren()
.filterToOne(hasClickAction())
.assertHasClickAction()
模式:根据已知子节点断言父节点角色
@Test
fun submitText_isInsideEnabledButton() {
rule.setContent {
Button(onClick = {}, modifier = Modifier.testTag("submit"), enabled = true) {
Text("Submit", modifier = Modifier.testTag("submitLabel"))
}
}
rule.onNodeWithTag("submitLabel", useUnmergedTree = true)
.onParent() // sticky: stays unmerged
.assertIsEnabled()
.assertHasClickAction()
}
内层 Text 查找器设置的 useUnmergedTree = true 会自动传递给 onParent(),无需重复设置。
模式:使用 onAncestors() 获取到根节点的祖先链
@Test
fun confirmButton_isInsideDialog() {
rule.setContent { ConfirmDialog() }
rule.onNodeWithTag(ConfirmButtonTag)
.onAncestors()
.filterToOne(isDialog())
.assertExists()
}
首选:rule.onNode(hasTestTag(ConfirmButtonTag) and hasAnyAncestor(isDialog())).assertExists() —— 用一个匹配器表达相同意图。参见 ../composing-semantics-matchers/SKILL.md。
模式:LazyColumn — 仅显示屏幕上的子项
// WRONG
rule.onNodeWithTag(ListTag).onChildren().assertCountEquals(1000)
// WRONG because: onChildren() returns a snapshot of currently composed children. A LazyColumn
// only composes the visible viewport plus a small prefetch buffer, so the count is window-sized,
// not data-set-sized.
// RIGHT — assert the visible count, OR scroll first then assert by tag
rule.onNodeWithTag(ListTag).performScrollToIndex(999)
rule.onNodeWithTag("$ItemTagPrefix${999}").assertIsDisplayed()
完整的 LazyColumn 工作流请参阅 ../../patterns/testing-lazy-lists/SKILL.md。
模式:使用 onSibling() 断言“该行的另一半”
@Test
fun checkbox_label_isPresent() {
rule.setContent {
Row {
Checkbox(checked = true, onCheckedChange = {},
modifier = Modifier.testTag("agreeBox"))
Text("I agree", modifier = Modifier.testTag("agreeLabel"))
}
}
rule.onNodeWithTag("agreeBox")
.onSibling()
.assertTextEquals("I agree")
}
onSibling() 要求恰好有一个同级项(Selectors.kt:114-125)。对于多个同级项,请使用 onSiblings() 和 filterToOne(...)。
强制规则
- 必须优先为目标节点添加
Modifier.testTag(...),而不是使用多步遍历链。Skydoves 的热门观点 #1。 - 必须在契约要求“恰好匹配一个”时使用
filterToOne(matcher),而不是使用filter(matcher).onFirst();前者在匹配数大于 1 时会抛出异常,后者则会静默选取第一个。 - 禁止假设
onChildren()会返回LazyColumn/LazyRow的完整数据集——它只会返回当前已组合的快照。请先使用performScrollToIndex/performScrollToKey滚动。 - 禁止查找单数形式的
onAncestor——只有复数形式的onAncestors()。对于直接父项,请使用onParent();也可使用hasParent(matcher)/hasAnyAncestor(matcher)作为谓词。 - 必须牢记
useUnmergedTree标志在onChild/onParent/filter中会保持不变。Skydoves 的热门观点 #2。 - **优先:**对于“上方/下方任意位置”的关系,请用
../composing-semantics-matchers/SKILL.md中的hasAnyAncestor/hasAnyDescendant替换遍历链。
验证
- 每条遍历链最多包含一个导航步骤,或者通过注释说明更深层链的合理性,并指出缺少的标签。
- 没有任何
onChildren()[i]链跨越LazyColumn/LazyRow边界而未事先调用performScrollTo*。 - 未使用
onAncestor(单数形式)——仅使用onParent、onAncestors或hasAnyAncestor。 - 在契约要求“恰好匹配一个”的所有情况下都使用了
filterToOne;filter().onFirst()均已替换。 - 链根节点上的
useUnmergedTree = true是有意设置的——并有注释或匹配器理由说明。 -
./gradlew :app:connectedDebugAndroidTest或: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/Selectors.kt—onParent、onChildren、onChild、onChildAt、onSibling(s)、onAncestors、onFirst、onLast、filter、filterToOne。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt— 集合[index]运算符和保持不变的useUnmergedTree字段。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt—hasParent、hasAnyChild、hasAnySibling、hasAnyAncestor、hasAnyDescendant,这些谓词可作为遍历的替代方案。