Engineering

A testTag is a contract between the screen and its test

Tests that assert on visible text break on copy edits, which trains people to weaken assertions. A test tag is a deliberate contract that survives rewording.

MobileByteSensei 10 min read
An identifier fitted on purpose, independent of everything the bird looks like
composetestingkotlin

A UI test finds a button by its label and asserts something about it. It passes, it is readable, and anybody can see what it is checking.

Then somebody changes the wording from "Save changes" to "Save", and the test fails for a reason that has nothing to do with behaviour.

The case this post is about

Two ways to find the same button in a test:

Modifier.testTag(Tags.saveButton)   stable; survives copy, locale, layout
onNodeWithText("Save changes")      breaks on a copy edit, in another language

The second is the one the documentation encourages, for a good reason: it asserts on what the user actually perceives. It also means a copy edit from "Save changes" to "Save" breaks a test that has nothing to do with copy.

What happens next is the subject. The test is fixed by loosening it — a substring match, a regex, a case-insensitive contains — and after three of those the test passes against text nobody intended.

This post follows those two lines, because neither is simply correct, and the choice determines what kind of change is allowed to break your suite.

The symptom

The first version is a test suite that goes red on copy edits. A product change to wording — four words, no logic — breaks eleven tests across six files, none of which were testing the wording. The failure is loud, the cause is trivial, and fixing it is eleven mechanical edits.

None of that is the expensive part. Eleven mechanical edits cost twenty minutes; what costs is that the suite has just demonstrated, to everybody watching, that red does not necessarily mean anything — and a team that has learned to expect false reds reads the next one more slowly. The damage is to how much the suite is believed, and that is not recovered by fixing the eleven tests.

The second thing is what that teaches. After the second or third time, people stop writing precise assertions. The fix for a brittle text assertion is to loosen it — match a substring, match case insensitively, match anything containing "save" — and each loosening makes the test slightly less capable of detecting a real regression. The suite drifts toward assertions that pass whatever happens.

Three of the same, told apart only by a condition that changes

The third is the localisation collision. Once strings are resources, the text rendered depends on the locale the test runs under, so a test asserting on English text is implicitly asserting on a configuration. It passes on a machine with one default and fails on another, which is the worst kind of flake because it looks environmental and is actually a design mistake in the assertion.

The fourth is the assertion that stops meaning anything without anybody noticing. A test that looked for a specific error message, loosened to match any text in a container, now passes when the container shows a success message. It was weakened for a good reason at each step, and no single step looked like it broke the test.

Why the obvious fix failed

Assert on the string resource rather than the literal. A real improvement — the test and the screen now reference the same key, so a copy edit changes both together. It solves the brittleness and leaves the test coupled to presentation: a redesign that moves the text into an icon's content description, or splits one label into two, breaks it again.

Match loosely. Substring and case-insensitive matching make the failures stop, which is the point and the problem. The failures stopping is indistinguishable from the test no longer checking, and nothing in the suite's output distinguishes those two outcomes.

Find by position. Assert on the third item in the row. Robust against wording and fragile against every layout change, including ones that are purely visual — and completely opaque to read, because the assertion no longer says what it is talking about.

Accept the churn. Treat test updates as part of copy changes. Defensible on a small suite, and it scales badly: the cost is paid by whoever makes the smallest, least risky kind of change, which is exactly the work you want to stay cheap.

The actual mechanism

Put a tag on the element — a stable identifier that exists for the test — and assert on that.

Modifier.testTag(Tags.saveButton)   stable; survives copy, locale, layout
onNodeWithText("Save changes")      couples the test to the wording

The important reframing is that a tag is not a testing convenience. It is an API the screen publishes, deliberately, for automated consumers — the same way a public method is an API for callers. Once it is named that way the right practices follow: tags are declared in one place rather than typed as literals, they are stable across refactors, and removing one is a breaking change rather than a cleanup.

The same individual before and after a moult, and the one thing that did not change

That framing also answers how many you should have. An API is small on purpose. A tag on every element is a screen with no encapsulation at all, where any internal rearrangement breaks something; tags on the things a test genuinely needs to address — the primary action, the list, the error surface — is a contract you can hold.

A deliberately small graded set, beside the heap it could have been

The sub-case that is easy to get wrong: what the test asserts once it has found the node. Finding by tag and then asserting on the node's text has reintroduced the coupling at the second step. The assertion should be about state — enabled, displayed, selected, count — and where the content genuinely matters, it should compare against the same resource the screen used rather than a literal.

There is a related point about what a tag should be named after. A tag named for the element's current appearance — the blue button, the top row — is as fragile as the text, just less obviously. Named for its role, it survives everything except the role disappearing, which is a change that should break the test.

The fix

declare   tags in one place per screen, not as literals at the call site
tag       the elements a test needs to address, not every element
assert    on state, not on the text of the node you found
resource  where content matters, compare against the key the screen renders

All four lines on one screen. The tags are a declared object, the screen references it, and the test asserts on state rather than on anything the copywriter can change:

// ProfileTags.kt — the API the screen publishes, in one place
object ProfileTags {
    const val SaveButton = "profile:save"
    const val ErrorBanner = "profile:error"
    const val ItemRow = "profile:item"              // per-instance: ItemRow + stable id
}

// ProfileScreen.kt — the screen references the object, never a literal
Button(
    onClick = onSave,
    enabled = state.isDirty,
    modifier = Modifier.testTag(ProfileTags.SaveButton),
) { Text(stringResource(Res.string.profile_save)) }

// ProfileScreenTest.kt — finds by tag, asserts on STATE
@Test
fun saveIsDisabledUntilSomethingChanges() = runComposeUiTest {
    setContent { ProfileScreen(state = ProfileState(isDirty = false), onSave = {}) }

    onNodeWithTag(ProfileTags.SaveButton).assertIsNotEnabled()   // not assertTextEquals
}

The test never mentions the word "Save". Rewording the button, translating it, or moving it into an icon's content description leaves this test untouched, because none of those change whether the button is enabled — which is the only thing it was ever about.

Rename the copy and run both suites:

$ ./gradlew :feature:profile:test
BUILD SUCCESSFUL — 14 tests, 0 failed      # tag-based: wording is not an input

# the same change against the text-based suite it replaced
BUILD FAILED — 14 tests, 11 failed         # none of which test wording

The first line matters more than it looks. Tags typed as string literals in both the screen and the test are two literals that must agree, which is the same drift problem as any other duplicated constant — and it fails as a typo that reports "node not found", which reads like a test problem rather than a mismatch.

The third line is where most of the remaining brittleness hides after somebody has adopted tags. It is easy to treat the tag as the whole fix, find the node by tag, and then assert on its text anyway — which means the test is still coupled to the wording and will still break on a copy edit, with the tag providing nothing but a more reliable way to reach the thing that breaks it. Tags make the lookup durable; what you assert afterwards is a separate decision and it is the one that determines whether the test is testing behaviour.

The practical version of that decision is short. Ask what the test is for, and assert the smallest thing that would be false if it stopped being true: that the button is enabled, that the list has three rows, that the error surface is displayed. Each of those survives every change except the one it exists to catch, which is the property you were after when you added the tag.

How we proved it

The brittleness is measurable directly: change one piece of copy and count the failing tests. If the number is greater than zero and none of those tests exist to check wording, the suite is coupled to presentation. That takes one commit to establish and it is hard to argue with.

The same change, matched two ways

The loosening trajectory is visible in history. Assertions that started specific and became substring matches are traceable through the log, and each commit has a reasonable message about fixing a broken test. Read in sequence they are a record of a suite being gradually disarmed, which is a thing nobody decided and everybody contributed to.

What I have not measured is whether tags caught a regression that text assertions would have missed. The argument here is about maintenance cost and about not training people to weaken assertions — both real, both about the suite's long-term capability rather than about a specific bug caught. I do not have the bug.

What I have not solved: tags on shared components. A list item rendered by a shared composable cannot carry a single fixed tag, because there are many of them — so you need a tag per instance, usually derived from the item's identity, and that reintroduces a question about what the identity is and whether it is stable. I have handled this case by case and do not have a rule I trust.

And a cost worth naming: tags are production code that exists only for tests. They appear in the screen, add a line per tagged element, and a reader of the screen has to know what they are for. That is a genuine readability tax, paid in the production file, to make the test file durable — a trade I think is right and which is not free.

The shared-component case is worth one more paragraph because it is the gap most likely to matter to somebody reading this. The pattern I keep returning to is a tag derived from the item's stable identity — the record's own identifier, not its index — which works whenever such an identifier exists and fails exactly when it does not. Index-derived tags look reasonable and are a trap: they are stable until something is inserted, at which point every test addressing a row below the insertion silently addresses a different row and may well still pass.

That failure is worse than the brittle one this post is about, because it is quiet. A broken text assertion goes red and annoys somebody; a tag that now points at the wrong row reports green while testing the wrong thing. I do not have a general rule for it, and the heuristic I actually use is that if a list's items have no stable identity of their own, that is usually worth fixing in the data rather than working around in the test.

What this generalizes to

Tests fail for two different reasons and the suite cannot tell them apart: the behaviour changed, or the way the test was finding things changed. Only the first is information.

Every test couples to the system through some surface, and the durability of the test is the durability of that surface. Text, position, class names, DOM structure, generated identifiers — all of them are incidental, all of them change for reasons unrelated to behaviour, and each change produces a failure indistinguishable from a real one. The suite that is coupled to incidental surfaces trains everybody to treat red as noise.

So the question to ask of any assertion is what would have to change for this to break. If the answer includes things that are not behaviour, the test will eventually be weakened by somebody reasonable, and the weakening will not look like a decision. Introducing a deliberate, stable surface for tests to address is how you keep red meaning something — which is the only property that makes a suite worth running.

One post a week, no digest

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

Follow by RSS