TalkBack Testing on Android: Automated Accessibility Checks with Espresso

TalkBack Testing on Android: Automated Accessibility Checks with Espresso

TalkBack is Android's built-in screen reader, and testing it manually doesn't scale across a large test suite. Espresso's AccessibilityChecks API, the Accessibility Test Framework, and Robolectric together give you a layered approach to catching accessibility regressions in CI before they reach users.

Android's TalkBack screen reader serves millions of users who depend on it for daily app interaction. Yet accessibility is one of the most systematically undertested areas in Android development. Unlike functional bugs that break your UI for everyone, accessibility bugs often go unnoticed by sighted developers until a real user files a support ticket — or worse, leaves a negative review.

The Android testing ecosystem offers a genuinely capable set of accessibility automation tools. Espresso's AccessibilityChecks, the Accessibility Test Framework (ATF), and Robolectric each operate at different levels of the testing pyramid, and using all three together gives you comprehensive coverage without requiring manual TalkBack sessions for every change.

How TalkBack Uses the Accessibility Tree

TalkBack reads from the Android accessibility tree, a parallel structure the OS maintains alongside the view hierarchy. Each View exposes properties via AccessibilityNodeInfo:

  • contentDescription — the primary label TalkBack announces
  • hintText — additional context, announced separately
  • className — used to generate trait announcements (e.g., "button", "checkbox")
  • isClickable, isEnabled, isChecked — state announcements
  • importantForAccessibility — whether TalkBack focuses on this view at all

When you automate accessibility testing, you're verifying that these properties are set correctly for every meaningful element in your UI.

Enabling Espresso AccessibilityChecks

The quickest way to add accessibility testing to an existing Espresso suite is AccessibilityChecks.enable(). Once enabled, it runs ATF checks after every ViewAction (click, type, scroll) in your tests.

Add the dependency to your build.gradle:

androidTestImplementation("androidx.test.espresso:espresso-accessibility:3.5.1")

Enable checks globally in a base test class:

@RunWith(AndroidJUnit4::class)
abstract class BaseAccessibilityTest {

    companion object {
        @BeforeClass
        @JvmStatic
        fun enableAccessibilityChecks() {
            AccessibilityChecks.enable()
                .setRunChecksFromRootView(true)
        }
    }
}

With setRunChecksFromRootView(true), ATF checks the entire view hierarchy on every interaction, not just the view being acted upon. This catches issues on sibling and parent views that you might not otherwise interact with.

A concrete test:

@Test
fun testLoginButtonIsAccessible() {
    onView(withId(R.id.btn_login)).perform(click())
    // ATF runs automatically — if btn_login has no contentDescription
    // or a touch target smaller than 48dp, the test will fail here
}

Configuring AccessibilityChecks for Your Project

The default ATF rule set is comprehensive but can produce failures on third-party views or legacy components you don't control. Use a custom result matcher to suppress specific known issues:

@BeforeClass
@JvmStatic
fun enableAccessibilityChecks() {
    AccessibilityChecks.enable()
        .setRunChecksFromRootView(true)
        .setSuppressingResultMatcher(
            // Suppress touch target failures on BottomNavigationView items
            // which are internally constrained by the Material library
            allOf(
                matchesCheckNames(`is`("TouchTargetSizeCheck")),
                matchesViews(isDescendantOfA(withId(R.id.bottom_navigation)))
            )
        )
}

Suppression should be precise and documented — use it to handle known framework limitations, not to silence real problems.

Inspecting AccessibilityNodeInfo Directly

For finer-grained control, you can inspect AccessibilityNodeInfo directly in a custom ViewAssertion:

fun hasContentDescription(): ViewAssertion {
    return ViewAssertion { view, noMatchingViewException ->
        noMatchingViewException?.let { throw it }

        val nodeInfo = view.createAccessibilityNodeInfoCompat()
        assertNotNull(
            "View ${view.javaClass.simpleName} at ${view.id} has no content description",
            nodeInfo?.contentDescription
        )
        assertTrue(
            "Content description must not be empty",
            nodeInfo?.contentDescription?.isNotBlank() == true
        )
    }
}

// Usage
@Test
fun testProductImageHasDescription() {
    onView(withId(R.id.img_product)).check(hasContentDescription())
}

This pattern is useful for writing reusable assertions that you can apply across multiple test classes.

Touch Target Size Validation

WCAG 2.5.5 requires touch targets of at least 44×44 CSS pixels. Android's Material Design guidelines specify 48×48dp as the minimum. ATF's TouchTargetSizeCheck catches violations, but you can also write an explicit assertion:

fun hasSufficientTouchTarget(minSizeDp: Int = 48): ViewAssertion {
    return ViewAssertion { view, noMatchingViewException ->
        noMatchingViewException?.let { throw it }

        val density = view.resources.displayMetrics.density
        val minSizePx = (minSizeDp * density).toInt()

        val width = view.width
        val height = view.height

        // Check the view itself
        assertTrue(
            "View width ${width}px is below minimum ${minSizePx}px (${minSizeDp}dp)",
            width >= minSizePx
        )
        assertTrue(
            "View height ${height}px is below minimum ${minSizePx}px (${minSizeDp}dp)",
            height >= minSizePx
        )
    }
}

@Test
fun testAllIconButtonsMeetTouchTargetSize() {
    val iconButtonIds = listOf(
        R.id.btn_share,
        R.id.btn_favorite,
        R.id.btn_more_options
    )

    for (id in iconButtonIds) {
        onView(withId(id)).check(hasSufficientTouchTarget())
    }
}

Note that Android allows extending a view's touch target beyond its visual bounds using ViewCompat.setMinimumTouchTargetSize() or TouchDelegate. Your test should verify the effective touch target, not just the view dimensions.

Testing Focus Traversal Order with AccessibilityDelegate

TalkBack moves through elements in the order the accessibility framework presents them — which can differ from visual order when custom view groups reorder accessibility focus. Testing traversal order requires either a manual TalkBack session or inspecting the node tree programmatically:

fun getAccessibilityTraversalOrder(root: View): List<String> {
    val result = mutableListOf<String>()

    fun traverse(view: View) {
        val nodeInfo = ViewCompat.getAccessibilityNodeInfoCompat(view) ?: return

        if (nodeInfo.isImportantForAccessibility && nodeInfo.isVisibleToUser) {
            val label = nodeInfo.contentDescription?.toString()
                ?: nodeInfo.text?.toString()
                ?: view.javaClass.simpleName
            result.add(label)
        }

        if (view is ViewGroup) {
            for (i in 0 until view.childCount) {
                traverse(view.getChildAt(i))
            }
        }
    }

    traverse(root)
    return result
}

@Test
fun testCheckoutFormTraversalOrder() {
    val scenario = launchActivity<CheckoutActivity>()
    scenario.onActivity { activity ->
        val rootView = activity.window.decorView

        val order = getAccessibilityTraversalOrder(rootView)

        // Verify logical order: name → email → card → expiry → cvv → submit
        val nameIndex = order.indexOfFirst { it.contains("Name", ignoreCase = true) }
        val emailIndex = order.indexOfFirst { it.contains("Email", ignoreCase = true) }
        val submitIndex = order.indexOfFirst { it.contains("Place Order", ignoreCase = true) }

        assertTrue("Name must appear before Email in traversal order", nameIndex < emailIndex)
        assertTrue("Submit must appear after form fields", emailIndex < submitIndex)
    }
}

Accessibility Test Framework Rules

ATF ships with a set of built-in rules beyond what AccessibilityChecks exposes directly. You can run the full rule set using the AccessibilityValidator:

@Test
fun testFullATFRuleSetOnDashboard() {
    val scenario = launchActivity<DashboardActivity>()
    scenario.onActivity { activity ->
        val validator = AccessibilityValidator.createDefault()

        // Run against the full view hierarchy
        validator.runChecks(activity.window.decorView)
    }
}

Key ATF rules to know:

  • DuplicateClickableBoundsCheck — catches overlapping clickable regions that confuse TalkBack
  • RedundantDescriptionCheck — catches content descriptions that repeat the element's type ("button" in a button's label)
  • SpeakableTextPresentCheck — catches interactive elements with no speakable text
  • TextContrastCheck — checks foreground/background contrast ratio against WCAG thresholds
  • TouchTargetSizeCheck — verifies minimum 48dp touch targets

Robolectric for Unit-Level Accessibility Tests

For pure unit testing without an emulator, Robolectric lets you test accessibility properties by inflating views in a JVM environment:

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ProductCardAccessibilityTest {

    @Test
    fun `product card image has content description`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val view = LayoutInflater.from(context)
            .inflate(R.layout.card_product, null, false) as ViewGroup

        // Bind some data
        val imageView = view.findViewById<ImageView>(R.id.img_product)
        imageView.contentDescription = "Product: Blue Running Shoes, $89"

        val nodeInfo = ViewCompat.getAccessibilityNodeInfoCompat(imageView)
        assertNotNull(nodeInfo)
        assertFalse(
            "Image must have a non-empty content description",
            nodeInfo!!.contentDescription.isNullOrBlank()
        )
    }

    @Test
    fun `price text is not marked as decorative`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val view = LayoutInflater.from(context)
            .inflate(R.layout.card_product, null, false)

        val priceText = view.findViewById<TextView>(R.id.txt_price)

        // Price must NOT be importantForAccessibility=no
        assertNotEquals(
            "Price text must be accessible to TalkBack",
            ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO,
            ViewCompat.getImportantForAccessibility(priceText)
        )
    }
}

Robolectric tests run in milliseconds and are perfect for catching regressions in custom view components before you even start an emulator.

Putting It All Together: CI Configuration

A practical CI setup layers all three approaches:

# .github/workflows/android-accessibility.yml
- name: Run Espresso Accessibility Tests
  run: |
    ./gradlew connectedAndroidTest \
      -Pandroid.testInstrumentationRunnerArguments.class=\
      com.example.AccessibilitySuite

- name: Run Robolectric Accessibility Tests
  run: ./gradlew testDebugUnitTest --tests "*Accessibility*"

Report failures as annotations on your pull request by parsing the ATF result XML from build/outputs/androidTest-results/.

HelpMeTest can integrate with your Android CI pipeline to run accessibility checks continuously against your staging builds, surfacing TalkBack regressions the moment they're introduced rather than during pre-release QA.

The combination of Espresso AccessibilityChecks, direct AccessibilityNodeInfo inspection, ATF rules, and Robolectric unit tests gives you a thorough, fast, and maintainable accessibility test suite that will catch the vast majority of TalkBack issues without requiring a manual device session for every commit.

Read more