mcpField
P
printing-the-semantics-tree

printing-the-semantics-tree

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

关于 printing-the-semantics-tree

面向 Jetpack Compose UI 测试的技能指南,介绍在查找器失败时如何检查语义树。它通过合并与未合并树转储帮助区分节点结构问题,并提供自定义断言的节点访问方法。

主要功能

  1. 01输出合并语义树进行诊断
  2. 02检查未合并语义树中的节点
  3. 03解读树转储与框架提示
  4. 04访问 SemanticsNode 编写自定义匹配器
  5. 05捕获树转储字符串用于测试日志

适用场景

  1. 01排查找不到或匹配多个节点
  2. 02确认节点是否被语义合并隐藏
  3. 03为自定义匹配器检查节点属性
  4. 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)。

参考资料

评论

支持 Markdown