mcpField
C
composing-semantics-matchers

composing-semantics-matchers

github.com/skydovesTesting & quality33962 installsUpdated 22w ago
Repository

About composing-semantics-matchers

A skill for composing SemanticsMatcher predicates in Jetpack Compose UI tests. It helps developers combine built-in filters, custom semantics keys, and hierarchy checks into one query with informative failure descriptions.

Key features

  1. 01Compose and, or, and not matchers
  2. 02Use built-in state and action filters
  3. 03Match custom semantics keys
  4. 04Find nodes by ancestor or descendant
  5. 05Show matcher clauses in failures

Use cases

  1. 01Find a button by text, enabled state, and click action
  2. 02Locate text within a dialog
  3. 03Test nodes with custom semantics properties

Install

Copy the command and run it in a terminal.

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

After installing, type/composing-semantics-matchersin Claude Code to confirm it loaded.

Details

From the project README

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

  • 1. Build matchers from the prebuilt filter library. Every prebuilt matcher lives in commonMain/.../Filters.kt. Group by signal:

    SignalMatchersFile:line
    Enabled / disabledisEnabled(), isNotEnabled()Filters.kt:38-47
    Toggle (Switch, Checkbox)isToggleable(), isOn(), isOff()Filters.kt:54-70
    Selection (Tab, RadioButton)isSelectable(), isSelected(), isNotSelected()Filters.kt:77-93
    FocusisFocusable(), isNotFocusable(), isFocused(), isNotFocused()Filters.kt:100-122
    Click actionhasClickAction(), hasNoClickAction()Filters.kt:129-136
    Scroll actionhasScrollAction(), hasNoScrollAction(), hasScrollToIndexAction(), hasScrollToKeyAction(), hasScrollToNodeAction()Filters.kt:143-425
    Text actions (TextField)hasSetTextAction(), hasInsertTextAtCursorAction(), hasPerformImeAction(), hasImeAction(ImeAction)Filters.kt:365-392
    Focus actionhasRequestFocusAction()Filters.kt:399
    Content descriptionhasContentDescription(value, substring, ignoreCase), hasContentDescriptionExactly(vararg)Filters.kt:165-212
    TexthasText(text, substring, ignoreCase), hasTextExactly(vararg, includeEditableText=true)Filters.kt:229-293
    A11y / state metadatahasStateDescription, hasProgressBarRangeInfo, isHeading, isDialog, isPopup, isHiddenFromAccessibility, isEditable, isRootFilters.kt:301-439
    Test taghasTestTag(testTag)Filters.kt:326-327
    HierarchicalhasParent(matcher), hasAnyChild(matcher), hasAnySibling(matcher), hasAnyAncestor(matcher), hasAnyDescendant(matcher)Filters.kt:446-530

    hasText searches SemanticsProperties.Text, SemanticsProperties.EditableText, AND SemanticsProperties.InputText simultaneously (Filters.kt:229-258). hasContentDescription matches against the merged list of descriptions (Filters.kt:165-188).

  • 2. Compose with and / or / not. Combinators come from SemanticsMatcher.kt:60-74. Each produces a new matcher whose description reads (left) && (right) / (left) || (right) / NOT (inner). The framework prints this description verbatim on assertion failure, so the failure message shows exactly which clause(s) failed.

  • 3. Use SemanticsMatcher.expectValue for custom keys. When the production code declares a custom SemanticsPropertyKey<T> and writes it via Modifier.semantics { customKey = value }, match it with SemanticsMatcher.expectValue(customKey, expectedValue) (SemanticsMatcher.kt:33-37). For "this key is set at all", use SemanticsMatcher.keyIsDefined(customKey) (SemanticsMatcher.kt:40-42); for "this key is NOT set", use keyNotDefined (SemanticsMatcher.kt:45-47). Built-in matchers like isOn() are themselves thin wrappers over expectValue(SemanticsProperties.ToggleableState, On) (Filters.kt:61-62).

  • 4. Pass the composed matcher to onNode/onAllNodes. onNode(matcher, useUnmergedTree=false) and onAllNodes(matcher, useUnmergedTree=false) are the canonical entry points (SemanticsNodeInteractionsProvider.kt). Both onNodeWithTag and friends are convenience wrappers — drop them when the predicate gets richer.

  • 5. For hierarchical assertions, prefer hasAnyAncestor/hasAnyDescendant over multiple traversal steps. Example: "find the Text inside the dialog" → onNode(hasText("Discard?") and hasAnyAncestor(isDialog())). Cleaner than onAllNodes(isDialog()).onFirst().onChildren().filter(hasText(...)).onFirst().

Patterns

Pattern: replace assertion chains with a single matcher
// 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()
}

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"
// 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()
Pattern: negation and "exactly one"
// "An enabled, non-toggleable Button (so: not a Switch or Checkbox) with text Submit"
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
// 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()
Pattern: dropdown / IME-action / scroll-to-key — narrow a TextField query
// "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 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)
// (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) &&
// (OnClick is defined) && (is enabled)

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

  • Every multi-condition query uses one onNode(matcher) rather than chained finders + assertions.
  • Custom key checks use SemanticsMatcher.expectValue / keyIsDefined — node.config[...] does not appear in matcher predicates.
  • ./gradlew :app:connectedDebugAndroidTest or :app:testDebugUnitTest passes.
  • Failure messages from intentionally-broken matchers print the composed description so the failing clause is identifiable.
  • No matcher is silently shadowed by a re-declared local lambda with the same name.

References

Comments

Markdown supported