If you’ve worked with Jetpack Compose long enough, you’ve probably thought:
“UI looks great — but how do I test it reliably?”
Compose makes building UI fun, but testing it is underrated. Without solid tests, your beautiful UI can break in subtle ways that users notice long before you do.
That’s where Espresso + Compose testing comes in - and this article helps you actually understand how to use them in real code, not just copy/paste snippets.
Inspired by: How to Test Jetpack Compose UIs Using Espresso
Why UI Testing Matters (and What Usually Fails)
Let’s be honest:
Manual testing feels okay until it doesn’t.
You may catch obvious crashes, but:
- Layout glitches slip through
- Recomposition bugs go unnoticed
- Edge cases eat your time
- Something that worked yesterday suddenly breaks
Especially with Compose’s dynamic UI and state system, UI tests catch problems early.
Automated testing:
- Reduces manual QA effort
- Gives confidence for refactors
- Acts as living documentation
- Helps prevent regressions
So yes - testing isn’t optional. It’s part of quality.
Espresso Meets Jetpack Compose - The Basics
Espresso was historically the go-to UI test tool for Android Views.
Compose introduced its own test APIs (composeTestRule), but Espresso still plays nicely when testing parts of your UI that compose with classic Views or interoperability components.
Before you write a test, set up your test rule:
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
This gives you an entry point to:
- Set content
- Interact with semantics
- Assert UI state
Plug this into your instrumented test file (usually under androidTest/).
Finding UI Elements
In Compose, elements are identified not by IDs, but by semantics - roles, content descriptions, and custom test tags.
For example:
composeTestRule.onNodeWithText("Login").performClick()
This finds a node displaying text and clicks it. Simple.
But often you’ll want a custom tag:
Modifier.testTag("login_button")
Then find it:
composeTestRule.onNodeWithTag("login_button").assertIsDisplayed()
This works great because:
- Tags aren’t visible to users
- They’re test-only handles
- They keep your test logic independent of design changes
Interacting with UI
Testing isn’t just about looking - it’s about doing.
Compose test APIs let you:
performClick()performTextInput("hello")performScrollTo()
For example:
composeTestRule.onNodeWithTag("email_field")
.performTextInput("dev@example.com")
This simulates the user typing into the field - a real interaction, not just a state change.
Asserting UI Behavior
Assertions tell you what should have happened.
Useful assertions include:
assertIsDisplayed()
assertTextEquals("Welcome")
assertIsEnabled()
For example:
composeTestRule.onNodeWithTag("submit_button")
.assertIsEnabled()
This makes sure your UI isn’t only visible, it’s interactive as expected.
Dealing With Asynchronous UI
UI often updates asynchronously - for example, after network calls or state updates.
Compose test rules let you wait for the UI to settle:
composeTestRule.waitForIdle()
This makes sure no pending changes are in flight before you assert.
Sometimes you need to wait for a condition:
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithTag("item").fetchSemanticsNodes().isNotEmpty()
}
This waits up to 5 seconds for something to appear - handy with asynchronous data.
Espresso + Compose - Real Interop
If your screen mixes Compose and classic Views (e.g., a RecyclerView or WebView), combine Espresso and Compose APIs:
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<MyViewHolder>(10))
And then back to Compose:
composeTestRule.onNodeWithTag("footer").assertExists()
The ability to mix both tools makes tests flexible on mixed projects.
A Real Example Workflow
Let’s say you want to test a login screen:
- Launch the UI
- Type valid credentials
- Click the login button
- Check for the welcome message
In code:
@Test
fun login_success_displaysWelcome() {
composeTestRule.onNodeWithTag("email_field")
.performTextInput("test@example.com")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("password123")
composeTestRule.onNodeWithTag("login_button")
.performClick()
composeTestRule.onNodeWithText("Welcome Back!")
.assertIsDisplayed()
}
Clean. Readable. And directly connected to user intent.
Why This Is Better Than Manual QA
Manual testing is:
- Slow
- Hard to reproduce
- Missing coverage
- Inconsistent
Automated Compose tests give:
- Repeatable results
- Confidence during refactor
- Fast feedback
- Integration with CI/CD
You’ll end up shipping safer, faster, and with fewer late-night bug hunts.
Final Tips for Daily Practice
Here are a few practical reminders:
-
Use
testTag()liberally - it makes targets predictable. - Prefer semantics over text lookups - UI text changes often.
- Group related actions in helper functions - reduce boilerplate.
- Run tests on multiple devices/emulators - behavior isn’t always consistent.
- Automate tests in CI/CD - make it part of your pipeline, not an afterthought.
Wrapping Up
Jetpack Compose makes UI elegant - and with the right tests, reliable too.
By combining:
- Compose testing APIs
- Espresso for interop
- Clear semantics
- Smart assertions
…you can confidently validate your UI behavior and avoid regressions.
Compose is powerful. Testing makes it trustworthy.

Top comments (0)