mcpField
C
composing-semantics-matchers

composing-semantics-matchers

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

composing-semantics-matchers について

Jetpack ComposeのUIテストでSemanticsMatcherを組み合わせるスキルです。組み込みフィルターやカスタムセマンティクスキー、階層条件を単一のクエリにまとめ、失敗時に条件を確認できます。

主な機能

  1. 01and、or、notによるマッチャー合成
  2. 02組み込み状態・アクションフィルター
  3. 03カスタムセマンティクスキーの照合
  4. 04祖先・子孫関係によるノード検索
  5. 05失敗時の条件表示

活用シーン

  1. 01テキスト・有効状態・クリック操作でボタンを検索
  2. 02ダイアログ内のテキストノードを特定
  3. 03カスタムセマンティクス属性を持つノードのテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill composing-semantics-matchers

インストール後、Claude Code で/composing-semantics-matchersを入力して読み込まれたか確認します。

詳細

Semantics Matcher の合成 — 1 つの Matcher、1 つのエラー

SemanticsMatcher は、説明文字列と (SemanticsNode) -> Boolean 述語を組み合わせたものです(SemanticsMatcher.kt:26)。Compose には豊富なフィルターライブラリと、すべての句をエラーメッセージに列挙する単一の Matcher を生成する 3 つのコンビネーター(and、or、not)が用意されています。このスキルでは、適切なファクトリーを選び、精密な述語を合成して、壊れやすいアサーションの連鎖を置き換えます。

このスキルを使う場面

  • 開発者が「有効な Button で、テキストが Submit のもの」や、複数の条件に一致するノードを探したい場合。
  • 開発者が hasText と hasClickAction の組み合わせ方や、not で Matcher を否定する方法を尋ねた場合。
  • 開発者がカスタムの SemanticsPropertyKey(ドメイン固有のロールなど)で、expectValue/keyIsDefined を使ってフィルターしたい場合。
  • 開発者が hasParent、hasAnyAncestor、hasAnyChild、hasAnySibling、hasAnyDescendant などの階層 Matcher について尋ねた場合。
  • テストで複数の assertIs* 呼び出しを連鎖させていて、開発者が単一の宣言的な述語にまとめたい場合。

このスキルを使わない場面

  • クエリが「タグ/テキスト/コンテンツ説明だけで検索する」である場合 — ../finding-nodes-by-tag-text-content/SKILL.md を使ってください。
  • 適切なノードは存在するものの、相対ノードとしてのみ存在する場合 — ../traversing-the-semantics-tree/SKILL.md を使ってください。
  • Matcher は問題なく、アサーションだけを見直す必要がある場合 — ../../assertions/asserting-node-state-and-text/SKILL.md を参照してください。

前提条件

  • テストのクラスパスに androidx.compose.ui:ui-test が含まれていること。解決できない場合は、../../setup/configuring-test-dependencies/SKILL.md を参照してください。
  • 本番コードでの Modifier.semantics { … } に慣れていること。カスタムキーをテストから照合できるようにするには、Modifier.semantics { customKey = value } で設定する必要があります。

ワークフロー

  • 1. 事前構築済みフィルターライブラリからマッチャーを構築します。 事前構築済みのマッチャーはすべて commonMain/.../Filters.kt にあります。シグナルごとに分類します。

    シグナルマッチャーファイル:行
    有効/無効isEnabled(), isNotEnabled()Filters.kt:38-47
    切り替え(Switch、Checkbox)isToggleable(), isOn(), isOff()Filters.kt:54-70
    選択(Tab、RadioButton)isSelectable(), isSelected(), isNotSelected()Filters.kt:77-93
    フォーカスisFocusable(), isNotFocusable(), isFocused(), isNotFocused()Filters.kt:100-122
    クリックアクションhasClickAction(), hasNoClickAction()Filters.kt:129-136
    スクロールアクションhasScrollAction(), hasNoScrollAction(), hasScrollToIndexAction(), hasScrollToKeyAction(), hasScrollToNodeAction()Filters.kt:143-425
    テキストアクション(TextField)hasSetTextAction(), hasInsertTextAtCursorAction(), hasPerformImeAction(), hasImeAction(ImeAction)Filters.kt:365-392
    フォーカスアクションhasRequestFocusAction()Filters.kt:399
    コンテンツ説明hasContentDescription(value, substring, ignoreCase), hasContentDescriptionExactly(vararg)Filters.kt:165-212
    テキストhasText(text, substring, ignoreCase), hasTextExactly(vararg, includeEditableText=true)Filters.kt:229-293
    アクセシビリティ/状態メタデータhasStateDescription, hasProgressBarRangeInfo, isHeading, isDialog, isPopup, isHiddenFromAccessibility, isEditable, isRootFilters.kt:301-439
    テストタグhasTestTag(testTag)Filters.kt:326-327
    階層hasParent(matcher), hasAnyChild(matcher), hasAnySibling(matcher), hasAnyAncestor(matcher), hasAnyDescendant(matcher)Filters.kt:446-530

    hasText は SemanticsProperties.Text、SemanticsProperties.EditableText、および SemanticsProperties.InputText を同時に検索します(Filters.kt:229-258)。hasContentDescription は、結合された説明のリストに対して一致を確認します(Filters.kt:165-188)。

  • 2. and/or/not で組み合わせます。 コンビネーターは SemanticsMatcher.kt:60-74 にあります。それぞれ、description が (left) && (right)/(left) || (right)/NOT (inner) となる新しいマッチャーを生成します。アサーション失敗時、フレームワークはこの説明をそのまま出力するため、失敗メッセージにはどの節が失敗したかが正確に示されます。

  • 3. カスタムキーには SemanticsMatcher.expectValue を使います。 本番コードでカスタムの SemanticsPropertyKey<T> を宣言し、Modifier.semantics { customKey = value } を使って値を設定している場合は、SemanticsMatcher.expectValue(customKey, expectedValue) で照合します(SemanticsMatcher.kt:33-37)。「このキーが設定されている」ことを確認するには SemanticsMatcher.keyIsDefined(customKey)(SemanticsMatcher.kt:40-42)を、「このキーが設定されていない」ことを確認するには keyNotDefined(SemanticsMatcher.kt:45-47)を使います。isOn() のような組み込みマッチャーも、expectValue(SemanticsProperties.ToggleableState, On) を薄くラップしたものです(Filters.kt:61-62)。

  • 4. 組み合わせたマッチャーを onNode/onAllNodes に渡します。 onNode(matcher, useUnmergedTree=false) と onAllNodes(matcher, useUnmergedTree=false) が標準のエントリーポイントです(SemanticsNodeInteractionsProvider.kt)。onNodeWithTag などは簡便なラッパーにすぎないため、述語が複雑になったら使わずに置き換えます。

  • 5. 階層に関するアサーションでは、複数回のトラバーサルより hasAnyAncestor/hasAnyDescendant を優先します。 例:「ダイアログ内の Text を検索する」→ onNode(hasText("Discard?") and hasAnyAncestor(isDialog()))。onAllNodes(isDialog()).onFirst().onChildren().filter(hasText(...)).onFirst() よりも簡潔です。

パターン

パターン:アサーションチェーンを単一のマッチャーに置き換える
// WRONG
@Test
fun submit_isEnabledClickable() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithText("Submit")
        .assertHasClickAction()
        .assertIsEnabled()
}
// WRONG because: each assertion is a separate fetch + error path. Failure prints "no click
// action" without telling the developer the matched node is the *enabled-but-wrong* button on
// a different screen.
// RIGHT
@Test
fun submit_isEnabledClickable() {
    rule.setContent { CheckoutScreen() }
    rule.onNode(hasText("Submit") and hasClickAction() and isEnabled())
        .assertExists()
}

組み合わせたマッチャーの説明は、失敗時に (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) && (OnClick is defined) && (is enabled) と出力され、満たされなかった節がすべて示されます。

パターン:階層的なマッチ — 「ダイアログ内のテキスト」
// WRONG
rule.onAllNodes(isDialog())
    .onFirst()
    .onChildren()
    .filterToOne(hasText("Discard?"))
    .assertIsDisplayed()
// WRONG because: it implicitly assumes the Text is a direct child of the dialog root.
// AlertDialog wraps its body in Column/Surface, so the Text is a descendant, not a child.
// RIGHT
rule.onNode(hasText("Discard?") and hasAnyAncestor(isDialog()))
    .assertIsDisplayed()
パターン:否定と「ちょうど1つ」
// "An enabled, non-toggleable Button (so: not a Switch or Checkbox) with text Submit"
rule.onNode(hasText("Submit") and isEnabled() and !isToggleable())
    .assertHasClickAction()

!matcher は operator fun not()(SemanticsMatcher.kt:72-74)を呼び出します。ライブラリ内のすべてのマッチャーで動作します。

パターン:カスタムセマンティクスキー
// production:
val PriorityKey = SemanticsPropertyKey<Int>("Priority")
var SemanticsPropertyReceiver.priority by PriorityKey

@Composable
fun TaskRow(task: Task) {
    Row(modifier = Modifier
        .testTag(TaskRowTag)
        .semantics { priority = task.priority }
    ) { /* … */ }
}

// test:
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.expectValue(PriorityKey, 1))
    .assertIsDisplayed()

// "this row has a priority assigned at all":
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.keyIsDefined(PriorityKey))
    .assertIsDisplayed()
パターン:ドロップダウン/IME アクション/キーへのスクロール — TextField クエリを絞り込む
// "the editable TextField that handles Done as IME action"
rule.onNode(hasSetTextAction() and hasImeAction(ImeAction.Done))
    .performTextInput("hello")

// "any LazyColumn child reachable by key — i.e. has IndexForKey on top of ScrollToIndex"
rule.onNode(hasScrollToKeyAction()).performScrollToKey(itemKey)

hasScrollToKeyAction は hasKey(ScrollToIndex) and hasKey(IndexForKey) の論理積です(Filters.kt:415-416)。hasScrollToNodeAction は軸範囲のチェックを追加します(Filters.kt:419-425)。

パターン:マッチャーの説明がエラーメッセージに表示される
val matcher = hasText("Submit") and hasClickAction() and isEnabled()
println(matcher.description)
// (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) &&
// (OnClick is defined) && (is enabled)

説明は人間が読める形式で、マッチャーが成立しない場合に assert(matcher)(Assertions.kt:254-267)によって使用されます。各節は単独でも理解できるようにする必要があります。そのため、インラインラムダより名前付きマッチャーが推奨されます。

必須ルール

  • マッチャーの合成には 必ず infix and/infix or/operator not を使用してください。同じ意図を捉える単一の合成マッチャーで済む場合、assertIs* の呼び出しを連鎖させては いけません。
  • カスタムセマンティクスキーは 必ず SemanticsMatcher.expectValue(...) または SemanticsMatcher.keyIsDefined(...) でラップしてください。アドホックなラムダ内から node.config[CustomKey] にアクセスしては いけません。ファクトリーメソッドを使うと、読みやすいエラー説明が生成されます。
  • 関係が「どこか上/下」である場合は、複数ステップの走査よりも hasAnyAncestor(isDialog())/hasAnyDescendant(...) を 必ず優先してください。相互参照:../traversing-the-semantics-tree/SKILL.md。
  • マッチャーがマージによって折りたたまれた内部要素を対象にする場合を除き、useUnmergedTree フラグはデフォルトの false のままにしてください。Skydoves のホットテイク #2。
  • 既存の組み込みマッチャーを再定義しては いけません(例:カスタムの SemanticsMatcher("is enabled") { … } を記述する)。isEnabled() を使用してください。カスタムマッチャーは、組み込みフィルターが存在しないキーに限って使用します。
  • **推奨:**テキストを基準にしたマッチャーより、hasTestTag(SubmitTag) and isEnabled() のようなテストタグを基準にしたマッチャーを優先してください。Skydoves のホットテイク #1。

検証

  • 複数条件のクエリはすべて、ファインダーとアサーションの連鎖ではなく、1つの onNode(matcher) を使用している。
  • カスタムキーのチェックでは SemanticsMatcher.expectValue/keyIsDefined を使用しており、マッチャーの述語に node.config[...] が登場しない。
  • ./gradlew :app:connectedDebugAndroidTest または :app:testDebugUnitTest が成功する。
  • 意図的に失敗させたマッチャーのエラーメッセージには合成された description が表示され、どの節が失敗したかを特定できる。
  • 同じ名前のローカルラムダを再宣言して、マッチャーを気付かれないまま隠していない。

参考資料

  • 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/SemanticsMatcher.kt — class SemanticsMatcher、expectValue/keyIsDefined/keyNotDefined、infix and、infix or、operator not。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt — すべての組み込みマッチャー(状態、アクション、テキスト、階層)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Assertions.kt — fun assert(matcher, messagePrefixOnError) と、エラーにマッチャーの説明がどのように表示されるか。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteractionsProvider.kt — onNode(matcher, useUnmergedTree)/onAllNodes(matcher, useUnmergedTree)。

コメント