| name | composing-semantics-matchers |
| description | Use this skill to build precise Compose UI test queries by composing `SemanticsMatcher` predicates with `infix and`, `infix or`, and `operator not`, plus the prebuilt filter library (`hasText`, `hasClickAction`, `isEnabled`, `isOn`, `hasTestTag`, `hasContentDescription`, `hasParent`, `hasAnyAncestor`, `hasAnyChild`, `hasAnySibling`, `hasAnyDescendant`, `hasImeAction`, `hasScrollToKeyAction`, `hasScrollToNodeAction`, `isDialog`, `isPopup`, `isRoot`, `isFocused`, `isEditable`, `isHeading`). Covers `SemanticsMatcher.expectValue` / `keyIsDefined` / `keyNotDefined` for custom semantics keys. Use when the developer asks how to find "an enabled button with text Submit", how to combine matchers, how to filter by a custom `SemanticsPropertyKey`, how to write a hierarchical predicate, or mentions `hasParent`, `hasAnyAncestor`, `expectValue`. If the developer wants one matcher instead of three chained assertions, use this skill. |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","ui-testing","SemanticsMatcher","hasText","hasClickAction","hasParent","hasAnyAncestor","expectValue","matcher-algebra"]} |
Composing Semantics Matchers — One Matcher, One Error
A SemanticsMatcher is a description string plus a (SemanticsNode) -> Boolean predicate (SemanticsMatcher.kt:26). Compose ships with a rich filter library and three combinators (and, or, not) that produce a single matcher whose error message lists every clause. This skill picks the right factory, composes precise predicates, and replaces brittle assertion chains.
When to use this skill
- The developer wants to find "an enabled Button with text Submit" or any node matching multiple constraints.
- The developer asks how to combine
hasText and hasClickAction, or how to negate a matcher with not.
- The developer needs to filter by a custom
SemanticsPropertyKey (e.g. a domain-specific role) using expectValue/keyIsDefined.
- The developer asks about hierarchical matchers like
hasParent, hasAnyAncestor, hasAnyChild, hasAnySibling, hasAnyDescendant.
- A test chains multiple
assertIs* calls and the developer wants a single, declarative predicate.
When NOT to use this skill
- The query is "find by tag/text/content description only" — use
../finding-nodes-by-tag-text-content/SKILL.md.
- The right node exists but only as a relative — use
../traversing-the-semantics-tree/SKILL.md.
- The matcher is fine; only the assertions need rework — see
../../assertions/asserting-node-state-and-text/SKILL.md.
Prerequisites
androidx.compose.ui:ui-test on the test classpath. See ../../setup/configuring-test-dependencies/SKILL.md if unresolved.
- Comfort with
Modifier.semantics { … } in production code. Custom keys must be set via Modifier.semantics { customKey = value } to be matchable from tests.
Workflow
Patterns
Pattern: replace assertion chains with a single matcher
@Test
fun submit_isEnabledClickable() {
rule.setContent { CheckoutScreen() }
rule.onNodeWithText("Submit")
.assertHasClickAction()
.assertIsEnabled()
}
@Test
fun submit_isEnabledClickable() {
rule.setContent { CheckoutScreen() }
rule.onNode(hasText("Submit") and hasClickAction() and isEnabled())
.assertExists()
}
The composed matcher's description prints (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) && (OnClick is defined) && (is enabled) on failure, naming every unsatisfied clause.
Pattern: hierarchical match — "the Text inside the dialog"
rule.onAllNodes(isDialog())
.onFirst()
.onChildren()
.filterToOne(hasText("Discard?"))
.assertIsDisplayed()
rule.onNode(hasText("Discard?") and hasAnyAncestor(isDialog()))
.assertIsDisplayed()
Pattern: negation and "exactly one"
rule.onNode(hasText("Submit") and isEnabled() and !isToggleable())
.assertHasClickAction()
!matcher invokes operator fun not() (SemanticsMatcher.kt:72-74) — works on every matcher in the library.
Pattern: custom semantics key
val PriorityKey = SemanticsPropertyKey<Int>("Priority")
var SemanticsPropertyReceiver.priority by PriorityKey
@Composable
fun TaskRow(task: Task) {
Row(modifier = Modifier
.testTag(TaskRowTag)
.semantics { priority = task.priority }
) { }
}
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.expectValue(PriorityKey, 1))
.assertIsDisplayed()
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.keyIsDefined(PriorityKey))
.assertIsDisplayed()
Pattern: dropdown / IME-action / scroll-to-key — narrow a TextField query
rule.onNode(hasSetTextAction() and hasImeAction(ImeAction.Done))
.performTextInput("hello")
rule.onNode(hasScrollToKeyAction()).performScrollToKey(itemKey)
hasScrollToKeyAction is the conjunction hasKey(ScrollToIndex) and hasKey(IndexForKey) (Filters.kt:415-416). hasScrollToNodeAction adds an axis-range check (Filters.kt:419-425).
Pattern: matcher description appears in error messages
val matcher = hasText("Submit") and hasClickAction() and isEnabled()
println(matcher.description)
The description is human-readable and used by assert(matcher) (Assertions.kt:254-267) when the matcher does not hold. Clauses MUST be readable in isolation — that is why named matchers are preferred over inline lambdas.
Mandatory rules
- MUST use
infix and / infix or / operator not to compose matchers; MUST NOT chain assertIs* calls when a single composed matcher captures the same intent.
- MUST wrap custom semantics keys with
SemanticsMatcher.expectValue(...) or SemanticsMatcher.keyIsDefined(...); MUST NOT access node.config[CustomKey] from inside an ad-hoc lambda. The factory methods produce readable error descriptions.
- MUST prefer
hasAnyAncestor(isDialog()) / hasAnyDescendant(...) over multi-step traversal when the relationship is "somewhere above/below". Cross-reference: ../traversing-the-semantics-tree/SKILL.md.
- MUST keep the
useUnmergedTree flag at its default false unless the matcher targets an inner element collapsed by merge. Skydoves hot take #2.
- MUST NOT redefine an existing prebuilt matcher (e.g. writing a custom
SemanticsMatcher("is enabled") { … }) — use isEnabled(). Custom matchers are reserved for keys that don't have a prebuilt filter.
- PREFERRED: prefer tag-anchored matchers —
hasTestTag(SubmitTag) and isEnabled() — over text-anchored. Skydoves hot take #1.
Verification
References
- Compose testing overview: https://developer.android.com/develop/ui/compose/testing
- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Semantics in 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 — every prebuilt matcher (state, action, text, hierarchical).
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Assertions.kt — fun assert(matcher, messagePrefixOnError) and how the matcher description surfaces in errors.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteractionsProvider.kt — onNode(matcher, useUnmergedTree) / onAllNodes(matcher, useUnmergedTree).