Testing Compose UIs With the New v2 Testing APIs

AndroidJetpack ComposeTestingKotlin

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:

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

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 →