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:
| Part | Plain meaning | Common 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
| Code | Plain 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.