Testing Compose UIs With the New v2 Testing APIs

AndroidJetpack ComposeTestingKotlin
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

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.

Semantics and test tags

Compose UI tests should assert semantics (content description, role) over pixel position. Use testTag sparingly — prefer user-visible properties. Synchronization with idling resources prevents flaky interactions; register custom idling for animations and async loads. Robolectric covers logic; device tests cover touch targets and real rendering. Screenshot tests complement semantics tests for visual regressions layout alone won't catch.

Screenshot and semantics together

Semantics tests catch broken behavior; screenshot tests catch visual regressions Compose semantics miss — gradient backgrounds, padding shifts. Keep screenshot scope to design-system components, not full screens with dynamic timestamps.

Resources

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

testing compose uis v2 rollout

Field RUM on Android 4G. RDS Proxy where relevant. Rollback in PR.

Hilt and Compose tests

@HiltAndroidTest
class HomeScreenTest {
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeRule = createAndroidComposeRule<MainActivity>()

    @Before fun setup() = hiltRule.inject()
}

Replace production bindings with @TestInstallIn fakes for deterministic UI.

Navigation testing

composeRule.onNodeWithContentDescription("Show details").performClick()
composeRule.onNodeWithTag("detail_screen").assertIsDisplayed()

Test tags on root of each destination — navigation graphs change; tags stabilize assertions.

Custom semantics

Modifier.semantics { contentDescription = "Loading" }

Merge properties carefully — duplicate descriptions confuse TalkBack and tests.

Screenshot tests

captureToImage() on Compose nodes for visual regression — supplement semantics tests, not replace.

Multimodule CI

:feature:checkout instrumented tests run only when :feature:checkout affected — Gradle test caching saves hours.

Accessibility test automation

composeRule.onNodeWithRole(Role.Button).assertHasClickAction()

Compose Test assertIsToggleable() etc. — catch missing roles before manual audit.

Compose UI tests pay off when semantics are designed alongside UI — not bolted on after release.

Frequently asked questions

q

a

q

a

q

a

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 →