mcpField
T
traversing-the-semantics-tree

traversing-the-semantics-tree

github.com/skydovesテスト・品質33959 回インストール22 週間前に更新
リポジトリ

traversing-the-semantics-tree について

単一のファインダーでノードを特定できない場合に、Compose のセマンティクスツリーをたどるテストスキルです。親・子・兄弟・祖先のセレクター、コレクションの絞り込み、LazyColumn の可視範囲に関する注意点を説明します。

主な機能

  1. 01親・子・兄弟ノードの移動
  2. 02ノードコレクションの絞り込み
  3. 03マージ済みツリー設定の引き継ぎ
  4. 04遅延リストのスナップショットへの対応

活用シーン

  1. 01安定した testTag のないノードを検索
  2. 02ノード間の構造的な関係を検証
  3. 03一致するノードを一つに絞り込み
  4. 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-170
    n 番目[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 が成功する。

参考資料

コメント