traversing-the-semantics-tree について
単一のファインダーでノードを特定できない場合に、Compose のセマンティクスツリーをたどるテストスキルです。親・子・兄弟・祖先のセレクター、コレクションの絞り込み、LazyColumn の可視範囲に関する注意点を説明します。
主な機能
- 01親・子・兄弟ノードの移動
- 02ノードコレクションの絞り込み
- 03マージ済みツリー設定の引き継ぎ
- 04遅延リストのスナップショットへの対応
活用シーン
- 01安定した testTag のないノードを検索
- 02ノード間の構造的な関係を検証
- 03一致するノードを一つに絞り込み
- 04スクロール後に LazyColumn の項目をテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill traversing-the-semantics-treeインストール後、Claude Code で/traversing-the-semantics-treeを入力して読み込まれたか確認します。
詳細
セマンティクスツリーをたどる — 単一の検索メソッドではノードにたどり着けない場合
単一の検索メソッド(onNodeWithTag など)で、テストクエリの約 95% に対応できます。残りにはツリーのナビゲーションが必要です。「この Text の親」「この Row の 3 番目の子」「有効になっている兄弟ノード」などです。このスキルでは、これらのナビゲーション演算子を解説し、LazyColumn のスナップショットに関する注意点を取り上げ、ツリーをたどる方法が適切でない場合についても説明します。
このスキルを使う場合
- 対象ノードに安定した
testTagがなく、プロダクションコードに追加できない場合(サードパーティ製のコンポーザブル、動的に生成される子ノードなど)。 - テストで構造上の関係を検証する場合(「Row の 2 番目の子はアイコンである」など)。
- 開発者が
.onChildren()[i].onChildAt(j)を連結していて、それが適切な方法か知りたい場合。 - 開発者が
onChildren、onChild、onParent、onSibling、onSiblings、onAncestors、filterToOne、onFirst、onLast、または[index]について言及している場合。 LazyColumnのテストで、画面外にあるために項目を見つけられない場合。
このスキルを使わない場合
- 対象ノードに
Modifier.testTag(...)を追加できる場合 — タグの追加は、ほとんどの場合、ノードをたどるよりも簡潔です。../finding-nodes-by-tag-text-content/SKILL.mdを参照してください。 - 関係が「上のどこか」/「下のどこか」である場合 — マッチャーベースの
hasAnyAncestor/hasAnyDescendantを優先してください(../composing-semantics-matchers/SKILL.mdを参照)。 - クエリがキーによる LazyColumn の項目に関する場合 — 代わりに
performScrollToKeyを使ってください(../../actions/clicking-and-scrolling/SKILL.md、../../patterns/testing-lazy-lists/SKILL.mdを参照)。
前提条件
ComposeTestRule/ComposeUiTestが動作する状態であること。../../setup/configuring-test-dependencies/SKILL.mdを参照してください。- マージ済みツリーと未マージツリーの違いを理解していること。
../finding-nodes-by-tag-text-content/SKILL.mdを参照してください。
ワークフロー
-
1. 関係の種類に応じてナビゲーターを選びます。 各オペレーターは、
SemanticsNodeInteraction(単数 — 0 件または複数件の場合に失敗)またはSemanticsNodeInteractionCollection(複数 — 0 件でも失敗しません)のいずれかを返します。単一ノードから移動する先 API 戻り値 ファイル:行 親 onParent()単数 Selectors.kt:36-42子がちょうど 1 つ onChild()単数 Selectors.kt:71-77指定インデックスの子 onChildAt(index)単数 Selectors.kt:85現在コンポーズされているすべての子 onChildren()コレクション Selectors.kt:53-59兄弟がちょうど 1 つ onSibling()単数 Selectors.kt:119-125すべての兄弟 onSiblings()コレクション Selectors.kt:101-107ルートまでのすべての祖先 onAncestors()コレクション Selectors.kt:140-146コレクションから絞り込む対象 API 戻り値 ファイル:行 最初 onFirst()(=[0])単数 Selectors.kt:156-158最後 onLast()単数 Selectors.kt:168-170n 番目 [index]単数 SemanticsNodeInteraction.kt:261-267サブコレクションへのフィルタリング filter(matcher)コレクション Selectors.kt:178-186ちょうど 1 つにフィルタリング filterToOne(matcher)単数 Selectors.kt:198-206 -
2.
onAncestor(単数形)は探さないでください。これは存在しません。 API にあるのは複数形のonAncestors()のみです(Selectors.kt:140-146)。[parent, grandparent, …, root]の順に返します。「直近の親が X を満たす」ことをアサートするには、onParent().assert(matcherX)またはhasParent(matcherX)述語を使います。 -
3.
useUnmergedTreeフラグは引き継がれることを覚えておいてください。 すべてのナビゲーターは、元のSemanticsNodeInteractionと同じuseUnmergedTree値を保持します(SemanticsNodeInteraction.kt:42-49。各Selectors.ktコンストラクターはuseUnmergedTreeを変更せずに引き継ぎます)。onNodeWithTag(tag, useUnmergedTree = true)で作成したファインダーは、onChild/onParent/filterの後もフラグをオンのまま保持します。Skydoves の見解 #2:デフォルトではマージし、コンポジションの詳細を対象にする場合にのみアンマージへ切り替えます。 -
4.
onChildren()は呼び出し時点のスナップショットです。 「現在セマンティクスツリーに存在する」ノードを返します(Selectors.kt:46-52)。LazyColumnまたはLazyRowでは、画面上に表示されているアイテムのみが現れます。画面外のアイテムに到達するには、まずperformScrollToIndex/performScrollToKey/performScrollToNodeでスクロールします(../../actions/clicking-and-scrolling/SKILL.mdを参照)。 -
5. ナビゲーションよりタグでの再検索が有効な場合が多くあります。 Compose のバージョン間や翻訳の切り替えによってツリー構造が変わる可能性がある場合、対象ノードに付けた安定したタグのほうが、
[2].onChildAt(0)のようなチェーンよりも長く使えます。本番コードでタグを追加してください。Skydoves の見解 #1。 -
6. 件数のアサーションを暗黙的に行う場合は
filterToOne(matcher)を使います。filterToOneは取得時に一致件数が 0 件または複数件の場合に例外をスローします。これはonNode(matcher)と同じ動作です(Selectors.kt:198-206)。コレクションを絞り込む場合のonNode(matcher)に相当します。
パターン
パターン:深い [index] チェーンよりタグを優先する
// WRONG
@Test
fun second_avatar_isVisible() {
rule.setContent { ProfileGrid(profiles = profiles) }
rule.onNodeWithTag("ProfileGrid")
.onChildren()[2]
.onChildren()[0]
.assertIsDisplayed()
}
// WRONG because: layout shuffling (a header inserted, an extra wrapper, a Spacer added)
// silently changes which node is being asserted. The test passes for the wrong reason.
// RIGHT
// production:
@Composable
fun ProfileGrid(profiles: List<Profile>) {
LazyColumn(modifier = Modifier.testTag(ProfileGridTag)) {
itemsIndexed(profiles) { index, profile ->
Row(modifier = Modifier.testTag("$AvatarTagPrefix$index")) {
Avatar(profile, modifier = Modifier.testTag("$AvatarImageTagPrefix$index"))
}
}
}
}
// test:
rule.onNodeWithTag("$AvatarImageTagPrefix${1}").assertIsDisplayed()
パターン:タグがない場合のトラバーサル
@Test
fun row_third_child_isIcon() {
rule.setContent {
Row(modifier = Modifier.testTag("toolbar")) {
Text("Title")
Spacer(Modifier.weight(1f))
Icon(Icons.Default.Share, contentDescription = "Share")
}
}
rule.onNodeWithTag("toolbar")
.onChildren()
.assertCountEquals(3)
rule.onNodeWithTag("toolbar")
.onChildAt(2)
.assertContentDescriptionEquals("Share")
}
onChildAt(index) は onChildren()[index] とまったく同じです(Selectors.kt:85)。どちらも、インデックスが範囲外の場合、またはリーフで解決されたノード数がちょうど 1 ではない場合に失敗します。
パターン:[i] の代わりに filterToOne
// WRONG
rule.onAllNodesWithTag(RowTag).onChildren().filter(hasClickAction())[0]
.assertHasClickAction()
// WRONG because: indexing into a filtered collection silently passes when the filter returns
// many. The test asserts only "at least one is clickable", not "exactly one".
// RIGHT
rule.onAllNodesWithTag(RowTag).onChildren()
.filterToOne(hasClickAction())
.assertHasClickAction()
パターン:既知の子から親のロールをアサートする
@Test
fun submitText_isInsideEnabledButton() {
rule.setContent {
Button(onClick = {}, modifier = Modifier.testTag("submit"), enabled = true) {
Text("Submit", modifier = Modifier.testTag("submitLabel"))
}
}
rule.onNodeWithTag("submitLabel", useUnmergedTree = true)
.onParent() // sticky: stays unmerged
.assertIsEnabled()
.assertHasClickAction()
}
内側の Text ファインダーに設定した useUnmergedTree = true は、自動的に onParent() に引き継がれるため、繰り返し指定する必要はありません。
パターン:ルートまでのチェーンに onAncestors() を使う
@Test
fun confirmButton_isInsideDialog() {
rule.setContent { ConfirmDialog() }
rule.onNodeWithTag(ConfirmButtonTag)
.onAncestors()
.filterToOne(isDialog())
.assertExists()
}
推奨:rule.onNode(hasTestTag(ConfirmButtonTag) and hasAnyAncestor(isDialog())).assertExists() — 同じ意図を 1 つのマッチャーで表現できます。../composing-semantics-matchers/SKILL.md を参照してください。
パターン:LazyColumn — 画面内の子要素だけが表示されます
// WRONG
rule.onNodeWithTag(ListTag).onChildren().assertCountEquals(1000)
// WRONG because: onChildren() returns a snapshot of currently composed children. A LazyColumn
// only composes the visible viewport plus a small prefetch buffer, so the count is window-sized,
// not data-set-sized.
// RIGHT — assert the visible count, OR scroll first then assert by tag
rule.onNodeWithTag(ListTag).performScrollToIndex(999)
rule.onNodeWithTag("$ItemTagPrefix${999}").assertIsDisplayed()
LazyColumn のワークフロー全体については、../../patterns/testing-lazy-lists/SKILL.md を参照してください。
パターン:onSibling() で「行のもう一方」を検証する
@Test
fun checkbox_label_isPresent() {
rule.setContent {
Row {
Checkbox(checked = true, onCheckedChange = {},
modifier = Modifier.testTag("agreeBox"))
Text("I agree", modifier = Modifier.testTag("agreeLabel"))
}
}
rule.onNodeWithTag("agreeBox")
.onSibling()
.assertTextEquals("I agree")
}
onSibling() で指定できる兄弟要素は必ず1つです(Selectors.kt:114-125)。複数の兄弟要素がある場合は、onSiblings() と filterToOne(...) を使ってください。
必須ルール
- 必須: 複数ステップのトラバーサルチェーンよりも、対象ノードに
Modifier.testTag(...)を追加する方法を優先してください。Skydoves の見解その1です。 - 必須: 条件が「一致する要素がちょうど1つ」である場合は、
filter(matcher).onFirst()ではなくfilterToOne(matcher)を使ってください。前者は2つ以上に一致すると例外をスローしますが、後者は黙って最初の要素を選びます。 - 禁止:
LazyColumn/LazyRowに対してonChildren()がデータセット全体を返すと想定しないでください。現在コンポーズされているスナップショットのみを返します。まずperformScrollToIndex/performScrollToKeyでスクロールしてください。 - 禁止: 単数形の
onAncestorを探さないでください。存在するのは複数形のonAncestors()のみです。直近の親にはonParent()を使い、述語にはhasParent(matcher)/hasAnyAncestor(matcher)を使ってください。 - 必須:
useUnmergedTreeフラグはonChild/onParent/filterをまたいで維持されることを忘れないでください。Skydoves の見解その2です。 - 推奨: 「上/下のどこかにある」という関係には、トラバーサルチェーンの代わりに、
../composing-semantics-matchers/SKILL.mdにあるhasAnyAncestor/hasAnyDescendantを使ってください。
検証
- すべてのトラバーサルチェーンのナビゲーションステップが1つ以下であるか、より深いチェーンについて、不足しているタグを明記したコメントで理由を説明している。
- 先行する
performScrollTo*がないまま、onChildren()[i]のチェーンがLazyColumn/LazyRowの境界をまたいでいない。 -
onAncestor(単数形)が使われていない。onParent、onAncestors、hasAnyAncestorのみが使われている。 - 条件が「一致する要素がちょうど1つ」であるすべての箇所で
filterToOneが使われており、filter().onFirst()が置き換えられている。 - チェーンのルートにおける
useUnmergedTree = trueの指定に意図があり、コメントまたはマッチャーで理由が示されている。 -
./gradlew :app:connectedDebugAndroidTestまたは:app:testDebugUnitTestが成功する。
参考資料
- 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/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Selectors.kt—onParent、onChildren、onChild、onChildAt、onSibling(s)、onAncestors、onFirst、onLast、filter、filterToOne。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt— コレクションの[index]演算子と、維持されるuseUnmergedTreeフィールド。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt— トラバーサルの代替となる述語hasParent、hasAnyChild、hasAnySibling、hasAnyAncestor、hasAnyDescendant。