10 Comments

Espresso – Testing RecyclerViews at Specific Positions

My team recently added a RecyclerView to a screen in an Android app we’re working on. It’s a horizontal view that allows a user to scroll left and right to see content that’s offscreen. One of the challenges we’ve faced while working on this view has been testing it in our Espresso tests—specifically, testing the contents of items at certain positions. In this post, I’ll show you an Espresso matcher that can be used to aid in testing RecyclerViews.

RecyclerViewActions

The espresso-contrib library provides a RecyclerViewActions class that offers a way to click on a specific position in a RecyclerView (see instructions on configuring espress-contrib).


// Click item at position 3
onView(withId(R.id.scroll_view))
    .perform(RecyclerViewActions.actionOnItemAtPosition(3, click()));

This is great for simulating a user who is tapping on an item in the RecyclerView. But most of the tests I wanted to write had to do with verifying the contents of an item at a specific position. Just seeing that some content was on the screen wasn’t good enough. I needed to verify that certain content was showing up in position 0 or position 1, etc.

RecyclerViewMatcher

Fortunately, Danny Roa already put together an Espresso matcher which he describes in his Espresso: Matching Views in RecyclerView post. Using the RecyclerViewMatcher, you can not only perform actions on an item at a specific position in a RecyclerView, but also check that some content is contained within a descendant of a specific item.


// Convenience helper
public static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) {
    return new RecyclerViewMatcher(recyclerViewId);
}

// Check item at position 3 has "Some content"
onView(withRecyclerView(R.id.scroll_view).atPosition(3))
    .check(matches(hasDescendant(withText("Some content"))));

// Click item at position 3
onView(withRecyclerView(R.id.scroll_view).atPosition(3)).perform(click());

Testing Off-Screen Items

The RecyclerViewMatcher matcher worked great until we tried testing items that were somewhere off-screen.

Our first improvement was to force the RecyclerView to scroll so that the desired position was on-screen. The RecyclerViewActions mentioned above provide a scrollToPosition action that can do just that. Here’s a helper for tapping on a specific position that first scrolls and then taps:


public static void tapRecyclerViewItem(int recyclerViewId, int position) {
    onView(withId(recyclerViewId)).perform(scrollToPosition(position));
    onView(withRecyclerView(recyclerViewId).atPosition(position)).perform(click());
}

Doing the same when verifying the content of a RecyclerView item also helped. But Espresso still seemed to have trouble verifying the content of an item whose index was greater than the number of items that could fit on the screen. For example, if four items at a time are visible on-screen, making assertions about the content of the item at position 10 would always fail.

The problem has to do with how the RecyclerViewMatcher retrieves the View at a specific position. It uses the getChildAt(int index)method on RecyclerView class (which is actually inherited from ViewGroup). However, this method only appears to know about the items that are visible in the view. Whatever view is at the top is index 0, the second is index 1, etc.

An alternative is to use the findViewHolderForAdapterPosition(int position) method to locate the view (by way of its ViewHolder) for a specific position. This technique allows you to specify index 32 and have Espresso know which view you’re talking about.

Here’s an updated matchesSafely method for the RecyclerViewMatcher that uses this:


public boolean matchesSafely(View view) {
  this.resources = view.getResources();

  if (childView == null) {
    RecyclerView recyclerView =
        (RecyclerView) view.getRootView().findViewById(recyclerViewId);
    if (recyclerView != null && recyclerView.getId() == recyclerViewId) {
        RecyclerView.ViewHolder viewHolder = 
          recyclerView.findViewHolderForAdapterPosition(position);
        if (viewHolder != null) {
            childView = viewHolder.itemView;
        }
    }
    else {
        return false;
    }
  }

  if (targetViewId == -1) {
    return view == childView;
  } else {
    View targetView = childView.findViewById(targetViewId);
    return view == targetView;
  }
}

And here’s a gist for the entire updated RecycleViewMatcher helper.

Summary

By combining the RecyclerViewActions provided by espresso-contrib with Danny Roa’s RecyclerViewMatcher (slightly modified), it’s possible to write comprehensive Espresso tests for an interface that uses a RecyclerView, including verifying content on items at specific positions.

Note: After writing this post, I noticed that someone had already submitted a pull request to the RecyclerViewMatcher helper with pretty much this same change. It has not been accepted as of yet.