Lesson 43 of 83 intermediate

Espresso Legacy Awareness & Instrumentation Basics

Master Espresso for XML-view codebases — still asked in every senior Android interview

Open interactive version (quiz + challenge)

Real-world analogy

Espresso is like a meticulous restaurant inspector who waits until the kitchen is completely idle before tasting each dish. It synchronises with the UI thread and async operations automatically, so your test never taps a button before the screen is ready — unlike a human tester who might click too early.

What is it?

Espresso is Google's UI testing framework for Android that provides a fluent API to find views, perform actions, and assert state in instrumented tests running on a real device or emulator. It automatically synchronises with the main thread, ensuring test actions only execute when the UI is idle.

Real-world relevance

In a large enterprise school management app with 200+ XML-based screens built over 5 years, the team used Espresso to write regression tests for the student grade-entry flow. When migrating to Compose, they added ComposeTestRule alongside Espresso to test the hybrid screens without rewriting every existing test, protecting against regressions during the migration.

Key points

Code example

// build.gradle.kts (app)
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-contrib:3.5.1")
androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")
androidTestImplementation("androidx.test:rules:1.5.0")

// LoginActivityTest.kt
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    // Register IdlingResource so Espresso waits for Retrofit calls
    private val idlingResource = CountingIdlingResource("NetworkCalls")

    @Before
    fun registerIdling() {
        IdlingRegistry.getInstance().register(idlingResource)
    }

    @After
    fun unregisterIdling() {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }

    @Test
    fun successfulLogin_navigatesToDashboard() {
        // Type credentials
        onView(withId(R.id.etEmail))
            .perform(typeText("user@school.edu"), closeSoftKeyboard())

        onView(withId(R.id.etPassword))
            .perform(typeText("secret123"), closeSoftKeyboard())

        // Click login button
        onView(withId(R.id.btnLogin)).perform(click())

        // Espresso waits for idlingResource to reach 0
        // Then asserts dashboard is visible
        onView(withId(R.id.dashboardTitle))
            .check(matches(isDisplayed()))
    }

    @Test
    fun emptyEmail_showsValidationError() {
        onView(withId(R.id.btnLogin)).perform(click())

        onView(withId(R.id.tilEmail))
            .check(matches(hasDescendant(withText("Email is required"))))
    }

    @Test
    fun recyclerList_clicksThirdItem() {
        onView(withId(R.id.rvStudents))
            .perform(RecyclerViewActions.actionOnItemAtPosition<StudentViewHolder>(2, click()))

        onView(withId(R.id.studentDetailName))
            .check(matches(isDisplayed()))
    }
}

Line-by-line walkthrough

  1. 1. ActivityScenarioRule(LoginActivity::class.java) — launches LoginActivity in a real instrumented process before each test and tears it down after.
  2. 2. CountingIdlingResource('NetworkCalls') — a counter-based IdlingResource; Espresso treats the app as idle only when the counter is 0.
  3. 3. IdlingRegistry.getInstance().register(idlingResource) — must be registered @Before each test; unregistered @After to prevent leaks across tests.
  4. 4. onView(withId(R.id.etEmail)) — locates the view whose android:id is etEmail in the current activity's view hierarchy.
  5. 5. perform(typeText('user@school.edu'), closeSoftKeyboard()) — types the string character by character and then dismisses the keyboard so it does not obscure other views.
  6. 6. onView(withId(R.id.btnLogin)).perform(click()) — simulates a finger tap on the Login button; Espresso waits for the UI to be idle first.
  7. 7. onView(withId(R.id.dashboardTitle)).check(matches(isDisplayed())) — after Espresso sees the idlingResource counter reach 0 (network done), it asserts the dashboard title is visible.
  8. 8. RecyclerViewActions.actionOnItemAtPosition(2, click()) — scrolls the RecyclerView to position 2 if needed, then dispatches a click on that ViewHolder.
  9. 9. hasDescendant(withText('Email is required')) — a hierarchical matcher; asserts that somewhere inside the TextInputLayout there is a child view displaying that error string.
  10. 10. check(doesNotExist()) — asserts the matched view is completely absent from the hierarchy (not just hidden), useful for verifying dialogs have been dismissed.

Spot the bug

@RunWith(AndroidJUnit4::class)
class GradeEntryTest {

    @get:Rule
    val rule = ActivityScenarioRule(GradeEntryActivity::class.java)

    @Test
    fun submitGrade_showsSuccessMessage() {
        Thread.sleep(2000) // Bug 1

        onView(withId(R.id.etGrade))
            .perform(typeText("95"))

        onView(withId(R.id.btnSubmit)).perform(click())

        Thread.sleep(1000) // Bug 2

        onView(withText("Grade saved"))
            .check(matches(isDisplayed())) // Bug 3
    }

    @Test
    fun invalidGrade_showsError() {
        onView(withId(R.id.etGrade))
            .perform(typeText("150"))

        onView(withId(R.id.btnSubmit)).perform(click())

        onView(withId(R.id.errorText))
            .check(matches(withText("Invalid grade"))) // Bug 4
    }
}
Need a hint?
Look at the Thread.sleep() calls, what check() is missing after an Espresso action on a Snackbar, and what matcher should be combined with withText() for the error assertion.
Show answer
Bug 1: Thread.sleep(2000) at the start is a timing hack to wait for the screen to load. Espresso already synchronises with the main thread and idles automatically — the sleep is unnecessary and makes the test slow and flaky on different devices. Fix: remove Thread.sleep() entirely; if there is async work (e.g., loading existing grade from DB), use IdlingResource instead. Bug 2: Thread.sleep(1000) after clicking Submit is another timing hack to wait for the network/database response. Same problem — register a CountingIdlingResource in the ViewModel or repository, increment before the async call, decrement in the callback, and Espresso will wait automatically without any sleep. Bug 3: onView(withText('Grade saved')).check(matches(isDisplayed())) — Snackbars are hosted in the decor view, not the activity's view hierarchy directly. This matcher can fail with NoMatchingViewException if the Snackbar appears in a parent view. Fix: use onView(allOf(withId(com.google.android.material.R.id.snackbar_text), withText('Grade saved'))).check(matches(isDisplayed())) to target the Snackbar's internal TextView specifically. Bug 4: check(matches(withText('Invalid grade'))) on a TextView only passes if the ENTIRE text is exactly 'Invalid grade'. If the error view shows 'Invalid grade: must be 0-100', this assertion fails. Fix: use check(matches(withText(containsString('Invalid grade')))) with a Hamcrest containsString matcher, or ensure the exact string matches the resource string used in the production code.

Explain like I'm 5

Espresso is like a robot that tests your app by tapping buttons and reading the screen. It is smart enough to wait until the app stops loading before it taps anything — so it never taps too early. You tell it 'find the Login button' then 'tap it' then 'check that the Home screen appeared', and it does all that automatically.

Fun fact

Espresso was created by an Googler (Valera Zakharov) and open-sourced in 2013. The name was chosen because, like a good espresso shot, tests should be fast, reliable, and jitter-free — no Thread.sleep() allowed!

Hands-on challenge

Write an Espresso test for a login screen that: (1) types an invalid email, clicks Login, and asserts a TextInputLayout error appears; (2) types valid credentials, clicks Login, registers a CountingIdlingResource to wait for a fake network call (increment before the call, decrement after), and asserts the dashboard title is displayed. Use ActivityScenarioRule and closeSoftKeyboard() after typing.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery