mcpField
F
finding-nodes-by-tag-text-content

finding-nodes-by-tag-text-content

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

关于 finding-nodes-by-tag-text-content

这项 Skill 帮助选择 Compose UI 测试查找器,并处理语义节点未匹配或匹配不明确的问题。它说明如何使用标签、文本和内容描述,判断匹配数量,以及何时检查未合并树。

主要功能

  1. 01按信号类型选择查找器
  2. 02建议在生产代码中设置测试标签
  3. 03说明单节点与集合的匹配数量
  4. 04指导使用未合并树
  5. 05涵盖子串与忽略大小写匹配

适用场景

  1. 01排查节点未匹配或匹配不明确
  2. 02为可组合项选择查找器
  3. 03测试合并节点中的内部内容
  4. 04降低测试对文案或语言区域变化的敏感度

安装

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

$npx skills add https://github.com/skydoves/android-testing-skills --skill finding-nodes-by-tag-text-content

安装后在 Claude Code 中输入/finding-nodes-by-tag-text-content确认已加载。

详细介绍

按标签、文本或内容描述查找节点——在生产代码中设置标签,在测试中按标签查找

每个 Compose UI 测试都从定位语义节点开始。本技能会选择合适的查找器,分析匹配数量约定,并判断何时切换 useUnmergedTree。当开发者遇到“Reason: Expected exactly '1' node but could not find any node that satisfies …”错误时,请使用本技能。

适用场景

  • 开发者报告 SemanticsNodeInteraction 出现“没有匹配的节点”/“匹配了多个节点”/“未找到标签”/“匹配结果不明确”。
  • 开发者询问如何在测试中定位 Button、Text、Icon、IconButton、Switch 或任何其他可组合项。
  • 开发者提到 onNodeWithTag、onNodeWithText、onNodeWithContentDescription、onAllNodesWith* 或 onRoot。
  • 测试在本地通过,但容易因文本编辑或 UI 字符串的国际化切换而失效。
  • 开发者询问 useUnmergedTree、合并后的树,或“为什么我的内部 Text 不存在”。

不适用场景

  • 已找到正确节点,但断言失败——状态问题请参阅 ../../assertions/asserting-node-state-and-text/SKILL.md,几何问题请参阅 ../../assertions/asserting-bounds-and-dimensions/SKILL.md。
  • 查找器返回了一个节点,但还需要自定义谓词——请参阅 ../composing-semantics-matchers/SKILL.md。
  • 正确的节点存在,但仅作为子节点/兄弟节点/后代节点存在——请参阅 ../traversing-the-semantics-tree/SKILL.md。
  • 需要检查语义树本身——请参阅 ../../debug/printing-the-semantics-tree/SKILL.md。

前置条件

  • 已接入 androidx.compose.ui:ui-test-junit4(或 v2)。如果无法解析 createComposeRule,请参阅 ../../setup/configuring-test-dependencies/SKILL.md。
  • 当前作用域内有 ComposeTestRule 或 ComposeUiTest。请参阅 ../../setup/choosing-test-rule-vs-runtest/SKILL.md。
  • 被测可组合项在运行查找器前调用了 setContent { … }。查找器是惰性的,每次操作时都会重新评估。

工作流

  • 1. 默认使用 onNodeWithTag(SomeTag)。 在生产源代码中添加 Modifier.testTag(SomeTag),并在其下方声明 const val SomeTag = "Some"。测试按标签查找,而不是按文本查找。Skydoves 的热辣观点 #1:在 androidx/material3 本身,onNodeWithTag 的使用次数约为 onNodeWithText 的 4 倍(1825 次对 424 次),约为 onNodeWithContentDescription 的 40 倍(1825 次对 46 次)。文本查找器会因文案修改而频繁变动,也会在切换语言区域时失效。

  • 2. 根据信号类型选择查找器。 对应关系(均来自 commonMain/.../Finders.kt):

    要匹配……查找器文件:行号
    Modifier.testTag("…")onNodeWithTag(testTag, useUnmergedTree=false)Finders.kt:31-35
    可见文本/可编辑文本/输入文本onNodeWithText(text, substring=false, ignoreCase=false, useUnmergedTree=false)Finders.kt:110-116
    无障碍内容描述onNodeWithContentDescription(label, substring=false, ignoreCase=false, useUnmergedTree=false)Finders.kt:66-73
    自定义谓词SemanticsNodeInteractionsProvider 中的 onNode(matcher, useUnmergedTree=false)SemanticsNodeInteractionsProvider.kt
    整棵树onRoot(useUnmergedTree=false)Finders.kt:150-153

    每个 onNodeWith* 都是一个轻量的 @CheckResult 封装,底层调用 onNode(hasTestTag(...))/onNode(hasText(...))/onNode(hasContentDescription(...))。其行为与匹配器形式完全相同。

  • 3. 遵守数量契约。 单数形式的 SemanticsNodeInteraction 在获取 0 个或多个(>1)匹配节点时会抛出 AssertionError(SemanticsNodeInteraction.kt:150-191)。复数形式的 SemanticsNodeInteractionCollection 在匹配数为 0 时不会抛出异常,而是返回一个空集合,assertCountEquals(0) 会接受该集合(Assertions.kt:276-292)。选择与预期数量相符的形式。

  • 4. 仅在查找内部元素时设置 useUnmergedTree = true。 合并树会折叠语义向上汇总到父节点的后代节点,因此 Button 内的 Text 和 Icon 会显示为一个节点。要查找内部的 Text,请传入 useUnmergedTree = true。此标志会持续生效:创建查找器时一旦设置了 useUnmergedTree = true,后续所有 onChild/onParent/filter 都会继续使用该设置(SemanticsNodeInteraction.kt:42-49)。Skydoves 的热辣观点 #2:默认使用合并树;仅在查找组合细节时切换。

  • 5. 数量不匹配时,查看自动建议的未合并树提示。 如果查找器针对合并树且未找到任何节点,框架会使用相同查询重新检查未合并树,并将匹配结果写入错误消息(SemanticsNodeInteraction.kt:184-205,getNodesInUnmergedTree)。如果错误消息显示设置 useUnmergedTree = true 后“本可匹配”的节点,请切换该标志。

  • 6. 匹配部分文本或忽略大小写时,显式传入布尔标志。 onNodeWithText("Sub", substring = true) 会匹配 "Submit" 和 "Subscribe"。可结合 onAllNodesWithText 和 assertCountEquals 来明确表达意图(见下文“模式:模糊文本”)。

模式

模式:在生产代码中设置标签,在测试中按标签查找
// WRONG
@Test
fun submit_isEnabled_afterFormFilled() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithText("Place order").assertIsEnabled()
}
// WRONG because: the button label changes with copy edits, and the test breaks the moment QA
// asks for "Buy now". Also fails under any locale that is not the test default.
// RIGHT
// production: ui/checkout/CheckoutScreen.kt
const val PlaceOrderButtonTag = "CheckoutScreen.PlaceOrderButton"

@Composable
fun CheckoutScreen() {
    Button(modifier = Modifier.testTag(PlaceOrderButtonTag), onClick = { /* … */ }) {
        Text(stringResource(R.string.place_order))
    }
}

// test:
@Test
fun submit_isEnabled_afterFormFilled() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithTag(PlaceOrderButtonTag).assertIsEnabled()
}
模式:断言数量时使用集合查找器
// WRONG
@Test
fun greeting_appearsTwice() {
    rule.setContent { GreetingList(listOf("Hello", "Hello")) }
    // Returns the first match silently; never proves "twice".
    rule.onNodeWithText("Hello").assertIsDisplayed()
}
// WRONG because: onNode* throws on >1 matches; if the duplicate count drifts to 1 the test
// passes for the wrong reason, and if it drifts to 3 the test fails with an opaque count error.
// RIGHT
@Test
fun greeting_appearsTwice() {
    rule.setContent { GreetingList(listOf("Hello", "Hello")) }
    rule.onAllNodesWithText("Hello").assertCountEquals(2)
}
模式:匹配 Button 内部的 Text(合并折叠)
// WRONG
@Test
fun button_text_isShown() {
    rule.setContent {
        Button(onClick = {}, modifier = Modifier.testTag("submit")) {
            Text("Submit", modifier = Modifier.testTag("submitLabel"))
        }
    }
    rule.onNodeWithTag("submitLabel").assertIsDisplayed()
}
// WRONG because: in the merged tree the Text is rolled into the Button. The finder sees zero
// nodes and the auto-attached unmerged hint will surface in the error message — flip the flag.
// RIGHT
@Test
fun button_text_isShown() {
    rule.setContent { /* same content */ }
    rule.onNodeWithTag("submitLabel", useUnmergedTree = true).assertIsDisplayed()
}
模式:部分文案匹配时使用 substring/ignoreCase
// WRONG
rule.onNodeWithText("hello").assertIsDisplayed()
// WRONG because: matches only the exact string. UI showing "Hello, world" or "HELLO" produces
// a "no node matched" error.
// RIGHT — when intent really is "contains 'Hello'":
rule.onNodeWithText("Hello", substring = true, ignoreCase = true).assertIsDisplayed()

// RIGHTER — prefer a tag and assert the text once:
rule.onNodeWithTag(GreetingTag).assertTextContains("Hello", substring = true, ignoreCase = true)
模式:仅包含图标的控件使用内容描述
// production:
IconButton(onClick = onShare, modifier = Modifier.testTag(ShareButtonTag)) {
    Icon(Icons.Default.Share, contentDescription = stringResource(R.string.share))
}

// test:
rule.onNodeWithTag(ShareButtonTag).assertIsDisplayed()                  // PREFERRED
rule.onNodeWithContentDescription("Share").assertIsDisplayed()          // acceptable for accessibility-focused tests

onNodeWithContentDescription 默认使用合并树进行精确匹配,因此合并后的 Icon 内容描述会向上汇总到父级 IconButton。要在包含多个图标的按钮中匹配特定 Icon 的描述,请设置 useUnmergedTree = true。

模式:将 onRoot() 作为截图/打印的起点
rule.onRoot().printToLog("tree")                  // dump merged
rule.onRoot(useUnmergedTree = true).printToLog("unmerged")

如需了解 printToLog/printToString 的深度控制,请参阅 ../../debug/printing-the-semantics-tree/SKILL.md。

强制规则

  • 必须优先使用 onNodeWithTag,而不是 onNodeWithText 和 onNodeWithContentDescription。Skydoves 热门观点 #1。
  • 必须在生产代码中将 Modifier.testTag(...) 定义为常量。不得在生产源码和测试中重复定义相同的字符串字面量。
  • 必须默认使用 useUnmergedTree = false。仅当需要定位被合并折叠的内部元素,或验证组合细节时,才改为 true。Skydoves 热门观点 #2。
  • 必须在数量本身属于测试契约时使用 onAllNodesWith* + assertCountEquals。不得依赖 onNodeWith* 的副作用来暗示“恰好一个”。
  • 不得在单条语句中将查找器链式调用到难以理解的程度(例如 onNode(...).onChild().onChildAt(2).onChild())。请改用标签——参见 ../traversing-the-semantics-tree/SKILL.md。
  • **建议:**当数量不匹配错误显示未合并树匹配项时,改用 useUnmergedTree = true,而不是重写匹配器。

验证

  • 除非断言明确针对用户可见文本或无障碍内容描述,否则测试文件中的每个查找器都使用 onNodeWithTag。
  • 每个测试标签都在生产源码中定义为 const val,而非直接写在测试中。
  • useUnmergedTree = true 仅出现在定位已合并可组合项(Button、IconButton、ListItem 等)内部元素的位置。
  • 在使用标签即可满足需求的地方,没有直接使用字符串字面量进行文本比较。
  • ./gradlew :app:connectedDebugAndroidTest(设备)或 :app:testDebugUnitTest(Robolectric 主机)在本地运行通过。
  • 不再出现“Reason: Expected exactly '1' node”错误。

参考资料

评论

支持 Markdown