关于 finding-nodes-by-tag-text-content
这项 Skill 帮助选择 Compose UI 测试查找器,并处理语义节点未匹配或匹配不明确的问题。它说明如何使用标签、文本和内容描述,判断匹配数量,以及何时检查未合并树。
主要功能
- 01按信号类型选择查找器
- 02建议在生产代码中设置测试标签
- 03说明单节点与集合的匹配数量
- 04指导使用未合并树
- 05涵盖子串与忽略大小写匹配
适用场景
- 01排查节点未匹配或匹配不明确
- 02为可组合项选择查找器
- 03测试合并节点中的内部内容
- 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”错误。
参考资料
- 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 Multiplatform 测试:https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Finders.kt—onNodeWithTag/Text/ContentDescription/onRoot及其对应的onAllNodesWith*方法。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteractionsProvider.kt— 基础方法onNode(matcher, useUnmergedTree)/onAllNodes(matcher, useUnmergedTree)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt— 数量契约(fetchOneOrThrow)和自动附加的未合并树提示(getNodesInUnmergedTree)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt— 底层匹配器hasTestTag、hasText、hasContentDescription。