UI Testing (Espresso)
The previous lesson's unit tests never touched a real screen — they ran as plain JVM code, checking
that a class like TrailRepository behaves correctly in isolation, with no Android
framework involved at all. Espresso tests a different layer entirely: they launch a
real Activity or Fragment on an actual device or emulator, tap real buttons,
and check what actually appears on screen. These are called instrumented tests because
they run inside the Android framework itself, not on a bare JVM — which is also why they're slower to
run than the unit tests from the last lesson.
The Core Pattern: Find, Act, Check
Nearly every Espresso test follows the same three-step shape: find a view, perform an action on it, then check the result. Here's TrailLog's simplest possible case — tapping the "add trail" button and confirming the add-trail screen appears:
@Test
fun tappingAddTrailButton_opensAddTrailScreen() {
onView(withId(R.id.addTrailButton))
.perform(click())
onView(withId(R.id.addTrailScreenTitle))
.check(matches(isDisplayed()))
}
onView(withId(...)) locates a view in the currently displayed layout by its resource id.
.perform(click()) simulates an actual tap on it, exactly as a real finger would.
.check(matches(isDisplayed())) then asserts that a particular view — here, the add-trail
screen's title — is visible on screen afterward. If TrailLog's navigation ever broke and tapping the
button did nothing, this test would fail immediately with a clear message naming the missing view.
Launching One Screen in Isolation: ActivityScenario and FragmentScenario
Rather than always running TrailLog's whole app from its launch screen just to test one Fragment deep
inside it, ActivityScenario and FragmentScenario launch a single screen
directly, in isolation, for a much more focused test:
@Test
fun trailListFragment_showsSavedTrailName() {
val scenario = launchFragmentInContainer<TrailListFragment>(
themeResId = R.style.Theme_TrailLog
)
onView(withText("Ridgeline Loop"))
.check(matches(isDisplayed()))
}
launchFragmentInContainer puts TrailListFragment inside a bare test container
Activity, without needing TrailLog's real launch Activity or navigation graph at all. This keeps the
test focused purely on whether TrailListFragment itself displays a saved trail's name
correctly, with nothing else in the app able to interfere with the result. The same
ActivityScenario.launch(SomeActivity::class.java) pattern exists for launching a full
Activity the same way.
The Tricky Part: Tests That Run Faster Than Your App
Espresso normally waits for the UI thread to go idle before running the next step, which handles most
timing automatically. But it has no way to know about work still running on a background
coroutine — like TrailListViewModel loading trails from Room. Left alone, a test can check
for the loaded list before that coroutine has actually finished, see an empty screen, and fail with a
misleading result even though the feature works fine.
IdlingResource is Espresso's answer: an object that tells Espresso "something is still
busy, don't run the next check yet." You register one that reports busy while trails are loading and
idle once they've been delivered to the UI, and Espresso will automatically wait for it before
proceeding:
class TrailLoadingIdlingResource : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
private var isIdleNow = true
fun setBusy() { isIdleNow = false }
fun setIdle() {
isIdleNow = true
callback?.onTransitionToIdle()
}
override fun isIdleNow() = isIdleNow
override fun registerIdleTransitionCallback(cb: IdlingResource.ResourceCallback?) {
callback = cb
}
}
This is one of the single most common sources of flaky, hard-to-explain Espresso failures in real
projects, precisely because the bug is timing-dependent — the same test can pass most of the time and
fail occasionally depending on how fast the emulator happens to be that run. Recognizing "my test fails
inconsistently around async loading" as an IdlingResource problem, rather than a bug in
the feature itself, saves a lot of confused debugging.
Testing a RecyclerView: Scrolling To and Clicking an Item
Plain onView(withText(...)) works for simple views, but TrailLog's trail list (see
RecyclerView Basics) needs its own tooling, since rows are
recycled and only a handful exist as live views at any moment. The
androidx.test.espresso:espresso-contrib artifact provides
RecyclerViewActions for exactly this:
@Test
fun scrollingToAndTappingATrail_opensItsDetailScreen() {
onView(withId(R.id.trailRecyclerView))
.perform(
RecyclerViewActions.scrollToPosition<TrailAdapter.TrailViewHolder>(12),
RecyclerViewActions.actionOnItemAtPosition<TrailAdapter.TrailViewHolder>(12, click())
)
onView(withId(R.id.trailDetailTitle))
.check(matches(isDisplayed()))
}
scrollToPosition scrolls the list far enough that the target row actually exists as a
live view — a row far off-screen can't be clicked until it's been scrolled into range.
actionOnItemAtPosition then performs an action, here a click, on the ViewHolder at that
position, without you needing to compute pixel coordinates or worry about which physical views are
currently recycled for which data.
Why Fewer UI Tests Than Unit Tests
Being honest about the trade-off: Espresso tests are slower to run (each one launches real Android components on a device or emulator) and more brittle (a layout change, a renamed id, or a timing issue can break one even when the underlying logic is fine) than the plain unit tests from the previous lesson. That's exactly why the standard "testing pyramid" shape puts a large base of fast unit tests under a much smaller number of carefully chosen UI tests — reserving Espresso for TrailLog's genuinely critical end-to-end flows, like adding a trail or opening one from the list, rather than trying to cover every possible interaction at the UI layer.