Build for Everyone · Part 3 of 5
Previously: Part 1 (“Raising the Floor”) set the baseline rules, and Part 2 (“From Five Stars to One Sentence”) built a custom rating control with real semantics. This part makes sure none of that quietly breaks.
TL;DR Accessibility regressions don’t crash, don’t change a single pixel, and don’t fail a single existing test. You catch them by testing the semantics tree, in five layers:
- Targeted assertions on the nodes that matter.
- Bulk assertions across groups of nodes.
- Touch-size checks.
- Automated audits with Google’s Accessibility Test Framework (ATF).
- A short manual TalkBack routine for everything machines can’t judge.
Each layer catches what the one below it can’t.
The regression nobody noticed
Here’s a migration that happens in real codebases. The design system team ships a new AppIconButton to replace scattered IconButton usages. It looks like this:
@Composable fun AppIconButton(icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) { IconButton(onClick = onClick, modifier = modifier) { Icon(icon, contentDescription = null) // "the caller can add semantics if needed" } } Forty call sites are migrated in one pull request, and the review approves it. Everything checks out:
- The compiler is happy: no call site passes a description, because there’s no parameter for one.
- Screenshot tests pass, because the pixels are identical.
- Unit tests pass, because none of them ever looked at labels.
Three weeks later, a screen reader user writes in: the share, save, and delete buttons all say “Unlabeled, button.”
That’s why accessibility needs its own tests. Every check you already run looks at pixels or behavior. TalkBack reads neither. It reads the semantics tree. So that’s what the tests have to read too.
The fix for AppIconButton is a required contentDescription: String? parameter, with no default, so every caller has to decide. The protection is the test layers below.
Layer 1: Targeted semantics assertions
Start with the nodes that carry the most risk: primary actions, custom controls, and anything a redesign is likely to touch. Compose UI tests find nodes by semantics, so the test reads the same tree TalkBack does.
One rule before any code: never hard-code visible strings in tests. "Save recipe" breaks the day someone edits the copy, and it means you can never run the suite in another locale. Resolve strings from resources the same way the app does:
private val context: Context = ApplicationProvider.getApplicationContext() private fun str(@StringRes id: Int, vararg args: Any): String = context.getString(id, *args) Now assert what TalkBack depends on:
class RecipeHeaderA11yTest { @get:Rule val rule = createComposeRule() @Test fun header_exposesHeadingAndLabelledActions() { rule.setContent { AppTheme { RecipeHeader(recipe = sampleRecipe, onSave = {}, onShare = {}) } } rule.onNode(hasText(sampleRecipe.title) and isHeading()) .assertIsDisplayed() rule.onNodeWithContentDescription(str(R.string.cd_save_recipe, sampleRecipe.title)) .assertIsDisplayed() .assertHasClickAction() rule.onNodeWithContentDescription(str(R.string.cd_share_recipe, sampleRecipe.title)) .assertHasClickAction() } } (Heading semantics are the subject of Part 4. Here we only check they exist.)
The real payoff comes with custom controls. Part 2’s RatingStars made promises in its semantics: one node, a state description, a range, and actions that keep the spoken value in sync. Each promise becomes an assertion:
class RatingStarsA11yTest { @get:Rule val rule = createComposeRule() private val label get() = str(R.string.label_recipe_rating) @Test fun rating_isOneAdjustableNodeWithSpokenValue() { rule.setContent { RatingStars(rating = 3, onRatingChange = {}, label = label) } rule.onNodeWithContentDescription(label) .assert(hasStateDescription(str(R.string.rating_state, 3, 5))) .assert(hasProgressBarRangeInfo(ProgressBarRangeInfo(3f, 0f..5f, steps = 4))) .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress)) } @Test fun increaseAction_updatesStateAndSpokenValue() { var rating by mutableIntStateOf(3) rule.setContent { RatingStars(rating = rating, onRatingChange = { rating = it }, label = label) } rule.onNodeWithContentDescription(label) .performCustomAccessibilityActionWithLabel(str(R.string.rating_action_increase)) assertEquals(4, rating) rule.onNodeWithContentDescription(label) .assert(hasStateDescription(str(R.string.rating_state, 4, 5))) } @Test fun increaseAction_isNotOfferedAtMaximum() { rule.setContent { RatingStars(rating = 5, onRatingChange = {}, label = label) } rule.onNodeWithContentDescription(label) .assert(!hasCustomAction(str(R.string.rating_action_increase))) } } fun hasCustomAction(label: String) = SemanticsMatcher("has custom action \"$label\"") { node -> node.config.getOrNull(SemanticsActions.CustomActions).orEmpty().any { it.label == label } } The second test is the one that pays for itself. It invokes the action the way an accessibility service would, then checks two things:
- The state changed. This catches callbacks that were never hoisted.
- The spoken state changed. This catches Part 2’s stale
remember { }trap.
A tap-based test catches neither.
Layer 2: Bulk assertions
Targeted tests protect the nodes you thought about. Bulk tests protect the ones you didn’t, like every product image in a grid or every icon button in a toolbar. Use test tags to select the group:
AsyncImage( model = product.imageUrl, contentDescription = product.altText ?: product.name, modifier = Modifier.testTag(TestTags.PRODUCT_IMAGE), ) Then make the check reusable. There are two traps in this helper, and both cause false greens, meaning the test passes when it shouldn’t:
fun SemanticsNodeInteractionsProvider.assertAllHaveDescriptions(tag: String, minimumCount: Int = 1) { // Trap 1: tagged images inside a clickable card are merged into the card. // In the default (merged) tree, the tagged node doesn't exist on its own. val nodes = onAllNodesWithTag(tag, useUnmergedTree = true) // Trap 2: assertAll on an empty collection passes. Prove the nodes exist first. val found = nodes.fetchSemanticsNodes().size assertTrue(found >= minimumCount, "Expected at least $minimumCount nodes tagged '$tag', found $found") nodes.assertAll(hasMeaningfulContentDescription()) } fun hasMeaningfulContentDescription() = SemanticsMatcher("has a meaningful contentDescription") { node -> val placeholders = setOf("image", "icon", "photo", "picture") node.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() .any { it.isNotBlank() && it.trim().lowercase() !in placeholders } } SemanticsMatcher.keyIsDefined(ContentDescription) alone isn't enough: an empty string "" or a lazy "image" passes it. The custom matcher rejects both.
Usage is one line per group:
@Test fun productGrid_everyImageIsDescribed() { rule.setContent { AppTheme { ProductGrid(products = sampleProducts, onOpen = {}) } } rule.assertAllHaveDescriptions(TestTags.PRODUCT_IMAGE, minimumCount = sampleProducts.size) } One more caution about lazy layouts. LazyVerticalGrid only composes the items that are visible. With twenty products, the test above checks perhaps six of them. Either feed the test a small dataset that fits on screen, or scroll with performScrollToIndex and assert in batches.
Layer 3: Touch target checks
Part 1’s 48dp rule becomes an assertion:
@Test fun toolbarActions_meetMinimumTouchSize() { rule.setContent { AppTheme { RecipeToolbar(onBack = {}, onSave = {}, onShare = {}) } } val clickables = rule.onAllNodes(hasClickAction()) val count = clickables.fetchSemanticsNodes().size assertTrue(count > 0, "No clickable nodes found") repeat(count) { index -> clickables[index] .assertTouchWidthIsAtLeast(48.dp) .assertTouchHeightIsAtLeast(48.dp) } } Both assertions are part of androidx.compose.ui.test and run against a SemanticsNodeInteraction inside a Compose UI test. They are not runtime modifiers. They measure a node's touch bounds, which aren't always the same as its layout bounds. ATF (Layer 4) measures the bounds Android reports to accessibility services. If the two layers ever disagree about a control, trust the stricter one and fix the layout, as Part 1 did with sizeIn(minWidth = 48.dp, minHeight = 48.dp). That version satisfies both.
Layer 4: Automated framework checks
Layers 1 to 3 check what you thought to assert. The Accessibility Test Framework (ATF), the same engine behind Accessibility Scanner, checks what you didn’t. It runs a catalogue of rules against the live accessibility tree.
On Compose 1.8 and later, ATF is wired into Compose tests through a separate artifact:
// build.gradle.kts androidTestImplementation("androidx.compose.ui:ui-test-junit4-accessibility:<compose-version>") class RecipeScreenAuditTest { // Accessibility checks need a real Activity behind the composition. @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @Before fun enableChecks() { rule.enableAccessibilityChecks() } @Test fun recipeScreen_passesAutomatedAudit() { rule.setContent { AppTheme { RecipeScreen(recipe = sampleRecipe) } } rule.onRoot().tryPerformAccessibilityChecks() } } Once enabled, checks also run when your tests perform actions like performClick(). That means every existing interaction test becomes an accessibility audit at no extra cost.
For View-based or hybrid screens tested with Espresso, the equivalent switch is set once per test class. setRunChecksFromRootView(true) audits the whole window, not just the view being acted on:
companion object { @BeforeClass @JvmStatic fun enableAccessibilityChecks() { AccessibilityChecks.enable() .setRunChecksFromRootView(true) .setSuppressingResultMatcher( // Narrow, documented, temporary: one check, one view, one ticket. allOf( matchesCheck(TextContrastCheck::class.java), matchesViews(withId(R.id.legacy_promo_banner)), // A11Y-412, remove by Q1 ), ) } } What ATF catches well:
- missing labels on things people can act on
- touch targets that are too small
- duplicate or redundant descriptions (a description that says “button” on a button)
- low contrast
- clickable elements stacked on top of each other
Contrast results depend on ATF capturing a screenshot, so keep Part 1’s theme contrast test as your main contrast guard.
What ATF can’t catch: whether a label makes sense, whether the reading order matches the intent, whether focus lands somewhere sensible after an error, and whether a custom control can actually be operated. A label of "btn_2" passes every automated check ever written.
Layer 5: Manual testing
Automated layers protect the floor. Only people can judge the experience. Keep the manual part small enough that it actually happens on every release: ten minutes, one critical flow, every release candidate.
- Linear swipe (minutes 0–2). Swipe through the flow from start to finish with TalkBack’s speech output shown on screen. Listen for “Unlabeled,” duplicated phrases, and moments where you lose track of where you are.
- Operate, don’t just listen (2–4). Use every custom control with adjust gestures and the Actions menu only. No sighted shortcuts.
- Break something (4–6). Submit an invalid form and trigger a loading state. Is the error spoken? Does the result get announced? (That’s Part 4.)
- Scale up (6–8). Set the largest font size and display size. Look for clipped text, overlapping controls, and buttons pushed off screen.
- Scan (8–10). Run Accessibility Scanner on each screen in the flow. It’s ATF with a UI, and it catches things on real data that your test fixtures never contain.
Involve real users at least once per major feature. Five sessions with people who use TalkBack, Switch Access, or magnification every day will teach you more than months of internal testing. Pay them for their time, give them tasks (“reorder last week’s groceries”) rather than asking “is this accessible?”, and watch quietly.
Putting tests in CI
Split the layers by cost so the fast ones run on every change:
- On every pull request: Layers 1 to 3. Semantics and touch-size assertions run on the JVM with Robolectric, so they take seconds and need no device. This is where the
AppIconButtonregression gets stopped. - On merge or nightly: Layer 4 on an emulator. Gradle Managed Devices give you the same emulator image every run, without anyone looking after a device farm.
- Before release: Layer 5, the manual routine, run by a person and signed off in the release checklist.
Keeping them stable:
- Disable animations in
testOptions. - Pin font scale, locale, and display size per run, so ATF results don’t drift between machines.
- Use fixed fixtures instead of live network data.
Triage every failure into one of three buckets:
- Regression: blocks the merge. No exceptions.
- Known issue: suppress it narrowly. One check, one element, a ticket number, and a date to remove it, as in the Espresso snippet above. A broad suppression like “ignore all contrast checks” is how a suite slowly stops protecting anything.
- False positive: suppress it with a comment explaining why it’s wrong, so the next person doesn’t re-litigate it.
Common mistakes
- Treating a passing audit as proof of accessibility. ATF proves that labels exist, not that they’re right. A screen can pass every check and still be unusable with TalkBack. Layer 5 isn’t optional.
- Finding nodes by visible text.
onNodeWithText("Add to cart")breaks when the copy changes, and it ties the suite to one locale forever. Use string resources, test tags, and semantics matchers. - False greens.
assertAllover an empty collection, tagged nodes searched in the merged tree, and lazy lists that compose six of twenty items all pass. Assert counts before asserting properties. - Testing taps instead of actions.
performClick()on a star proves the star works for sighted users.performCustomAccessibilityActionWithLabelproves it works for everyone else.
Up next
Everything so far has been about screens that sit still. Real apps change constantly: a badge updates, a form fails validation, a result loads three seconds later. Part 4, Announcing, Navigating, and Focusing, covers three things:
- live regions, which announce changes without stealing focus
- heading semantics, which let users jump between sections
- deliberate focus management, which puts focus where the user needs it after an error
It also shows how to test all three with what you learned here.
📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps
If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend “Mastering Android AI: App to Intelligence” It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.





