Espresso with Kotlin

Custom RecyclerView Matcher and ViewAssertion with Espresso (Kotlin)

Meng Taing
2359media
Published in
6 min readOct 31, 2017

--

Instrumentation test is important to make sure your app shows the correct UI as expected. It also prevents any break of logic while you’re refactoring. Espresso is a default library for Android instrumentation test. There are some basic assertions to verify yourTextView like this:

onView(withId(R.id.text1))
.check(matches(withText("Hello World")))

However, there is no default assertion for RecyclerView such as counting the items.

Our objective is to write a custom Matcher or ViewAssertion to count the items on RecyclerView in instrumentation test.

In case you are not familiar with Espresso or instrumentation test, this step-by-step guide will walk you through every single details. You might start to like Espresso tests after read this.

How to Start

To begin with, download the starter project here. The starter project contains a RecyclerView which displays a list of programs for a hackathon event. The dummy data is generated by ProgramProvider. After you compile and run the app, it should look like this:

List of programs in a hackathon event

To run Espresso test, click on the green forward arrow icon on the test class declaration line:

Run instrumentation by clicking on the green arrow icon

If everything works correctly, you should see a green status like this:

Test passed!

Create Test Activity

To create a new test for MainActivity, place cursor anywhere within the class declaration, press Cmd + Shift + T (Ctrl+ Shift + T for Windows) to create a new test:

Create New Test

Leave everything unchecked for Create Test dialog, click OK.

Leave everything unchecked

On Choose Destination Directory dialog, select the directory in …/app/src/androidTest. All the instrumentation tests should reside in androidTest folder, while test folder is for jUnit test.

Choose androidTest directory for instrumentation test

Create the first test in MainActivityTest:

class MainActivityTest {
@Rule
@JvmField
var activityRule = ActivityTestRule<MainActivity>(MainActivity::class.java)
@Test
fun countPrograms() {
onView(withId(R.id.programs))
.check(matches(...))
}
}

ActivityTestRule is used to launch the activity under test. Do not call activityRule.launchActivity(null) in any test because it will cause the test to idle.

R.id.programs is the id of our RecyclerView in activity_main.xml. We will create our custom ViewMatcher to replace the (…) in our code above.

Create Custom View Matcher

Create a new Kotlin class calledCustomMatchers under androidTest directory. All the test-related helpers, rules, assertions, etc should not reside in main directory.

Create a companion method called withItemCount(count: Int) (equivalent to static method in Java). This method should return Matcher<View>:

class CustomMatchers {
companion object {
fun withItemCount(count: Int): Matcher<View> {

}
}
}

We will return a BoundedMatcher, which only process the items of a specific subtype of that match. In case you have heard about TypeSafeMatcher, you can see their difference in this article.

BoundedMatcher implements the following two methods:

return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description?) {
}

override fun matchesSafely(item: RecyclerView?): Boolean {
}
}
  • describeTo allows us to append our own description to the custom matcher. For example
description?.appendText("RecyclerView with item count: $count")
  • matchSafely is where we implement the comparison logic for item count.
return item?.adapter?.itemCount == count

Note: Although BoundedMatcher implements describeMismatch method, it does not display the description when mismatch occurs.

  override fun describeMismatch(item: Any?, description: Description?) {
// This block will never be executed
}

The completed CustomMatchers should look like this:

class CustomMatchers {
companion object {
fun withItemCount(count: Int): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description?) {
description?.appendText("RecyclerView with item count: $count")
}

override fun matchesSafely(item: RecyclerView?): Boolean {
return item?.adapter?.itemCount == count
}
}
}
}
}

Now we can use the freshly baked withItemCount matcher in our test:

@Test
fun countPrograms() {
onView(withId(R.id.programs))
.check(matches(withItemCount(100)))
}

It’s better to fail the test first. Make sure you get the following error after you run the test:

It’s always good to make sure your test fail at the beginning

Now let’s change the item count to the correct value, which is 12 in our example. Run the test, and you should see the green status again.

You can use BoundedMatch for other any view types other than RecyclerView. However, the error message doesn’t tell us the actual count of the items in RecyclerView. We also want to avoid calling matches() if possible. That’s why we introduce a customViewAssertion.

Create Custom ViewAssertion

Similar to the custom Matcher above, create a new Kotlin class calledCustomAssertions under androidTest directory. Create a companion method called hasItemCount(count: Int) with return type as ViewAssertion :

class CustomAssertions {
companion object {
fun hasItemCount(count: Int): ViewAssertion {
}
}
}

Let’s create a inner class called RecyclerViewItemCountAssertion which implements ViewAssertion. You can extract the class out to suit your project architecture. Pass the item count as the constructor parameter and implement the corresponding check method:

private class RecyclerViewItemCountAssertion(private val count: Int) : ViewAssertion {

override fun check(view: View, noViewFoundException: NoMatchingViewException?) {

}
}

First, throw noViewFoundException if it’s not null:

if (noViewFoundException != null) {
throw noViewFoundException
}

Second, throw exception if the asserted view is not RecyclerView:

if (view !is RecyclerView) {
throw IllegalStateException("The asserted view is not RecyclerView")
}

Third, throw exception if the asserted RecyclerView does not have adapter:

if (view.adapter == null) {
throw IllegalStateException("No adapter is assigned to RecyclerView")
}

Last but not least, assert the actual count from the adapter with the expected count from the constructor:

ViewMatchers.assertThat("RecyclerView item count", view.adapter.itemCount, CoreMatchers.equalTo(count))

Put everything together, we get:

class CustomAssertions {
companion object {
fun hasItemCount(count: Int): ViewAssertion {
return RecyclerViewItemCountAssertion(count)
}
}

private class RecyclerViewItemCountAssertion(private val count: Int) : ViewAssertion {

override fun check(view: View, noViewFoundException: NoMatchingViewException?) {
if (noViewFoundException != null) {
throw noViewFoundException
}

if (view !is RecyclerView) {
throw IllegalStateException("The asserted view is not RecyclerView")
}

if (view.adapter == null) {
throw IllegalStateException("No adapter is assigned to RecyclerView")
}

ViewMatchers.assertThat("RecyclerView item count", view.adapter.itemCount, CoreMatchers.equalTo(count))
}
}
}

Call the newly created ViewAssertion in our test with wrong count to see the error message:

@Test
fun countProgramsWithViewAssertion() {
onView(withId(R.id.programs))
.check(hasItemCount(100))
}
Expected Value vs Actual Value

As you can see, the error message now tells us the actual count of item in the RecyclerView. Change the count to 12 and run the test again. You should be able to see the green status.

What You Have Learned

View the entire project here.

Although Espresso does not provide all the convenient methods for testing all kind of views, it does open up its interfaces to allow developers implement their own matchers, assertions, and actions. This is especially helpful when we have custom widgets. If you can’t find any default assertion or matcher, implement your own one!

--

--

Meng Taing
2359media

Fullstack developer. When life gives you a lemon, write a script to turn it into lemonade so that you don’t have to deal with it again.