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 announceshintText— additional context, announced separatelyclassName— used to generate trait announcements (e.g., "button", "checkbox")isClickable,isEnabled,isChecked— state announcementsimportantForAccessibility— 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 TalkBackRedundantDescriptionCheck— catches content descriptions that repeat the element's type ("button" in a button's label)SpeakableTextPresentCheck— catches interactive elements with no speakable textTextContrastCheck— checks foreground/background contrast ratio against WCAG thresholdsTouchTargetSizeCheck— 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.