Android Testing with Espresso
Introduction
UI testing is one of the most valuable layers in any Android quality strategy. It exercises your app through the same surface area that real users navigate, catching regressions that unit tests simply can't reach. Espresso is Google's flagship UI testing framework for Android because it eliminates much of the boilerplate that surrounded older approaches, and it syncs automatically with the main thread so assertions fire at the right moment. This tutorial walks you through Espresso from project setup to advanced scenarios like RecyclerViews, Jetpack Compose, asynchronous idling resources, and UI Automator bridges for system-level dialogs.
Setting Up Espresso
Before writing a single test, you need to configure your module dependencies and opt into instrumentation tests. Android Studio scaffolds an androidTest source set for you, but the dependencies themselves are explicit in your build.gradle.kts.
dependencies {
testImplementation("junit:junit:4.13.2")
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:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.2")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.1")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.1")
}
This configuration pulls in the core matcher and action APIs, the contrib library that ships RecyclerView support, and the Intents module for intent-based testing. The Compose snippets enable Espresso to locate and interact with composables using semantic labels. Because the debugImplementation variant is used for the Compose manifest, it only adds the necessary test harness in debug builds, keeping release apks lean.
Understanding onView with ViewMatchers and ViewActions
The central idiom in Espresso is the onView call, which locates a view in the current hierarchy and then chains either an action or an assertion. Matchers describe what you want, while actions describe what should happen, and assertions verify that it happened.
class LoginScreenTest : AbstractTest() {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun entersCredentialsAndSubmits() {
onView(withId(R.id.etUsername))
.perform(typeText("[email protected]"))
.check(matches(isDisplayed()))
onView(withId(R.id.etPassword))
.perform(clearText(), typeText("SecretPass123!"))
.check(matches(isDisplayed()))
onView(withText("Sign In"))
.perform(click())
onView(withText("Welcome back, developer!"))
.check(matches(isDisplayed()))
}
}
Each chained call maps directly to a real Espresso workflow. perform drives the action, and the framework inserts the correct number of Looper messages so the action happens on the main thread. check runs the assertion after the previous action completes, and failures report exactly which view matcher failed rather than throwing opaque runtime exceptions. Notice that clearText precedes typeText on the password field, which prevents leftover characters from corrupting the test input.
Testing RecyclerViews
RecyclerViews resist simple click-based testing because items are recycled and may not exist in the DOM when the test starts. The Espresso Contrib library provides RecyclerViewActions, which resolves items by holder type, position, or a custom matcher.
class TaskListTest : AbstractTest() {
@get:Rule
val activityRule = ActivityScenarioRule(TaskListActivity::class.java)
@Test
fun scrollsToThirdItemAndTogglesIt() {
onView(withText("Review PR #42"))
.inAdapter(R.string.task_list_title, TaskViewHolder::class.java)
.check(matches(isDisplayed()))
onView(withId(R.id.cbComplete))
.inAdapter(R.string.task_list_title, TaskViewHolder::class.java)
.perform(click())
.check(matches(isChecked()))
}
}
The inAdapter call tells Espresso to search inside the adapter associated with the given layout resource, resolving views through the ViewHolder class you specify. Using the string resource reference for the adapter keeps the test decoupled from a hard-coded layout ID, while the ViewHolder type ensures type safety at compile time. When the list is populated from a network call, the item must already be bound before the assertion runs, so any idling resource setup belongs in the test before this block executes.
Testing Compose UI with Espresso
Jetpack Compose does not expose traditional Android view IDs in the same way, but it does publish semantic labels that Espresso can match via withSemanticContent. The Compose test library adds a small helper so onNode calls resolve cleanly alongside the regular Espresso onView calls.
@Composable
fun ProfileScreen(userId: String) {
var name by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text(text = "Profile", modifier = Modifier.testTag("profile_title"))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Display Name") },
modifier = Modifier.testTag("display_name_field")
)
Button(
onClick = { /* submit */ },
modifier = Modifier.testTag("submit_button")
) {
Text("Save")
}
}
}
class ProfileComposeTest : AbstractTest() {
@get:Rule
val composeRule = createComposeRule()
@Test
fun updatesNameAndSubmits() {
composeRule.setContent { ProfileScreen(userId = "u-101") }
composeRule.onNodeWithText("Display Name").performTextInput("Ada Lovelace")
composeRule.onNodeWithTag("submit_button").performClick()
composeRule.onNodeWithText("Profile saved").assertIsDisplayed()
}
}
The testTag modifier attaches a semantic label that Compose exposes through its own node lookup API, bypassing the need for legacy view IDs. performTextInput types the full string in a single call, and assertIsDisplayed is the Compose-flavored equivalent of an Espresso check. Mixing setContent with tag-based assertions keeps the test focused on user-visible behavior rather than internal implementation details.
IdlingResource for Asynchronous Operations
Espresso waits automatically for the main thread's message queue to drain, but it cannot sense background work happening off the main thread. An IdlingResource bridges that gap by telling Espresso to pause until a custom condition becomes idle, which is essential for network calls, animations, and coroutines that do not post back through the standard Handler path.
class MainThreadSyncIdlingResource : IdlingResource {
private val callbacks = mutableListOf<IdlingResource.ResourceCallback>()
private val handler = Handler(Looper.getMainLooper())
private val count = AtomicInteger(0)
fun increment() {
count.incrementAndGet()
}
fun decrement() {
if (count.decrementAndGet() == 0) {
callbacks.forEach { it.onTransitionToNotIdle() }
}
}
override fun getName() = "MainThreadSyncIdlingResource"
override fun isIdleNow(): Boolean = count.get() == 0
override fun registerCallback(cb: IdlingResource.ResourceCallback) {
callbacks.add(cb)
if (!isIdleNow()) cb.onTransitionToNotIdle()
}
}
This implementation uses an AtomicInteger as the actual idle counter, which guarantees thread-safe increments and decrements even when multiple background tasks overlap. The registerCallback method immediately notifies Espresso that the resource is not idle if work is already outstanding, preventing a race where Espresso checks isIdleNow before the first callback has been recorded. You register an instance of this class with Espresso.registerIdlingResources before the test interaction and remove it afterward using unregisterIdlingResources to avoid cross-test pollution.
Using UI Automator for System Dialogs
System dialogs such as location permission prompts, notification shade panels, and accessibility menus live outside your app's window hierarchy, so Espresso cannot reach them directly. The UiAutomator library lets your test interact with the global Android UI, and you can combine it with Espresso by pausing the test thread while the system action runs, then resuming once the expected element appears.
@Throws(InterruptedException::class)
fun grantLocationPermission() {
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.pressHome()
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.wait(Until.hasObject(By.text("Allow")), 5_000)
val allowButton = device.findObject(UiSelector().text("Allow"))
allowButton.click()
Thread.sleep(1_000)
}
The test presses Home first to guarantee a clean starting state, then waits for the permission dialog text using a bounded timeout rather than a blind sleep. findObject returns a UiObject backed by the current overlay hierarchy, and click() performs a real tap that respects accessibility constraints. The final short sleep gives the app time to resume and settle before the next Espresso interaction begins, but in production tests you would typically replace it with a more precise wait on the app's own UI state.
Common Pitfalls
The most frequent cause of flaky Espresso tests is assuming the UI is ready before it actually is. Network responses, animation end listeners, and coroutines that do not post back through the main Looper all create moments where your view technically exists but its content has not finished updating. Registering an IdlingResource for each logical async window removes the guesswork. Another common mistake is relying on view positions inside a RecyclerView; the adapter may have recycled that position between test steps, so always match by content or data-bound text instead. Finally, avoid mixing Thread.sleep into your instrumentation tests unless you are calling out to a non-Espresso subsystem like UI Automator, because sleeping blocks the test runner and turns a deterministic failure into a timing-dependent one.
Conclusion
Espresso remains the most straightforward and reliable way to assert that an Android app behaves correctly from the user's perspective. By combining onView matchers, the RecyclerView helpers from the contrib library, Compose semantic nodes, IdlingResource for background work, and UiAutomator for system dialogs, you can cover nearly every surface your users interact with. The discipline that matters most is consistency: register idling resources where they belong, keep your matchers declarative rather than positional, and prefer content-based selectors over hardcoded coordinates. When those habits take hold, your test suite becomes a stable safety net rather than a source of intermittent failures.