Custom RecyclerView Matcher and ViewAssertion with Espresso (Kotlin)
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:
To run Espresso test, click on the green forward arrow icon on the test class declaration line:
If everything works correctly, you should see a green status like this:
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:
Leave everything unchecked for Create Test dialog, click OK.
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.
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:
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))
}
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!