Edition 95

The 'Android Data Matcher' Locator Strategy

One of the well-known limitations of finding elements on Android is that the Android UI renderer doesn't actually build and render elements until they're about to appear on the screen. What this means is that, if we are looking for an element which is far down in a list, and not yet visible on the screen, the element just does not "exist". You can prove this to yourself by generating a source snapshot of your app while there are lots of list elements present below the edge of the screen. They won't be there! Let's take as an example the ApiDemos app; let's say we're on the screen below, and want to tap on the "Layouts" item, which is not on the first screen's worth of items (you can see the first screen on the left, and how we had to scroll to get to "Layouts" on the right):

Need to scroll to layout

How do we handle a situation like this with Appium? Well, what I usually say is to "think like a user"--how would a user find this item? By swiping to scroll the list, of course, and using their eyes to periodically check for the appropriate content. We could, therefore, implement a scroll-and-find helper method with built-in retry loops. If we're using the Espresso driver, however, there's a better way: the 'Android Data Matcher' strategy!

This strategy takes advantage of the fact that Espresso runs in the app context, not on top of the Accessibility layer, so it has access to the data which backs a list, not just the elements which are displayed on the screen. (In Android, ListViews are a subtype of what's called an AdapterView, which connects the list datasource). We can use a part of the Espresso API to essentially target this data, and have Espresso automatically scroll to the element which represents that data for us.

As you can see in Appium's Data Matcher Guide, if we were using Espresso directly (not through Appium), we might accomplish this feat with some code that looks like the following:

onData(hasEntry("title", "textClock")
  .inAdapterView(withId("android:id/list))
  .perform(click());

This Espresso code waits for some "data" according to the parameters we've given the onData command, namely that we want an entry which has a specific title, within the list that has a certain Android ID. The important bit here is the hasEntry function, which builds a Hamcrest Matcher. The matcher is used by Espresso to help find the desired data and turn it into a view we can interact with.

There are all kinds of matchers, which you can explore and use with Espresso, and now with Appium. So how do we do this with Appium? Basically, we need to use the -android datamatcher locator strategy, which in Java is accessed as MobileBy.androidDataMatcher. And for the selector, rather than a simple string, we construct a JSON object containing the Matcher information, stringify it, and pass it as the selector. It's a bit sneaky (or, how I like to think of it, a clever way of working within the W3C WebDriver protocol definitions for locator strategy selectors). Here's how we'd construct the attempt to find the "Layouts" item we saw in the screenshot above:

// first, find the AdapterView (not necessary if there's only one)
WebElement list = wait.until(ExpectedConditions.presenceOfElementLocated(
    MobileBy.className("android.widget.ListView")
));

// Construct the Hamcrest Matcher spec
String selector = new Json().toJson(ImmutableMap.of(
    "name", "hasEntry",
    "args", ImmutableList.of("title", "Layouts")
));

// find and click on the element using the androidDataMatcher selector
list.findElement(MobileBy.androidDataMatcher(selector)).click();

As you can see, we have to do a bit of work to codify the Hamcrest Matcher we want to use, by settings its name and args. What should the values here be? Basically, whatever they would be in the raw Espresso case (the name of the matcher is hasEntry, and its arguments are title and Layouts, because we're trying to find an entry whose title is 'Layouts').

That's basically it! When you see this work, it's pretty awesome--the screen will jump very quickly to the expected item, without having to do a bunch of manual scrolling. Below is a full example, with two tests; one attempting to find the 'Layouts' item using XPath alone, which fails (because the element is not on the screen), and a second test which succeeds because it uses this locator strategy. This strategy is an especially good replacement for XPath when you only have partial information about the element you want to find.

public class Edition095_Android_Datamatcher {
    private AndroidDriver driver;
    private String APP = "https://github.com/appium/android-apidemos/releases/download/v3.1.0/ApiDemos-debug.apk";
    WebDriverWait wait;

    @Before
    public void setUp() throws Exception {
        DesiredCapabilities capabilities = new DesiredCapabilities();

        capabilities.setCapability("platformName", "Android");
        capabilities.setCapability("deviceName", "Android Emulator");
        capabilities.setCapability("automationName", "Espresso");
        capabilities.setCapability("app", APP);

        driver = new AndroidDriver(new URL("http://localhost:4723/wd/hub"), capabilities);
        wait = new WebDriverWait(driver, 10);
    }

    @After
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

    @Test
    public void testFindHiddenListItemNormally() {
        wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.AccessibilityId("Views")
        )).click();
        wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.xpath("//*[@content-desc='Layouts']")
        )).click();
        wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.AccessibilityId("Baseline")
        ));
    }

    @Test
    public void testFindHiddenListItemWithDatamatcher() {
        wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.AccessibilityId("Views")
        )).click();
        WebElement list = wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.className("android.widget.ListView")
        ));
        String selector = new Json().toJson(ImmutableMap.of(
            "name", "hasEntry",
            "args", ImmutableList.of("title", "Layouts")
        ));
        list.findElement(MobileBy.androidDataMatcher(selector)).click();
        wait.until(ExpectedConditions.presenceOfElementLocated(
            MobileBy.AccessibilityId("Baseline")
        ));
    }
}

(Of course, you can always view the full code sample on GitHub!)