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.

Monday, 17 August 2020

Espresso tests: Wait until view is visible

Whilst searching online for some suggestions on how to wait for a particular View to become visible within an Espresso test, I noticed that all of the suggestions that defined a new ViewAction class, defined one that operated on the root view. I can see why this is necessary in situations where the View in question is not present in the view hierarchy and will enter the view hierarchy at a later point. However, if you're waiting on a View that's present in the view hierarchy to change from one state to another, it's much more elegant to match and operate on that one View specifically rather than matching and operating on the root view.

So, assuming you're waiting on a View that's present in the view hierarchy to change from INVISIBLE or GONE visibility to VISIBLE, you can define a ViewAction class as follows:

/**
 * A [ViewAction] that waits up to [timeout] milliseconds for a [View]'s visibility value to change to [View.VISIBLE].
 */
class WaitUntilVisibleAction(private val timeout: Long) : ViewAction {

    override fun getConstraints(): Matcher<View> {
        return any(View::class.java)
    }

    override fun getDescription(): String {
        return "wait up to $timeout milliseconds for the view to become visible"
    }

    override fun perform(uiController: UiController, view: View) {

        val endTime = System.currentTimeMillis() + timeout

        do {
            if (view.visibility == View.VISIBLE) return
            uiController.loopMainThreadForAtLeast(50)
        } while (System.currentTimeMillis() < endTime)

        throw PerformException.Builder()
            .withActionDescription(description)
            .withCause(TimeoutException("Waited $timeout milliseconds"))
            .withViewDescription(HumanReadables.describe(view))
            .build()
    }
}

And define a function that creates an instance of this ViewAction when called, as follows:

/**
 * @return a [WaitUntilVisibleAction] instance created with the given [timeout] parameter.
 */
fun waitUntilVisible(timeout: Long): ViewAction {
    return WaitUntilVisibleAction(timeout)
}

You can then call on this ViewAction in your test methods as follows:

onView(withId(R.id.myView)).perform(waitUntilVisible(3000L))

You can run with this concept and similarly define view actions that wait on other properties of the view to change state, e.g. waiting for the text of a TextView to change to some expected text.

For more view actions and view matchers like this one, check out the android-test-utils repository.

Espresso tests: Match child view by position within parent

I noticed whilst browsing online that all of the answers to the question "can I match the child at a particular index within a particular parent view" required the definition of a new Matcher class. Whilst it's not a huge deal to define a new Matcher class as and when needed, it's not necessary in this case. You can instead get a handle on a particular child of a particular view by joining up the view matchers offered by Espresso into a method as follows:
/**
 * @param parentViewId the resource id of the parent [View].
 * @param position the child index of the [View] to match.
 * @return a [Matcher] that matches the child [View] which has the given [position] within the specified parent.
 */
fun withPositionInParent(parentViewId: Int, position: Int): Matcher<View> {
    return allOf(withParent(withId(parentViewId)), withParentIndex(position))
}

You can use this method as follows:

onView(
    withPositionInParent(R.id.parent, 0)
).check(
    matches(withId(R.id.child))
)

For more view actions and view matchers like this one, check out the android-test-utils repository.