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.

Note: Every test shown in this lesson is correct, real Espresso code as it would run in Android Studio's instrumented test source set — but this website's own project doesn't run a live Android Studio build or emulator, so nothing here executes on the page itself.

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.

Up next: Testing Coroutines & Flow tackles the other half of the async testing problem — how to test suspend functions and Flow emissions directly, without a device or emulator at all.