Android Accessibility Automation with Espresso

As one of the accessibility champions in our company and a digital inclusion proponent, I have always tried to explore new avenues to improve accessibility across the different platforms that we support. According to the World Bank, 15% of the world’s population has some type of disability. Adding accessibility features makes using mobile apps easier for all users, not just those with disabilities.

The Android platform offers multiple assistive technologies available, including TalkBack, which we use in our eBay Android app. TalkBack helps vision-impaired users interact with their devices by adding spoken, audible, and vibration feedback, and comes pre-installed on most Android devices.

To ensure TalkBack works as expected with our application, traditionally we have focused predominantly on manual testing to ensure the critical flows in our app works for our TalkBack users, and that they are able to complete any action, such as searching for items, checking for prices, or completing a transaction, just like our sighted users do.

Using Espresso for UI tests

With advent of automation, it makes sense to adopt automated testing for accessibility also. Espresso is a popular testing framework that helps us to synchronize asynchronous tasks while automating in-app interactions, such as:

  • Performing actions on View objects
  • Locating and activating items within RecyclerView and AdapterView objects
  • Validating the state of outgoing intents
  • Assessing how users with accessibility needs can use the app

Espresso + Accessibility

It makes sense for us to add support for accessibility in UI tests using Espresso. To extend the existing accessibility test framework provided by Android and enable Accessibility checks for our application, we add the following line in the @BeforeClass annotation in the Espresso test:

AccessibilityChecks.enable();

This line will cause accessibility checks to run on a given view every time you use a ViewAction from the ViewActions class. As long as we have sufficient coverage in our tests, and we can ensure all of our UI components are reachable, these accessibility rules will validate all the pertinent UI fields in the current screen for which we are testing.

Based on the current version available at the time of writing (VERSION_2_0_CHECKS), there are eight available checks that can be executed from our Espresso tests. Refer to the Accessibility rules coverage section later in this article for the details of these rules and a summary of what the rule asserts.

We can next specify which particular views to skip from these checks. This is especially helpful if we are aware of certain accessibility violations with a given view and are not able to fix them right away. We can achieve this by specifying a simple matcher:

AccessibilityCheckResultUtils.matchesViews(anyOf(withId(R.id.textview_item_name),withId(R.id.about_this_item_title))

Finally, we also have the provision to skip any particular rule from being executed. This is quite handy if we know that a certain rule is going to fail if we have not addressed it yet, or whether it’s okay for our app to not handle this particular rule. Skipping rules should be an exception and not a norm. This is also achieved by specifying a matcher, as follows:

AccessibilityCheckResultUtils.matchesCheckNames(is("TouchTargetSizeViewCheck"))

All of this comes together in a simple util class defined for accessibility testing. Turning on accessibility in our existing Espresso test is then as simple as invoking one line of code, as illustrated in the following code snippet.

import java.util.List;

import android.support.test.espresso.contrib.*;

import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult;
import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult;
import org.hamcrest.*;

import static com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesCheckNames;
import static com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesViews;
import static org.hamcrest.Matchers.*;

/**
 * util class defined to enable accessibility checks in UI tests.
 */
public class AccessibilityTestUtil
{
	/**
	 * Invoke this method to run all accessibility rules defined in {@link AccessibilityRule} for all views starting
	 * from root view
	 */
	public static void enableAllChecks()
	{
		AccessibilityChecks.enable().setRunChecksFromRootView(true);
	}

	/**
	 * Invoke this method to run accessibility rules defined in {@link AccessibilityRule}
	 *
	 * @param accessibilityRules list of {@link AccessibilityRule} that will be suppressed for reporting
	 */
	public static void enableChecksWithSuppressedRules(List accessibilityRules)
	{
		List accessibilityRuleNames = new ArrayList<>();
		for (AccessibilityRule rule : accessibilityRules)
			accessibilityRuleNames.add(rule.name());

		Matcher accessibilityCheckResultMatcher =
			is(anyOf(matchesCheckNames(isIn(accessibilityRuleNames))));

		AccessibilityChecks.enable().setRunChecksFromRootView(true)
			.setSuppressingResultMatcher(accessibilityCheckResultMatcher);
	}

	/**
	 * Invoke this method to run accessibility rules defined in {@link AccessibilityRule}
	 *
	 * @param accessibilityRule {@link AccessibilityRule} that will be suppressed for reporting
	 */

	public static void enableChecksWithSuppressedRules(AccessibilityRule accessibilityRule)
	{
		enableChecksWithSuppressedRules(Collections.singletonList(accessibilityRule));
	}

	/**
	 * Invoke this method to run accessibility rules defined in {@link AccessibilityRule}
	 *
	 * @param viewIds list of views that will be skipped from any accessibility rules checks.
	 * use -1 for views without specified id.
	 */
	public static void enableChecksWithSuppressedViews(List viewIds)
	{
		Matcher accessibilityCheckResultMatcher =
			is(anyOf(matchesViews(isIn(viewIds.toArray()))));

		AccessibilityChecks.enable().setRunChecksFromRootView(true)
			.setSuppressingResultMatcher(accessibilityCheckResultMatcher);
	}

	/**
	 * Invoke this method to run accessibility rules in {@link AccessibilityRule} with exceptions defined in matcher
	 * viewCheckResultMatcher for instance an example Matcher could be of the form ,
	 * allOf( matchesCheckNames(is("TouchTargetSizeViewCheck")), matchesViews(withId(R.id.seller_primary_phone)))
	 * all issues related to touch target on View objects with a resource ID of "seller_primary_phone" are suppressed
	 *
	 * @param viewCheckResultMatcher matcher defined
	 */
	public static void enableChecksWithSuppressedRulesForViews(
		Matcher viewCheckResultMatcher)
	{
		AccessibilityChecks.enable().setRunChecksFromRootView(true).setSuppressingResultMatcher(viewCheckResultMatcher);
	}

AccessibilityTestUtil is the util class, as shown in the previous example. Here is a sample Espresso test, with accessibility checks enabled: 

@LargeTest
public final class OrderConfirmationEspressoTest
{
	@Rule
	public ActivityTestRule viewItem =
		new ActivityTestRule<>(OrderConfirmationActivity.class, true, false);

	@Before
	public void setup()
	{
		// setup
	}

	/**
	 * Enable accessibility checks and exclude these views - button_item_description , about_this_item_title
	 */
	@BeforeClass
	public static void beforeClass()
	{
		AccessibilityTestUtil.enableChecksWithSuppressedViews(
			Arrays.asList(R.id.button_item_description, R.id.about_this_item_title));
	}

	@BeforeClass
	public void test()
	{
		onView(withId(R.id.button_all_recent)).perform(click());
	}

	@After
	public void tearDown()
	{
		// tear down
	}

Accessibility rules coverage

The following rules are invoked when we enable tests for accessibility checks:

  • TouchTargetSizeViewCheck
    • Target height or target width less than 48 dp is flagged, unless there is a touchdelegate detected.
  • TextContrastViewCheck
    • Checks text color and background and factors in large text, and calculates the contrast ratio: - 4.5 for regular text, 3 for large text.
  • DuplicateSpeakableTextViewHierarchyCheck
    • If two Views in a hierarchy have the same speakable text, that could be confusing for users if at least one of them is clickable.
  • SpeakableTextPresentViewCheck
    • If the view is focusable, this checks whether valid speakable text exists, and errors if the view is missing speakable text needed for a screen reader.
  • EditableContentDescViewCheck
    • Throws an error if Editable TextView has a contentDescription.
  • ClickableSpanViewCheck
    • Checks if ClickableSpan is inaccessible. Individual spans cannot be selected independently in a single TextView, and accessibility services are unable to call ClickableSpan#onClick.
  • RedundantContentDescViewCheck
    • Accessibility services are aware of the view's type and can use that information as needed. For example, it throws a warning if the content description has a redundant word, such as “button.”
  • DuplicateClickableBoundsViewCheck
    • Throws an error if Clickable view has the same bounds as another clickable view (likely a descendent). Sometimes there are containers marked clickable, and they don't process any click events.

Best practices and benefits

Start enabling Accessibility Checks to your existing tests, observe if there are any failures, and address them as required. As a best practice, keep Accessibility Checks in mind while adding new tests. Once considerable progress is made, enable Accessibility Checks for the entire test suite, say in Base TestCase, so that all tests run these accessibility validations by default. You can always add specific rules to be skipped if you believe that a particular rule does not apply for your app.

In summary, using Accessibility checks in Espresso tests has two key advantages—more focus on accessibility issues and better automation coverage at scale for UI tests. If you are as passionate as I am about accessibility and digital inclusion, I believe this is the right thing to do.