mcpField
T
traversing-the-semantics-tree

traversing-the-semantics-tree

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

关于 traversing-the-semantics-tree

这项 Jetpack Compose 测试技能用于在单个查找器无法定位节点时遍历语义树。内容涵盖父、子、兄弟和祖先选择器、集合筛选,以及 LazyColumn 可见性限制。

主要功能

  1. 01遍历父、子及兄弟节点
  2. 02筛选节点集合
  3. 03沿用合并树设置
  4. 04处理惰性列表快照

适用场景

  1. 01查找没有稳定 testTag 的节点
  2. 02断言节点之间的结构关系
  3. 03筛选出唯一匹配节点
  4. 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 通过。

参考资料

评论

支持 Markdown