composing-semantics-matchers について
Jetpack ComposeのUIテストでSemanticsMatcherを組み合わせるスキルです。組み込みフィルターやカスタムセマンティクスキー、階層条件を単一のクエリにまとめ、失敗時に条件を確認できます。
主な機能
- 01and、or、notによるマッチャー合成
- 02組み込み状態・アクションフィルター
- 03カスタムセマンティクスキーの照合
- 04祖先・子孫関係によるノード検索
- 05失敗時の条件表示
活用シーン
- 01テキスト・有効状態・クリック操作でボタンを検索
- 02ダイアログ内のテキストノードを特定
- 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-530hasTextは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)。