Testing Compose UIs With the New v2 Testing APIs
Compose testing looks alien the first time you see it because there are no view IDs to find. Instead of onView(withId(R.id.button)), you query a semantics tree — a parallel representation of your UI built for accessibility and testing. Once that clicks, Compose tests are more expressive and less flaky than the Espresso tests they replace, precisely because they hook into the framework's own idea of "the UI is idle now."
I've migrated large Espresso suites to Compose testing on production apps, and the tests that survived were the ones that stopped guessing at timing and let the framework synchronize. Here's the model, the API surface worth knowing, and the habits that keep a suite green.
The semantics tree is your query target
Every composable can contribute semantics — properties like text, content description, role, toggle state, and whether it's clickable. Your tests navigate this tree. That's why accessible UIs are also testable UIs: the same content descriptions a screen reader uses are what your finders match against.
When there's no natural semantic to match — a decorative container, an icon among several identical ones — attach an explicit tag:
Button(
onClick = onSubmit,
modifier = Modifier.testTag("submit_button"),
) { Text("Submit") }
Prefer matching by user-visible text or content description where you can, because those tests double as accessibility checks. Reach for testTag when the alternative is a brittle match against layout structure.
The test rule and the three-step rhythm
Every Compose test follows the same shape: set content, find a node, act or assert.
class LoginScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun submitButton_disabledUntilFormValid() {
composeRule.setContent {
LoginScreen(state = LoginState(email = "", password = ""))
}
composeRule.onNodeWithTag("submit_button").assertIsNotEnabled()
composeRule.onNodeWithContentDescription("Email")
.performTextInput("[email protected]")
composeRule.onNodeWithContentDescription("Password")
.performTextInput("hunter2")
composeRule.onNodeWithTag("submit_button").assertIsEnabled()
}
}
createComposeRule() runs on the JVM (with Robolectric) and is fast enough to keep in your unit test set. When you need a real Activity — say you're testing navigation or system interactions — swap to createAndroidComposeRule<MainActivity>(), which runs instrumented on a device or emulator.
Finders, assertions, and actions
The API is small and composable. Three families cover almost everything:
| Category | Examples |
|---|---|
| Finders | onNodeWithText, onNodeWithContentDescription, onNodeWithTag, onAllNodes |
| Assertions | assertIsDisplayed, assertIsEnabled, assertTextEquals, assertIsSelected |
| Actions | performClick, performTextInput, performScrollTo, performTouchInput |
Matchers compose with boolean operators, which is how you disambiguate when several nodes share text:
composeRule.onNode(
hasText("Delete") and hasClickAction() and isEnabled()
).performClick()
For lists, onNodeWithText(...).performScrollTo() brings an off-screen item into view before you act on it — no manual scrolling loops.
Synchronization: the thing that kills flakiness
The most important idea in Compose testing is automatic synchronization. Between each test action, the rule waits until the UI is idle — no pending recompositions, no running animations, no outstanding work registered with the test clock. That's why you should almost never write Thread.sleep. It doesn't help and it makes tests slower and flakier.
When you genuinely need to wait for a condition — a network result driving state — use waitUntil:
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("result_row").fetchSemanticsNodes().isNotEmpty()
}
For animations, take manual control of the clock rather than sleeping:
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithTag("fab").performClick()
composeRule.mainClock.advanceTimeBy(300) // step the animation deterministically
composeRule.onNodeWithText("Menu").assertIsDisplayed()
Controlling mainClock is how you make animation-dependent tests deterministic instead of timing-dependent. This is the single biggest lever for a stable suite.
Structuring a suite that scales
A few conventions that paid off on large codebases:
- Test the state, not the pixels. Because the screens I build render from a single immutable
UiState(see my Compose lessons), most tests just feed a state and assert what renders. That's fast and stable. - Keep composables stateless. A stateless composable is trivially testable — you pass inputs and lambdas and verify output. Hoisted state means you rarely need a ViewModel in a UI test at all.
- Use screenshot tests for the visual layer. Semantics tests verify behavior; they don't catch a broken layout. Pair them with a screenshot testing tool (Paparazzi or Roborazzi) so both correctness and appearance are covered.
- Print the tree when lost.
composeRule.onRoot().printToLog("TREE")dumps the semantics tree to logcat, which is how you figure out why a finder matched nothing. - Keep tags meaningful and stable. Treat
testTagvalues like a small API; renaming them randomly breaks tests for no reason.
Where it fits in the pyramid
Compose UI tests are the middle of the testing pyramid — more expensive than pure logic unit tests, cheaper and more reliable than full end-to-end flows. I lean heavily on stateless-composable tests with createComposeRule because they run on the JVM in CI in seconds, then keep a thin layer of instrumented tests for the handful of flows that touch real system behavior. If you want the broader philosophy on how much of each to write, I laid it out in testing pyramid vs trophy.
The framework does the hard part — knowing when the UI is settled — as long as you don't fight it. Match by semantics, let it synchronize, control the clock when you must, and Compose tests become the cheapest confidence you can buy per line.
Resources
- Testing your Compose layout — official guide
- Compose testing cheat sheet
- Semantics in Compose
- Synchronization in Compose tests
- Roborazzi — JVM screenshot testing
- Robolectric
Frequently asked questions
How do Compose UI tests find elements without view IDs?
Compose tests query the semantics tree, not view IDs. You match nodes by text, content description, role, or an explicit testTag you attach via Modifier.testTag, then assert or act on the resulting node.
Do I need an emulator to test Compose UIs?
Not always. Instrumented tests with createAndroidComposeRule run on a device or emulator, but many pure-Compose tests run on the JVM with Robolectric via createComposeRule, which is far faster in CI.
Why are my Compose tests flaky?
Most flakiness comes from fighting the test clock. Avoid Thread.sleep, let Compose auto-synchronize on idle, and when you drive animations or coroutines manually use mainClock and waitUntil instead of arbitrary delays.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →