Compose & Espresso UI Testing Explained

TESTING › UI

UI tests run instrumented, on a real or emulated device, because they need the real rendering and input pipeline. For Compose, the entry point is a test rule: createComposeRule() hosts Composable content with no specific Activity behind it, while createAndroidComposeRule<T>() launches an actual Activity and also exposes it, useful when your test needs to reach into the Activity itself.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun `submit button shows confirmation`() {
    composeTestRule.setContent { MyScreen() }

    composeTestRule.onNodeWithText("Submit").performClick()
    composeTestRule.onNodeWithText("Done!").assertIsDisplayed()
}

Crucially, Compose tests don't query pixels or the Composable call tree, they query the **semantics tree**, the same structure that powers accessibility.

Compose test operations fall into three categories you chain together: **finders** locate a node (onNodeWithText, onNodeWithTag), **actions** interact with it (performClick, performTextInput, performScrollTo), and **assertions** verify state (assertIsDisplayed, assertExists, assertTextEquals).

When there's no visible text or content description to match on, tag the Composable yourself:

Box(modifier = Modifier.testTag("loadingSpinner")) { CircularProgressIndicator() }

// in the test:
composeTestRule.onNodeWithTag("loadingSpinner").assertExists()

assertExists() and assertIsDisplayed() sound interchangeable but aren't: assertExists() only checks the node is present in the tree, assertIsDisplayed() also requires it to actually be visible on screen, not scrolled off or clipped.

Compose merges child semantics into a parent by default, a Button absorbs its inner Text's semantics so the whole button reads as one unit for accessibility. That's usually what you want, but it can hide a child node you specifically need to match:

// May fail: the Text is merged into the Button's semantics
composeTestRule.onNodeWithText("OK").assertExists()

// Queries the unmerged tree, exposing the child node directly
composeTestRule.onNodeWithText("OK", useUnmergedTree = true).assertExists()

useUnmergedTree = true is the escape hatch, it makes the finder search semantics nodes before merging happens, exposing children that would otherwise be folded into their parent.

For legacy View-based screens, Espresso has one core pattern you'll type constantly: onView(matcher).perform(action).check(assertion). Locate the view, act on it, verify the outcome.

onView(withId(R.id.username))
    .perform(typeText("user@example.com"), closeSoftKeyboard())
    .check(matches(withText("user@example.com")))

ViewMatchers (withId, withText) find the view, ViewActions (click(), typeText()) interact with it, ViewAssertions (matches) verify it. One more that's easy to forget: to assert a view is absent from the hierarchy entirely, use check(doesNotExist()), not matches(isDisplayed()), which requires the view to be present.

Espresso automatically waits for the UI thread's message queue and any registered IdlingResources to go idle before every perform or check, which is why you rarely see Thread.sleep() in well-written Espresso tests. Compose has the same auto-synchronization built in. But **neither** framework can see background work that's invisible to the UI thread, a raw background thread, an unmanaged network call.

For that, you register a custom IdlingResource (often a CountingIdlingResource, incremented when work starts and decremented when it finishes) so Espresso knows to wait for it too:

// BAD, fragile arbitrary wait
onView(withId(R.id.loginButton)).perform(click())
Thread.sleep(2000)

// GOOD, Espresso waits automatically once the resource is registered
onView(withId(R.id.loginButton)).perform(click())
onView(withId(R.id.dashboard)).check(matches(isDisplayed()))

A ListView, GridView, or Spinner recycles off-screen items, so onView() often can't reach them, they simply don't exist in the current View hierarchy. Espresso.onData() is built for exactly this: it matches against the **adapter's backing data**, not the rendered views, and scrolls the widget to bring the matching item into view before acting on it.

onData(allOf(`is`(instanceOf(String::class.java)), `is`("Option 3")))
    .inAdapterView(withId(R.id.spinner))
    .perform(click())

Compose's LazyColumn has the analogous problem for a different reason, it only *composes* visible items, so off-screen nodes simply aren't in the semantics tree yet. The fix there is performScrollToNode(matcher) on the list, which composes the target before you interact with it.

Two setup details that matter for reliable instrumented UI tests: the testInstrumentationRunner must be androidx.test.runner.AndroidJUnitRunner (declared in build.gradle), or on-device tests won't run at all. And system animations are a common source of flakiness, since Espresso can't synchronize with an in-progress animation, so the standard fix is disabling the window, transition, and animator animation scales rather than sprinkling sleeps everywhere:

android {
    defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    testOptions {
        animationsDisabled = true
    }
}

Dependency placement matters too: Compose's ui-test-junit4 goes under androidTestImplementation, since it only ships with instrumented test builds, not the main app.

Back to Compose & Espresso UI Testing