Writing Basic Espresso Tests
Basic instrumentated tests are fine for testing non-UI logic. They even work acceptably for some basic UI testing. The more complex your UI testing gets, though, the more likely it is that you will find plain instrumented tests to be limiting and tedious.
Espresso is designed to simplify otherwise-complex UI testing scenarios, such as:
- Testing across screens, such as confirming that tapping a
RecyclerView
row in one fragment correctly launches a detail fragment associated with the model object for that row - Testing over time, such as waiting for a list to be populated from a database before actually testing it
Espresso tests are part of your instrumented tests. Espresso simply provides another API for writing test code; those tests run alongside the rest of your instrumented tests.
However, Espresso tests are very fragile. They are prone to failing not due to problems in your code but due to problems when executing the tests.
Add Espresso Dependencies
One of the androidTestImplementation
dependencies that we are pulling in is androidx.test.espresso:espresso-core
:
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
As you might expect, this contains the core Espresso code, and it is sufficient to write many Espresso tests. Additional libraries exist for testing things like RecyclerView
.
Disable Animations
One of Espresso’s limitations is that it does not like the stock animated effects that Android applies to various actions, such as launching activities. You will have better results if you disable those animations.
In the “Developer options” section of the Settings app, you will want to disable:
- Window animation scale
- Transition animation scale
- Animator duration scale
Set Up the Activity or Fragment
To be able to test your UI, we need to actually have that UI appear on-screen. To that end, we can use ActivityScenario
or FragmentScenario
. These will launch an activity or fragment for us, allowing us to then test them afterwards.
The testListContents()
test function in RosterListFragmentTest
uses ActivityScenario
to launch the MainActivity
of this app:
@Test
fun testListContents() {
ActivityScenario.launch(MainActivity::class.java)
onView(withId(R.id.items)).check(matches(hasChildCount(3)))
}
ActivityScenario.launch()
launches the activity and returns an ActivityScenario
instance. That class implements Closeable
, so we can use use()
to execute our tests and close the scenario (and finish the activity) when we are done.
Find Widgets via Hamcrest Matchers
Writing Espresso tests is often described as having three main steps:
- Find the widgets you want to examine or manipulate
- Perform actions on those widgets where needed (and where possible)
- Check to see if widgets have a certain state
Technically speaking, with Espresso, we do not “find widgets”, though it is often simplest to phrase it that way. A more accurate description would be “obtain a ViewInteraction
object that pertains to a particular widget”. The ViewInteraction
object in turn allows us to perform actions on the underlying widget and check the widget to see if it has a certain state.
To do that, we use an onView()
static method supplied by Espresso. It will search the view hierarchy of the current activity for a widget.
How we identify the widget is via a “matcher”. A matcher simply is an object that can identify whether some other object matches certain criteria. In particular, Espresso uses Hamcrest matchers.
There are three main sources of matchers that you can use:
-
ViewMatchers
contains a number ofstatic
methods that return matchers that find aView
with some specific characteristic, such aswithId()
to find aView
with a particular ID - Hamcrest’s
Matchers
class has a series ofstatic
methods that return matchers that help you combine other matchers (e.g.,allOf()
to find aView
that matches more than one criteria) or work with plain Java collections (e.g.,empty()
to match a collection that is empty) - Your own custom matchers
Here, we are using withId()
to find some widget in MainActivity
that has an ID of items
.
Perform Actions
onView()
gives us a ViewInteraction
. Given a ViewInteraction
, one thing that you can do is ask it to perform()
one or more actions, represented by ViewAction
objects. The ViewActions
(note the plural) class contains a series of static
methods that create ViewAction
objects. And, once again, the pattern is to use static imports for those methods. Actions can include things like clicking the widget (click()
), clicking the system BACK button (pressBack()
), swiping in a particular direction (e.g., swipeDown()
), and so on.
In this case, we want to test whether MainActivity
is showing our three to-do items (from the in-memory repository) in a RecyclerView
(the items
widget that we found). There are no actions that we need to apply here, so we skip that step.
Assert Results
Once the activity is in the desired state via any actions, we can use the ViewInteraction
from onView()
to validate that the widgets contain the desired content or otherwise are set up properly. That is handled by calling check()
on a ViewInteraction
, passing in a ViewAssertion
that… well… asserts something. A ViewAssertion
basically wraps the assertion calls that you might make directly in JUnit4, working with the ViewInteraction
to confirm that the underlying View
has some particular state.
The simplest ViewAssertion
is obtained via the matches()
static method. This takes a Hamcrest matcher and confirms that the widget matches whatever the matcher’s criteria are.
One of the many static methods on ViewMatchers
is hasChildCount()
, which matches whether a ViewGroup
(like RecyclerView
) has a certain number of children. So, matches(hasChildCount(3))
is a ViewAssertion
that will succeed if the items
RecyclerView
has three children and will fail the test if not.
The net effect of the entire test is that we set up the database with three to-do items, then launch the MainActivity
and confirm that it shows those three items:
package com.commonsware.todo.ui.roster
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.commonsware.todo.R
import com.commonsware.todo.repo.ToDoDatabase
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRepository
import com.commonsware.todo.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module
@RunWith(AndroidJUnit4::class)
class RosterListFragmentTest {
private lateinit var repo: ToDoRepository
private val items = listOf(
ToDoModel("this is a test"),
ToDoModel("this is another test"),
ToDoModel("this is... wait for it... yet another test")
)
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val db = ToDoDatabase.newTestInstance(context)
val appScope = CoroutineScope(SupervisorJob())
repo = ToDoRepository(db.todoStore(), appScope)
loadKoinModules(module {
single { repo }
})
runBlocking { items.forEach { repo.save(it) } }
}
@Test
fun testListContents() {
ActivityScenario.launch(MainActivity::class.java)
onView(withId(R.id.items)).check(matches(hasChildCount(3)))
}
}
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.