printing-the-semantics-tree について
Jetpack Compose UIテストでファインダーが失敗した際に、セマンティクスツリーを調べるためのスキルガイドです。マージ済み・未マージのツリー出力と、カスタム検証用のノード取得を説明します。
主な機能
- 01マージ済みセマンティクスツリーの出力
- 02未マージツリー内のノード確認
- 03ツリー出力とフレームワークのヒントの解釈
- 04カスタムマッチャー向けSemanticsNode取得
- 05ログ用ツリー出力の文字列化
活用シーン
- 01ノードが見つからない・複数一致する問題の調査
- 02セマンティクスのマージによるノードの隠れ方の確認
- 03カスタムマッチャーでのノード属性検査
- 04CIログへのツリー構造の記録
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill printing-the-semantics-treeインストール後、Claude Code で/printing-the-semantics-treeを入力して読み込まれたか確認します。
詳細
セマンティクスツリーの出力 — ファインダーが失敗したら、ツリーをダンプする
onNodeWithTag の失敗は構造的な問題です。タグが間違っている、マッチャーがマージ済みツリーを対象としているのにタグがリーフに付いている、またはノードがまだ実際にコンポーズされていない、といった原因が考えられます。いずれもタイミングの問題ではないため、Thread.sleep を使っても解決しません。このスキルでは、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 directive #7 を参照してください。
このスキルを使用しないタイミング
- 症状がタイミングに関係している場合(アニメーションが完了していない、アイドリングリソースが処理中など)。
../../synchronization/synchronizing-with-idle/SKILL.mdまたは../../synchronization/testing-animations-deterministically/SKILL.mdを使用してください。 - テストがタグではなくテキストで検索しているためにファインダーが間違っている場合。
../../finders/finding-nodes-by-tag-text-content/SKILL.mdを使用して修正してください(skydoves directive #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(...)がマージ済みツリーで一致するノードを見つけられず、同じマッチャーが未マージツリーでは一致する場合、エラーメッセージに「These nodes were found in the unmerged tree」というブロックが含まれます。参照先は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 = "…")を優先します。
検証
- ファインダーが失敗した場合、テスト(またはエージェントによる対話型デバッグ)で、該当するサブツリーの
printToLog("DEBUG")行が少なくとも 1 行出力されます。 - ファインダーの問題を解決するために
Thread.sleepが追加されていません。 - ダンプでノードが
useUnmergedTree = trueの場合にのみ表示されるなら、ファインダーがそのフラグを渡すように更新されています。 - 複数の一致が見つかった場合、sleep やアサーションの試行回数を増やすのではなく、
filterToOne、hasAnyAncestor、親のtestTagなどを使ってファインダーを絞り込んでいます。 - カスタムマッチャーが、意味のある診断メッセージを指定した
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