DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Why Your Compose UI Test Can't Find That Button (Semantics vs. Text Matching)

Why Your Compose UI Test Can't Find That Button (Semantics vs. Text Matching)

You write onNodeWithText("Submit").performClick(), the test fails with "no matching
semantics node," and the button is right there on screen. Nothing about the UI is
broken — the test is looking for the wrong thing, and it's one of the most common ways
Compose UI tests get flaky or brittle without anyone noticing why.

The core misunderstanding

Compose UI tests don't query the screen the way a human reads it. They query the
semantics tree — a separate tree that Compose builds alongside the visual tree,
purely for accessibility and testing. A composable only shows up in a test query if it
(or something nested in it) actually merges the semantics property you're asking about.

onNodeWithText("Submit") matches on the merged Text semantics property. That
breaks the moment:

  • The visible label is drawn with Canvas or drawText, not a Text composable.
  • The button's content is an Icon with no visible text at all.
  • The text is split across multiple child composables and Compose merges them into a parent node you didn't expect.
  • The text is correct but wrapped in a Row with clearAndSetSemantics {} somewhere in the tree, which silently erases everything below it.

None of these are bugs in the app. They're all completely normal Compose UI. The test
was just asking the wrong question.

The fix: stop matching on text, start matching on identity

Give interactive elements a stable identity that has nothing to do with their visible
copy:

@Composable
fun SubmitButton(onClick: () -> Unit) {
    Button(
        onClick = onClick,
        modifier = Modifier.testTag("submit_button")
    ) {
        Text("Submit")
    }
}
Enter fullscreen mode Exit fullscreen mode
@Test
fun submitButton_click_triggersCallback() {
    composeTestRule.setContent {
        SubmitButton(onClick = { clicked = true })
    }
    composeTestRule.onNodeWithTag("submit_button").performClick()
    assertTrue(clicked)
}
Enter fullscreen mode Exit fullscreen mode

testTag is a semantics property specifically for this — it never shows up on screen,
never gets localized out from under your test, and doesn't care whether the label is a
Text, an Icon with a content description, or a custom-drawn glyph. Reserve
onNodeWithText for the one thing it's actually good at: asserting that copy is
correct, not for locating the element you're about to interact with. Those are two
different jobs and conflating them is why so many Compose UI tests both fail
mysteriously and fail to catch real copy regressions.

The second trap: merged vs. unmerged trees

Even with testTag in place, a surprisingly common failure is a node that exists in
the semantics tree but isn't reachable by the default query — because Compose merges
descendant semantics into a single parent node for accessibility services (a screen
reader shouldn't announce five separate child nodes for one logical button). Your test
runs against the merged tree by default.

If you're asserting on something that got merged away — e.g. checking that a specific
child Text inside a Row has certain semantics, when the Row itself is the thing
exposing merged semantics to accessibility — the query needs the unmerged tree:

composeTestRule
    .onNodeWithTag("submit_button", useUnmergedTree = true)
    .onChildren()
    .filterToOne(hasText("Submit"))
    .assertIsDisplayed()
Enter fullscreen mode Exit fullscreen mode

useUnmergedTree = true walks the tree as Compose actually built it, before the
accessibility-merge pass collapses it. This is the fix for the specific failure mode
where a node "should be there" by every visual inspection, printToLog() shows it
exists, but the default query still can't find it — because printToLog(), notably,
prints the unmerged tree by default too, which is exactly why it disagrees with your
failing query and makes this bug so confusing to debug the first time you hit it.

A minimal checklist before you write another Compose UI test

  1. Add testTag to anything you'll click or assert on, not just the ones that happen not to have a Text child today — UI copy changes, and a testTag-based test survives that; a text-based one silently starts failing for the wrong reason.
  2. Use onNodeWithText for content assertions, onNodeWithTag for element location. They're not interchangeable, even though both compile against the same SemanticsNodeInteraction API.
  3. Reach for useUnmergedTree = true the moment printToLog() shows a node that your query still can't find — that mismatch is almost always a merge boundary, not a real absence.
  4. Don't add Thread.sleep or retry loops to "fix" a node-not-found failure. It's never actually timing; Compose test rules already run synchronously against the composition by default. A node-not-found failure is a semantics-tree mismatch, and sleeping just delays the same failure or turns it into a flaky pass/fail coin flip.

None of this requires a testing framework beyond what compose.ui.test already ships
with — it's entirely about understanding the semantics tree Compose is actually
building, instead of assuming the test API mirrors what's visually on screen.

If you want the fuller picture — Hilt-based fake injection for ViewModel-level tests,
Turbine for asserting on StateFlow/Flow emissions without race conditions, and the
full Espresso-to-Compose migration checklist for mixed-UI apps — I wrote it all up in
the Android & Compose Testing Playbook.

Top comments (0)