关于 printing-the-semantics-tree
面向 Jetpack Compose UI 测试的技能指南,介绍在查找器失败时如何检查语义树。它通过合并与未合并树转储帮助区分节点结构问题,并提供自定义断言的节点访问方法。
主要功能
- 01输出合并语义树进行诊断
- 02检查未合并语义树中的节点
- 03解读树转储与框架提示
- 04访问 SemanticsNode 编写自定义匹配器
- 05捕获树转储字符串用于测试日志
适用场景
- 01排查找不到或匹配多个节点
- 02确认节点是否被语义合并隐藏
- 03为自定义匹配器检查节点属性
- 04在 CI 日志中记录树结构
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill printing-the-semantics-tree安装后在 Claude Code 中输入/printing-the-semantics-tree确认已加载。
详细介绍
打印语义树——查找器失效时,转储语义树
onNodeWithTag 失败是结构性问题——标签错误、匹配器作用于合并后的树但标签位于叶节点上,或节点确实尚未组合。以上都不是时序问题,因此 Thread.sleep 毫无作用。本技能将教会 Agent 通过 printToLog / printToString 转储语义树,并解读框架内置的“在未合并的树中找到”提示,从而诊断查找器失败。
何时使用本技能
- 测试失败,并显示
"Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: …"。 - 测试失败,并显示
"Reason: Expected exactly '1' node but found 'N' nodes that satisfy: …"。 - 错误消息以
"…were found in the unmerged tree. If you really wanted to match against merged tree, use useUnmergedTree = true."结尾,且开发者对此感到困惑。 - 开发者正在编写自定义匹配器,需要直接访问
SemanticsNode。 - 测试通过添加
Thread.sleep修复了——根本问题是标签错误,而不是时序问题。请替换为本技能中的模式。参见 skydoves 指令 #7。
何时不应使用本技能
- 症状与时序有关(动画尚未结束、空闲资源正忙)。请使用
../../synchronization/synchronizing-with-idle/SKILL.md或../../synchronization/testing-animations-deterministically/SKILL.md。 - 查找器因测试按文本而非标签搜索而出错。请使用
../../finders/finding-nodes-by-tag-text-content/SKILL.md修复(skydoves 指令 #1)。 - 诊断涉及的是无障碍违规,而非节点查找。请使用
../enabling-accessibility-checks/SKILL.md。
前提条件
- 在
androidTestImplementation或testImplementation中添加androidx.compose.ui:ui-test(该 API 位于commonMain,因此在两个源集内都可用)。 - 已有可用的
ComposeTestRule或ComposeUiTest(除标准骨架外无需额外设置)。
工作流
- 1. 查找器失败时,先转储合并后的语义树。 在
rule.setContent { … }之后立即添加这一行(或放在失败的查找器之前)。
rule.onRoot().printToLog("DEBUG")
签名为 fun SemanticsNodeInteraction.printToLog(tag: String, maxDepth: Int = Int.MAX_VALUE)——对于单节点接收者,默认打印完整子树。引用位置:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:62-69。使用 adb logcat -s DEBUG 查看输出。
- 2. 如果合并后的转储中未显示预期节点,则转储未合并的语义树。
rule.onRoot(useUnmergedTree = true).printToLog("DEBUG")
合并语义的可组合项内部的后代节点(例如 Button 内的 Icon)只会显示在未合并的语义树中。
- 3. 阅读输出语法。 格式由
Output.kt:135-296固定:
Node #<id> at (l=<L>, t=<T>, r=<R>, b=<B>)px, Tag: '<tag>'
<Sorted ConfigKey> = '<value>'
...
[<FlagKey1>, <FlagKey2>] // keys whose value is Unit
Actions = [<ActionKey1>, ...] // AccessibilityActions and Function values
MergeDescendants = 'true' // when isMergingSemanticsOfDescendants
ClearAndSetSemantics = 'true' // when isClearingSemantics
|-Node #<childId> at (...)px // children indented with " |-"
-
首行:
Node #+ ID + 边界框 +(可选)Tag: '<testTag>'。 -
配置项按键名的字母顺序排序。
-
AccessibilityAction和Function<*>值会汇总为Actions = [keyName, …]。 -
Unit值(例如Disabled、IsTraversalGroup)会汇总为[Disabled, IsTraversalGroup, …]。 -
MergeDescendants = 'true'和ClearAndSetSemantics = 'true'标志会显示在节点块的底部。 -
4. 阅读框架内置提示。 当
onNode(...)在合并后的语义树中找到 0 个匹配项,但同一个匹配器在未合并的语义树中能找到匹配项时,错误消息会包含“在未合并的语义树中找到以下节点”块。引用位置:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt:184-194。开发者看到此提示时,应在失败的查找器上将useUnmergedTree改为true。合并与未合并的选择请参阅../../finders/finding-nodes-by-tag-text-content/SKILL.md。 -
5. 对于自定义匹配器,直接使用
fetchSemanticsNode/fetchSemanticsNodes。 它们会返回底层的SemanticsNode(或多个节点),供直接检查。
val node = rule.onNodeWithTag("title").fetchSemanticsNode()
assertThat(node.config[SemanticsProperties.Text]).isNotEmpty()
val all = rule.onAllNodesWithTag("row").fetchSemanticsNodes(
atLeastOneRootRequired = true,
errorMessageOnFail = "expected at least one row",
)
单节点签名:fun fetchSemanticsNode(errorMessageOnFail: String? = null): SemanticsNode。集合签名:fun fetchSemanticsNodes(atLeastOneRootRequired: Boolean = true, errorMessageOnFail: String? = null): List<SemanticsNode>。
- 6. 对于集合查找器,默认的
maxDepth为 0(不包含子节点)。 若开发者需要每个匹配项的完整子树,请覆盖此值。
rule.onAllNodesWithTag("row").printToLog("DEBUG", maxDepth = Int.MAX_VALUE)
引用位置:Output.kt:83-116。
- 7. 将转储内容捕获为 String,以便基于断言进行调试。 这在 CI 日志中特别有用。
val tree = rule.onRoot(useUnmergedTree = true).printToString()
println(tree) // or attach to a test report
模式
模式:使用 Thread.sleep“等待节点”,而不是转储语义树
// WRONG
@Test
fun appears() {
rule.setContent { MyScreen() }
Thread.sleep(2000) // hide the symptom
rule.onNodeWithTag("submit").assertIsDisplayed() // still fails
}
// WRONG because: Thread.sleep desyncs from MainTestClock and does not advance composition.
// If the node is missing, more wall time will not produce it. The cause is structural.
// Skydoves directive #7: Thread.sleep is a smell.
// RIGHT
@Test
fun appears() {
rule.setContent { MyScreen() }
rule.onRoot(useUnmergedTree = true).printToLog("DEBUG") // see what's actually present
rule.onNodeWithTag("submit").assertIsDisplayed()
}
模式:“在未合并的语义树中找到”的提示
// WRONG — the test ignores the framework hint
@Test
fun iconExists() {
rule.setContent { Button(onClick = {}) { Icon(Icons.Default.Add, modifier = Modifier.testTag("add-icon")) } }
rule.onNodeWithTag("add-icon").assertExists()
// AssertionError: ... 0 matches.
// These nodes were found in the unmerged tree:
// Node #4 ... Tag: 'add-icon'
// If you really wanted to match against merged tree, use useUnmergedTree = true.
}
// WRONG because: a Button merges descendants. The Icon's tag is collapsed into the Button
// node in the merged tree. The error message names the fix in plain English.
// RIGHT
@Test
fun iconExists() {
rule.setContent { Button(onClick = {}) { Icon(Icons.Default.Add, modifier = Modifier.testTag("add-icon")) } }
rule.onNodeWithTag("add-icon", useUnmergedTree = true).assertExists()
}
模式:匹配项不明确(“找到 3 个节点”)——通过转储内容消除歧义
// WRONG
rule.onNodeWithTag("row").assertIsDisplayed()
// AssertionError: Reason: Expected exactly '1' node but found '3' nodes that satisfy:
// (TestTag = 'row')
// RIGHT — dump first, then narrow the matcher
rule.onAllNodesWithTag("row").printToLog("DEBUG", maxDepth = Int.MAX_VALUE)
// Read the dump to find a stable disambiguator (e.g. parent tag, contentDescription),
// then narrow:
rule.onAllNodesWithTag("row")
.filterToOne(hasAnyAncestor(hasTestTag("section-completed")))
.assertIsDisplayed()
模式:使用 fetchSemanticsNode 进行自定义断言
// RIGHT
@Test
fun rowConfigContainsCustomKey() {
rule.setContent { /* … */ }
val node = rule.onNodeWithTag("row").fetchSemanticsNode("missing 'row'")
val custom: String? = node.config.getOrNull(MyCustomKey)
assertThat(custom).isEqualTo("expected")
}
强制规则
- 必须在查找器失败时立即使用
rule.onRoot().printToLog("DEBUG")(或未合并树变体)转储语义树。单节点接收器的默认maxDepth为Int.MAX_VALUE,因此无需进一步配置即可打印完整子树。出处:Output.kt:43, 65。 - 必须将框架提示
"…were found in the unmerged tree. If you really wanted to match against merged tree, use useUnmergedTree = true."视为指令——在失败的查找器上设置useUnmergedTree = true。出处:SemanticsNodeInteraction.kt:184-194。 - 不得添加
Thread.sleep(…)来“给树加载时间”。查找器失败是结构性问题;增加等待时间不会产生该节点。Skydoves 指令 #7。唯一合理的用途是等待截图/RenderThread。 - 必须在对
SemanticsNodeInteractionCollection调用printToLog时,将maxDepth覆盖为非零值——集合的默认值是0(不包含子节点)。出处:Output.kt:85, 112。 - **建议:**从合并树开始诊断,仅在预期节点被折叠时才升级为
useUnmergedTree = true。Skydoves 指令 #2:默认保持useUnmergedTree = false。 - **建议:**对于自定义匹配器,优先使用
fetchSemanticsNode(errorMessageOnFail = "…"),这样失败消息会指出具体测试,而不只是断言所在行。
验证
- 查找器失败时,测试(或 Agent 进行交互式调试时)会为相关子树至少输出一行
printToLog("DEBUG")。 - 未添加
Thread.sleep来修复查找器问题。 - 如果转储结果显示该节点仅在
useUnmergedTree = true时出现,则已更新查找器以传入该标志。 - 如果找到多个匹配项,则已缩小查找器的范围(通过
filterToOne、hasAnyAncestor、父级testTag等方式),而不是添加 sleep 或多次尝试断言。 - 自定义匹配器使用
fetchSemanticsNode(errorMessageOnFail = "…"),并提供有意义的诊断消息。 - 在 CI 中,测试日志包含转储输出(开发者会收集测试进程的 logcat)。
参考资料
printToLog/printToString源码:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:41-116- 输出语法(矩形、排序后的配置、操作、标志):
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Output.kt:135-296 - “在未合并树中找到”提示:
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt:178-194 fetchSemanticsNode(s)签名:compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt- Compose 中的语义:https://developer.android.com/develop/ui/compose/accessibility/semantics
- Compose 测试——查找器:https://developer.android.com/develop/ui/compose/testing#finders
- Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet