Your UI tests query the accessibility tree, which is why testTag is a last resort

Compose UI tests match against the semantics tree, not the layout. Selecting by text and content description tests what users perceive; selecting by testTag tests an implementation detail you invented for the test.

6 min read
androidcomposekotlintesting

Day 84 — Your tests query the accessibility tree

Day 84 of 100, opening the testing pillar. Day 67 said your tests already depend on the semantics tree; this is what follows from that.

The symptom

A test suite of opaque selectors:

@Test fun canSubmitOrder() = runComposeUiTest {
    setContent { OrderScreen(sample) }
    onNodeWithTag("order-screen-submit-button-primary").performClick()
    onNodeWithTag("order-screen-confirmation-dialog").assertExists()
}

Every element carries a tag invented for the test. The test passes, and it tells you almost nothing: the button might say "Cancel", it might be invisible, it might announce itself as a checkbox. The tag matched, so the test is green.

Then a redesign renames the tags and forty tests fail with no functional change.

Why the obvious approach fails

The obvious approach is to tag everything on the theory that tags are stable selectors.

They are stable — that's the problem. A testTag is a string you added for the test's benefit, so it survives changes to the visible interface. A test that only checks tags passes on a screen a user cannot operate.

Tags also don't exist for the user, so a test built on them can't tell you whether the label is right, whether the control is enabled, or whether it's announced correctly.

Tests match the same semantics tree TalkBack reads — so a good selector is what a user perceives

The actual mechanism

Compose UI tests match against the semantics tree — the same structure from Days 67–70. There is no separate test tree, no view hierarchy being queried, no reflection over your composables.

That has a direct consequence: a good test selector is a thing the user can perceive.

onNodeWithText("Submit order")                        // what it says
onNodeWithContentDescription("Delete message")        // what it's called
onNode(hasClickAction() and hasText("Submit"))        // what it does

Each of these fails if the label changes, which is correct — a renamed button is a change worth a test noticing. Each also passes only if the element is actually in the semantics tree, which means it's reachable by TalkBack too.

The selector hierarchy worth adopting:

  1. Text — for anything with a visible label.
  2. Content description — for icon-only controls.
  3. Role plus a propertyhasClickAction(), isToggleable(), isSelected().
  4. testTag — when none of the above can identify the element.

When testTag is right

It isn't never. Three legitimate cases:

A container with no label of its own — a list, a scroll region, a section wrapper. onNodeWithTag("order-list").performScrollToIndex(50) has no text-based equivalent.

Disambiguating identical content — three rows all saying "Pending". A tag derived from the data (testTag("row-${order.id}")) is more honest than an index.

Cross-framework tooling — UIAutomator and Appium see tags as resource ids when testTagsAsResourceId = true is set, which is the only way to reach Compose content from those tools.

What makes a tag wrong is using it where a text selector would work. The tag adds no information and removes the assertion that the label is correct.

Reading the tree

The single most useful debugging call in the pillar:

composeRule.onRoot().printToLog("TREE")

It dumps every node with its properties — text, content description, actions, merged state. When a selector doesn't match, this says why in about five seconds, and it's faster than guessing at the matcher.

The merged/unmerged distinction from Day 68 applies directly:

onRoot().printToLog("MERGED")
onRoot(useUnmergedTree = true).printToLog("UNMERGED")

A card merged into one node announces its children's text concatenated, so onNodeWithText("£79.99") fails against the merged tree and succeeds against the unmerged one. Reaching for useUnmergedTree is sometimes right and is always worth a moment's thought — asserting against the merged tree tests what a user experiences.

Matchers compose

The matcher API is a small algebra, which is what makes precise selection possible without tags:

onNode(hasText("Delete") and hasClickAction())
onNode(hasContentDescription("Archive") and !isEnabled())
onAllNodes(isSelectable()).filter(isSelected()).assertCountEquals(1)

// relationships
onNode(hasParent(hasTestTag("order-list")) and hasText("Pending"))
onNode(hasAnyDescendant(hasText("Overdue")))

and, or, !, plus the structural matchers — hasParent, hasAnyChild, hasAnyAncestor, hasAnyDescendant. Between them, almost any element is reachable without inventing an identifier.

The assertion is the other half

A selector that matches proves an element exists. What you assert about it is where the test's value is:

onNodeWithText("Submit order")
    .assertIsDisplayed()          // in the tree AND visible
    .assertIsEnabled()
    .assertHasClickAction()

assertExists() versus assertIsDisplayed() is the distinction that catches people — the first passes for an element scrolled off screen or with zero size. For "the user can see this", assertIsDisplayed() is the assertion.

The state assertions are the ones that catch real regressions: assertIsEnabled(), assertIsOn(), assertIsSelected(), assertTextEquals(). A test that only asserts existence passes on a disabled button.

The accessibility dividend

Worth being explicit because it's the pillar's most useful practical claim: writing tests against text and descriptions forces the semantics to be correct.

An icon button with no contentDescription has no text-based selector, so the test can't find it, so you add the description — and TalkBack users get it too. A card that produces four nodes makes selectors awkward, so you merge it, and the swipe count drops.

Days 67–70's work and this pillar's work are largely the same work. That's not a moral argument; it's why teams that test this way tend to have accessible apps without a separate effort.

How to prove it

Take an existing test that uses tags and rewrite one selector as text. If it still passes, the tag was redundant. If it doesn't, the element has no accessible name — which is a bug the tag was hiding.

Then run the same screen with TalkBack. The elements your tests can find should be exactly the ones TalkBack announces, and a mismatch in either direction is worth understanding.

Text matching has options

Two parameters on the text finders that save a lot of brittleness:

onNodeWithText("submit", ignoreCase = true)
onNodeWithText("Order #", substring = true)

substring is the one to reach for when a label contains dynamic content — an order number, a count, a formatted date. Asserting on the whole string couples the test to the formatting, which Day 85 argues belongs in a unit test anyway.

The related trap: onNodeWithText fails if more than one node matches, with a message about multiple nodes rather than about your intent. onAllNodesWithText(…)[0] works and is usually a sign the selector should be narrowed with hasParent instead.

What this generalizes to

The principle is test the interface, not the implementation — with a Compose-specific sharpening: the interface here is a real, inspectable structure, not an abstraction.

That's unusual and useful. In most UI frameworks "test what the user sees" is advice; in Compose it's mechanical, because the tree your test queries is the tree assistive technology consumes. A selector that works for one works for the other, and a selector that needs an invented identifier is telling you something is missing.

The web has the same property and learned it the same way. Testing Library's whole design argument — query by role, by label, by text, and treat a test id as the escape hatch — is this post with different function names, and it caught on because the alternative produced suites that passed while the product was broken.

Tomorrow, Day 85: the testing APIs — finders, actions and assertions in detail, and the rule for when to reach for each.


Day 84 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Testing the semantics tree.