4. UI Testing with Espresso (Instrumented Tests)

4. UI Testing with Espresso (Instrumented Tests) 

What

So far, our tests (JUnit + MockK, Topic 2) ran completely on the JVM — no emulator, no real screen, nothing actually appeared on a device. That's great for testing logic, but it can never tell you if a real button, when actually tapped, does the right thing on the real screen.

That's where Instrumented Tests come in — tests that run on an actual emulator or device, because they need to talk to real Android things: real Views, real Activities, real clicks.

> Analogy, continuing from before: If a plain Unit Test is like checking your cricket bat alone in the nets, an Instrumented/UI test is like actually walking out and playing a practice match on the real pitch — checking that everything works together, for real, on an actual field (here, the emulator/device).

**Espresso** is Google's official library for writing these UI tests. Think of it as a **robot user** — it looks at the screen, taps buttons, types into fields, scrolls, and then checks whether what's now on screen matches what you expected. You write the script; Espresso plays the role of the user, automatically, every single time you run the test.

Why

- After changing code, manually opening the app and clicking through every screen to check nothing broke is slow, repetitive, and easy to get wrong.

- Business-logic tests (Topic 2) tell you your ViewModel/data logic is correct — but they can't tell you if the "Next" button is actually wired to the right view, or if your text is actually visible on screen. Only a real UI test can confirm that.

- Automated UI tests catch regressions in navigation, form input, and on-screen text before a real user ever sees them.

- Espresso is the standard, most commonly used Android UI testing tool — interviewers routinely expect you to know the basic onView().perform().check() pattern.

Comparison: The 3 Building Blocks of Every Espresso Test

Every single Espresso test — no matter how complex — is built from just three ingredients, always in the same order:

PartPlain meaningCommon examples
1. Find the view (Matcher)"Which view on screen am I talking about?"withId(R.id.xxx), withText("...")
2. Do something to it (Action)"What should happen to it?"click(), typeText("..."), closeSoftKeyboard()
3. Check the result (Assertion)"Did it turn out the way I expected?"check(matches(withText("...")))

Simple way to remember the pattern:

onView( find it ).perform( do something ) — for actions

onView( find it ).check( confirm something ) — for assertions

Mermaid Diagram

graph LR
    A[Rule launches the Activity] --> B[Find view - onView + withId]
    B --> C[Perform action - click or typeText]
    C --> D[Check result - matches withText]

Code Example

We'll use the same running example as the video: an app with course screens (Next/Previous/Share buttons), plus a simple Notes screen (Title + Description → Submit).

Step 1 — Add the right dependencies

// build.gradle (Module)
dependencies {
    // These go under "androidTestImplementation", NOT "testImplementation" —
    // because instrumented tests need a real device/emulator to run on, unlike
    // plain unit tests which run on the JVM alone.
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation("androidx.test:core:1.5.0")

    // Only needed if you're also testing Intents (see bonus section below)
    androidTestImplementation("androidx.test.espresso:espresso-intents:3.5.1")
}

Step 2 — Set up the Rule (auto-launch the Activity before every test)

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    // "Rule" = common setup that runs before EVERY test in this class.
    // Here: launch MainActivity first, every single time, before any test method runs.
    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    // ... test methods go here
}

In plain words: Without this Rule, there'd be no app on screen for Espresso to click on. The Rule guarantees the activity is already open and ready before each test starts — similar to @Before in plain JUnit, just Android-specific.

Step 3 — Test the "Next" button (navigation)

@Test
fun clickingNextThreeTimes_showsThirdCourseTitle() {
    // Find the Next button, and perform a click — three times
    onView(withId(R.id.nextButton)).perform(click())
    onView(withId(R.id.nextButton)).perform(click())
    onView(withId(R.id.nextButton)).perform(click())

    // Now check: does the course title on screen match the 3rd course's expected title?
    onView(withId(R.id.courseTitleTextView))
        .check(matches(withText("Kotlin Coroutines")))
}

In plain words: We tap "Next" 3 times, just like a real user would, then check the on-screen text really changed to what we expect — proving the navigation actually works on a real screen, not just in our head.

Step 4 — Test the Notes form (typing + submit + verify next screen)

@Test
fun submittingTitleAndDescription_showsCombinedTextOnNextScreen() {
    val title = "Hello"
    val description = "Learning Espresso testing"

    // Type into the Title field, then close the keyboard
    // (closing the keyboard first avoids it covering the Submit button below)
    onView(withId(R.id.titleEditText)).perform(typeText(title), closeSoftKeyboard())

    // Type into the Description field
    onView(withId(R.id.descriptionEditText)).perform(typeText(description), closeSoftKeyboard())

    // Tap Submit — this should open a new screen, passing title+description along
    onView(withId(R.id.submitButton)).perform(click())

    // On the new screen, check the combined text is exactly what we expect
    val expectedMessage = "$title | $description"
    onView(withId(R.id.resultTextView))
        .check(matches(withText(expectedMessage)))
}

In plain words: This mimics a real user filling a form: type a title, type a description, hide the keyboard, tap submit — then confirm the next screen actually shows both pieces of information, combined correctly.

Bonus (brief mention) — Checking that Share opens a real Intent

The video briefly touches this — it needs one extra setup step and is really its own topic, but here's the basic shape:

@Test
fun clickingShare_firesShareIntent() {
    Intents.init() // start recording intents (or use an IntentsRule instead)

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

    intended(hasAction(Intent.ACTION_SEND)) // confirm a "share" intent was actually fired

    Intents.release()
}

In plain words: Instead of checking on-screen text, this checks that tapping Share actually triggers Android's share mechanism — useful when a button's whole job is to hand off to another app.

Quick glossary of the syntax used above

CodePlain meaning
@RunWith(AndroidJUnit4::class)Use Android's special test runner for this class — required for instrumented tests.
ActivityScenarioRule(MainActivity::class.java)Launch MainActivity automatically before every test.
onView(withId(R.id.xxx))Find the view with this ID on the screen.
withText("...")Find or match a view showing this exact text.
.perform(click())Simulate a real tap on this view.
.perform(typeText("..."))Simulate typing the specified text into the view.
closeSoftKeyboard()Dismiss the on-screen keyboard — useful before clicking a button hidden behind the keyboard.
.check(matches(withText("...")))Verify that the view's text matches the expected text — the test fails if it doesn't.
intended(hasAction(...))Verify that an Intent with the specified action was sent — requires Espresso-Intents.


Common Mistakes

- Adding Espresso under testImplementation instead of androidTestImplementation — Espresso needs a real device/emulator, so it must go under androidTestImplementation, or it simply won't be found when the instrumented test runs.

- Forgetting the ActivityScenarioRule — without it, there's no activity open on screen for Espresso to interact with, and the test fails immediately.

- Not closing the soft keyboard before clicking a button — if the keyboard is covering the button, Espresso can't tap it, and the test fails for a reason that has nothing to do with your actual app logic.

- Hardcoding expected text everywhere — if the actual on-screen text ever changes (even a typo fix), every test with that hardcoded string breaks; prefer referencing the same string resource the app itself uses.

- Expecting one video or course to cover every possible test scenario — UI testing has a lot of surface area (lists, dialogs, permissions, intents, etc.); Google's official android/testing-samples repository on GitHub is a good ongoing reference for patterns you haven't seen yet.

- Trying to run instrumented tests without a connected emulator/device — unlike plain unit tests (which run on the JVM instantly), these need an actual Android runtime to execute on; there's no way around that.

Interview Q&A (Fresher Level)

Q: What's the difference between a unit test and an instrumented (UI) test in Android?

A: A unit test runs on the JVM alone, testing logic in isolation, without any real Android environment. An instrumented test runs on a real emulator/device, because it interacts with real Views and Activities — things that only exist in the actual Android runtime.

Q: What library does Android use for UI testing, and what's its core pattern?

A: Espresso. Every test follows the same three-step pattern: find a view (onView + a matcher like withId), perform an action on it (click(), typeText()), and check the result (check(matches(...))).

Q: What is ActivityScenarioRule, and why do you need it?

A: It's a JUnit Rule that automatically launches a given Activity before every test method in the class runs — so there's always a real screen ready for Espresso to interact with, without repeating that setup code in every single test.

Q: How do you simulate typing text into a field with Espresso?

A: onView(withId(R.id.fieldId)).perform(typeText("some text")), often followed by closeSoftKeyboard() so the keyboard doesn't block anything below it.

Q: How would you test that tapping a button opens Android's share sheet?

A: Using the Espresso-Intents library — after the click, call intended(hasAction(Intent.ACTION_SEND)) to confirm the expected Intent was actually fired.

Q: Why must Espresso's dependencies be added as androidTestImplementation rather than testImplementation?

A: Because Espresso needs a real Android runtime (Views, Activities, a screen) to run against — something only available on an emulator/device, not on the plain JVM that regular unit tests run on.