Friday, 21 August 2020

Espresso tests: Match child view by position within RecyclerView

Let's say you want to write a user interface test that matches the child view at a particular position within a RecyclerView and you want to assert some properties on that child view. When you search for this online, you'll come across a whole host of solutions that do work but, sadly, are not very fluent or Espresso-esque. You'll see solutions that will lead you to write assertions like the following:

onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0))
    .check(matches(withText("Some Text")))

onView(withId(R.id.recyclerView))
    .check(matches(atPosition(0, withText("Some Text"))))

Like I said: sure, this works, but I feel we can do better. Most notably, what I would change about these is to match on the child view of interest instead and assert properties on that rather than matching on the entire RecyclerView. So what we're aiming for are assertions like the following:

onView(withPositionInRecyclerView(R.id.recyclerView, 0))
    .check(matches(withText("Some Text")))

It's a subtle change and maybe it's just me but I feel this is a little easier on the eye and makes the reading experience a little more pleasurable. If you've made it this far and you're bought in, here's the Matcher class you need to define to make this possible:

/**
 * A matcher that matches the child [View] at the given position
 * within the [RecyclerView] which has the given resource id.
 *
 * Note that it's necessary to scroll the [RecyclerView] to the desired position
 * before attempting to match the child [View] at that position.
 */
class WithPositionInRecyclerViewMatcher(private val recyclerViewId: Int,
                                        private val position: Int) : TypeSafeMatcher<View>() {

    override fun describeTo(description: Description) {
        description.appendText("with position $position in RecyclerView which has id $recyclerViewId")
    }

    override fun matchesSafely(item: View): Boolean {
        val parent = item.parent as? RecyclerView
            ?: return false

        if (parent.id != recyclerViewId)
            return false

        val viewHolder: RecyclerView.ViewHolder = parent.findViewHolderForAdapterPosition(position)
            ?: return false // has no item on such position

        return item == viewHolder.itemView
    }
}

And, for best practice, define a function that wraps this class as follows:

/**
 * @return an instance of [WithPositionInRecyclerViewMatcher] created with the given parameters.
 */
fun withPositionInRecyclerView(recyclerViewId: Int, position: Int): Matcher<View> {
    return WithPositionInRecyclerViewMatcher(recyclerViewId, position)
}

That's it. We're done. Happy testing and for more view matchers like this one, check out the android-test-utils repository.

No comments: