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
CanvasordrawText, not aTextcomposable. - The button's content is an
Iconwith 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
RowwithclearAndSetSemantics {}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")
}
}
@Test
fun submitButton_click_triggersCallback() {
composeTestRule.setContent {
SubmitButton(onClick = { clicked = true })
}
composeTestRule.onNodeWithTag("submit_button").performClick()
assertTrue(clicked)
}
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()
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
-
Add
testTagto anything you'll click or assert on, not just the ones that happen not to have aTextchild today — UI copy changes, and atestTag-based test survives that; a text-based one silently starts failing for the wrong reason. -
Use
onNodeWithTextfor content assertions,onNodeWithTagfor element location. They're not interchangeable, even though both compile against the sameSemanticsNodeInteractionAPI. -
Reach for
useUnmergedTree = truethe momentprintToLog()shows a node that your query still can't find — that mismatch is almost always a merge boundary, not a real absence. -
Don't add
Thread.sleepor 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)