Compose & Espresso UI Testing Explained
TESTING › UI
UI tests for Android run instrumented, meaning they execute on a real or emulated device rather than the local JVM, because they need the real rendering pipeline and real touch input to be trustworthy. For Compose, your entry point is a test rule. createComposeRule() hosts a Composable directly with no real Activity behind it, which is the lighter, more common choice when your test only cares about the UI itself. createAndroidComposeRule<T>() instead launches an actual Activity of the type you name and exposes it to your test, which matters when the test needs to reach into that Activity directly, for example to check an intent extra or call a method on it.
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun submitButtonShowsConfirmation() {
val activity = composeTestRule.activity // real Activity, reachable
composeTestRule.onNodeWithText("Submit").performClick()
composeTestRule.onNodeWithText("Done!").assertIsDisplayed()
}
Once your Compose test has content on screen, it doesn't inspect Composables or pixels to find things, it queries the semantics tree, the same tree that accessibility services use to describe your UI to a screen reader. Every Compose test operation falls into one of three categories that you chain together: finders locate a node, actions interact with it, and assertions verify its state. When there is no visible text or content description to search for, you can give a Composable a stable identifier yourself with Modifier.testTag("tag"), then locate it with the finder built for tags.
Box(modifier = Modifier.testTag("loadingSpinner")) {
CircularProgressIndicator()
}
// finder -> (implicit action) -> assertion, chained together
composeTestRule.onNodeWithTag("loadingSpinner").assertExists()
Two assertions get confused constantly, and interviewers know it. assertExists() only checks that a node is present somewhere in the semantics tree, nothing about whether a person could actually see it. assertIsDisplayed() goes further: it requires the node to be visible on screen right now, not scrolled out of view, not clipped, not hidden behind something else. A list item that has scrolled off the top of the screen can still exist in the tree while failing a displayed check, and that gap between existing and being visible is exactly what this kind of question is testing.
Compose merges a node's descendant semantics into its parent by default. A Button absorbs the semantics of the Text sitting inside it, so a screen reader announces the button as a single unit instead of narrating a container and a label separately. That's the right behavior for accessibility, but it is a classic gotcha in tests: if you try to find that inner Text node directly by its own text, the finder may not see it, because it has been folded into the parent. The escape hatch is a parameter on the finder itself, not a new assertion or a change to the UI, that tells it to search the tree as it exists before merging happens.
// May fail: the Text is merged into the Button's semantics
composeTestRule.onNodeWithText("OK").assertExists()
// Searches the tree before merging, exposing the child node
composeTestRule.onNodeWithText("OK", useUnmergedTree = true).assertExists()
Compose's automatic synchronization waits for recomposition, layout, and animation to settle, but it has no way to know about a condition your own code is waiting on, like a network response arriving before content appears. That's what composeTestRule.waitUntil { ... } is for: it repeatedly evaluates the condition you give it, advancing the test clock as it goes, and returns as soon as the condition is true, or throws once the timeout you set has elapsed. It's the tool for waiting on state that Compose's own idle detection simply cannot see.
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule
.onAllNodesWithText("Loaded")
.fetchSemanticsNodes()
.isNotEmpty()
}
composeTestRule.onNodeWithText("Loaded").assertIsDisplayed()
By default the Compose test clock auto-advances straight to the idle end state, which is exactly what you want for most tests, but it makes it impossible to inspect a UI mid-animation. Setting composeTestRule.mainClock.autoAdvance = false hands you manual control instead: nothing advances until you call advanceTimeBy(...) yourself, letting you step forward a fixed number of milliseconds and assert on the frame you land on. It's the tool for testing an animation's intermediate state, not just its start and end.
composeTestRule.mainClock.autoAdvance = false
composeTestRule.onNodeWithTag("box").performClick() // trigger animation
composeTestRule.mainClock.advanceTimeBy(150) // step 150ms in
composeTestRule.onNodeWithTag("box").assertExists() // inspect mid-animation
composeTestRule.mainClock.autoAdvance = true // restore normal behaviour
Espresso testing for legacy View-based screens has one pattern you will type constantly, and its ordering is deliberate: locate the view, act on it, then verify the outcome. ViewMatchers like withId and withText find the view, ViewActions like click() and typeText() interact with it, and ViewAssertions like matches() check the result. The three stages always run in that order, matcher first, action second, assertion last, because you can't verify an outcome before you've caused it.
onView(withId(R.id.username))
.perform(typeText("user@example.com"), closeSoftKeyboard())
.check(matches(withText("user@example.com")))
Asserting that something is absent is a different move from asserting that something exists but is hidden, and the two are easy to mix up. ViewAssertions.doesNotExist() passes only when the matcher you supply resolves to no view at all in the current hierarchy, which is the correct way to prove an error message or a dialog never appeared. Reaching for matches(isDisplayed()) instead is a mistake here, because that assertion actually requires the view to be present and visible, the opposite of what you're trying to prove.
// Proves absence: passes only when no view matches
onView(withText("Invalid password"))
.check(doesNotExist())
Espresso rarely needs a manual wait because it waits for you: before every perform or check, it blocks until the UI thread's message queue, any running AsyncTask, and every registered idling resource report themselves idle. That's why Thread.sleep() calls in an Espresso test are a smell, they're either unnecessary or, worse, papering over a real synchronization gap. The gap Espresso genuinely can't see on its own is background work invisible to the UI thread, a raw background thread or an unmanaged network call. For that you register a custom IdlingResource, commonly a CountingIdlingResource incremented when work starts and decremented when it finishes, so Espresso learns to wait for it too.
// BAD, fragile arbitrary wait
onView(withId(R.id.loginButton)).perform(click())
Thread.sleep(2000)
// GOOD, Espresso waits automatically once idle
onView(withId(R.id.loginButton)).perform(click())
onView(withId(R.id.dashboard)).check(matches(isDisplayed()))
A ListView, GridView, or Spinner recycles its off-screen rows for memory efficiency, which means an item that isn't currently visible often doesn't exist anywhere in the View hierarchy for onView() to find. Espresso.onData() solves this by matching against the adapter's backing data instead of the rendered views, then scrolling the widget to bring the matching item into view before acting on it. Whenever a question mentions a Spinner or a recycling list, that's the signal to reach for onData() rather than onView().
onData(allOf(`is`(instanceOf(String::class.java)), `is`("Option 3")))
.inAdapterView(withId(R.id.spinner))
.perform(click())
Compose's LazyColumn has a related problem for a different reason: it only composes the items currently visible, so an item scrolled off screen isn't merely hidden, it hasn't been composed at all and simply doesn't exist yet in the semantics tree. Trying to find it with a normal finder fails outright. The fix is to scroll the list itself into position first, targeting the list's own node rather than the item, which forces the target item to compose. Only after that scroll can you find and act on the node directly.
composeTestRule.onNodeWithTag("MyLazyList")
.performScrollToNode(hasText("Item 50"))
// now composed and reachable
composeTestRule.onNodeWithText("Item 50").performClick()
Instrumented tests need an actual runner installed to drive them, and getting this wrong means your tests simply don't run at all, no matter how correct they are. AndroidX instrumented tests, both Espresso and Compose, require testInstrumentationRunner set to androidx.test.runner.AndroidJUnitRunner in your Gradle config. The older android.test.InstrumentationTestRunner is deprecated, and a plain JUnit4 runner has no idea how to talk to a device at all, it only knows how to run on the local JVM.
// build.gradle (app)
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
System animations are one of the most common sources of Espresso flakiness, and the reason is structural: Espresso's synchronization waits for the message queue and idling resources, but it has no way to detect that a window transition or a ripple animation is still mid-flight. The standard fix isn't a sleep, it's turning the animations off entirely for the test environment, by disabling the window, transition, and animator animation scales, either through developer settings on the test device or a Gradle test option. Removing the animation removes the thing Espresso can't see in the first place.
// build.gradle, disable system animations for all instrumented tests
android {
testOptions {
animationsDisabled = true
}
}
Where you declare a test dependency in Gradle controls when it actually gets compiled into a build, and getting this wrong either bloats your production APK or breaks compilation of your instrumented tests. Compose's ui-test-junit4, the artifact that provides createComposeRule() and friends, is an instrumented test dependency, so it belongs under androidTestImplementation, never under plain implementation. A related artifact, ui-test-manifest, goes under debugImplementation instead, since it only needs to be present in debug builds, not the test APK itself.
// build.gradle (app)
dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
}