Engineering

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.

MobileByteSensei 8 min read
Day 84 — Your tests query the accessibility tree
androidcomposekotlintesting

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 case this post is about

One test, written with opaque selectors:

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

It passes, it is stable, and it asserts almost nothing a user would notice. Rename the button from "Submit order" to "Place order" and the test stays green. Remove the button's label entirely and the test stays green.

Those tags also exist only for the test, so they are invisible to everything else — including the accessibility tree, which is the same tree the test is querying.

This post follows that one test, because selecting by what a user can perceive makes the test fail when the user-visible thing changes, which is the whole point of having it.

The symptom: a test suite of opaque selectors

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 tagging everything doesn't make tests stable

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 mechanism: Compose tests match against the semantics tree

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 property — hasClickAction(), isToggleable(), isSelected().
  4. testTag — when none of the above can identify the element.

That hierarchy is a preference order, and preference orders are worth writing as code because then a selector can be chosen from a node rather than typed from habit:

data class Node(
    val text: String? = null,
    val contentDescription: String? = null,
    val clickable: Boolean = false,
    val toggleable: Boolean = false,
    val testTag: String? = null,
)

fun selectorFor(node: Node): String = when {
    node.text != null -> "onNodeWithText(\"${node.text}\")"
    node.contentDescription != null -> "onNodeWithContentDescription(\"${node.contentDescription}\")"
    // A role-based selector needs a role AND something to disambiguate it. A bare
    // hasClickAction() matches every button on the screen, which is not a selector.
    node.toggleable -> "onNode(isToggleable())"
    node.clickable && node.testTag != null -> "onNode(hasClickAction() and hasTestTag(\"${node.testTag}\"))"
    node.testTag != null -> "onNodeWithTag(\"${node.testTag}\")"
    else -> "UNREACHABLE — this node is in no selector's range, and TalkBack cannot reach it either"
}

The inbox screen's nodes, run through it:

listOf(
    Node(text = "Submit order", clickable = true),
    Node(contentDescription = "Delete message", clickable = true),
    Node(toggleable = true, testTag = "unread_filter"),
    Node(clickable = true, testTag = "row_4711"),
    Node(clickable = true),
).forEach { println(selectorFor(it)) }
onNodeWithText("Submit order")
onNodeWithContentDescription("Delete message")
onNode(isToggleable())
onNode(hasClickAction() and hasTestTag("row_4711"))
UNREACHABLE — this node is in no selector's range, and TalkBack cannot reach it either

The last line is the point of running it. That node is a clickable with no text, no description and no tag — an icon-only button somebody built with a Box and a clickable modifier. It has no test selector because it has no accessibility label, and those are the same absence rather than two separate problems. A test suite that reaches for testTag first would have papered over it with a tag and shipped a button TalkBack announces as "button".

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: rewrite one tag selector as text

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: test the interface, not the implementation

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.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS