finding-nodes-by-tag-text-content について
Compose UI テストで適切な検索方法を選び、セマンティクスノードの未検出や曖昧な一致を解決する Skill です。タグ、テキスト、コンテンツ説明、一致数、および未マージツリーを使う場面を扱います。
主な機能
- 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を参照してください。 - ファインダーがノードを 1 つ返すものの、カスタム述語が必要な場合 —
../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)を使います。 本番ソースにconst val SomeTag = "Some"を定義し、Modifier.testTag(SomeTag)を追加します。テストではテキストではなく、タグで検索します。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*は、onNode(hasTestTag(...))/onNode(hasText(...))/onNode(hasContentDescription(...))をラップするだけの薄い@CheckResultラッパーです。動作はマッチャー形式と同じです。 -
3. 件数の契約を守ります。 単数形の
SemanticsNodeInteractionは、0件または複数件のノードに対して取得するとAssertionErrorをスローします(SemanticsNodeInteraction.kt:150-191)。複数形のSemanticsNodeInteractionCollectionは0件でもスローせず、空のコレクションを返します。この場合、assertCountEquals(0)は成功します(Assertions.kt:276-292)。期待する件数に合った形式を選びます。 -
4. 内側の要素を対象にする場合に限り、
useUnmergedTree = trueに切り替えます。 マージ済みツリーでは、セマンティクスが親に集約される子孫要素がまとめられます。たとえば、Button内のTextとIconは1つのノードとして表示されます。内側のTextを対象にするには、useUnmergedTree = trueを渡します。このフラグは引き継がれます。useUnmergedTree = trueで検索方法を作成すると、その後のすべてのonChild/onParent/filterでも有効なままです(SemanticsNodeInteraction.kt:42-49)。Skydoves の見解その2:既定ではマージ済みツリーを使い、コンポジションの詳細を対象にする場合に限り切り替えます。 -
5. 件数が一致しない場合は、自動提案される未マージのヒントを確認します。 検索方法がマージ済みツリーを対象にしていて、結果が0件の場合、フレームワークは同じクエリを未マージのツリーに対して再実行し、一致するノードをエラーメッセージに含めます(
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 を参照してください。
必須ルール
- 必須:
onNodeWithTextやonNodeWithContentDescriptionよりも、onNodeWithTagを優先します。Skydoves の独自の見解その1。 - 必須: 本番コードでは、
Modifier.testTag(...)を定数の下で定義します。禁止: 本番ソースとテストの両方で、同じ文字列リテラルを再定義しないでください。 - 必須: デフォルトでは
useUnmergedTree = falseを使用します。マージによって折りたたまれた内側の要素を対象にする場合、またはコンポジションの詳細を検証する場合に限り、trueに切り替えます。Skydoves の独自の見解その2。 - 必須: ノード数そのものがテストの契約に含まれる場合は、
onAllNodesWith*とassertCountEqualsを使用します。禁止:onNodeWith*を使い、副作用として「ちょうど1つ」であることを暗に期待しないでください。 - 禁止: 明瞭さが損なわれるほど、1つの文の中でファインダーを連結しないでください(例:
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の基盤となるマッチャー。