---
title: "Jetpack Compose Testing: Strategies for Bulletproof UI"
description: "Master Compose UI testing with semantic matchers, test tags, screenshot testing, and a pragmatic strategy that balances coverage, speed, and maintenance cost."
url: https://android-x.com/blog/compose-testing-strategies
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-03-27
updated: 2026-10-11
category: "Testing"
reading_time: "12 min read"
---

# Jetpack Compose Testing: Strategies for Bulletproof UI

> Master Compose UI testing with semantic matchers, test tags, screenshot testing, and a pragmatic strategy that balances coverage, speed, and maintenance cost.

By Rocky Elsalaymeh. Published 2026-03-27, updated 2026-10-11. Canonical page: https://android-x.com/blog/compose-testing-strategies

## Why Compose Changes the Testing Game

View-based UI tests matched against a view hierarchy and waited on it with idling resources. Compose tests ask a different question of a different structure. They query the semantics tree, the same description of the UI that accessibility services read: "The semantics framework is primarily used for accessibility, so tests take advantage of the information exposed by semantics about the UI hierarchy" ([Android](https://developer.android.com/develop/ui/compose/testing/semantics)). A test that finds a button by its label is checking what a user, sighted or not, can find.

Timing is handled for you in the common case. "Compose tests are synchronized by default with your UI", and the clock is virtual: "This means Compose tests don't run in real time, so they can pass as fast as possible" ([synchronization](https://developer.android.com/develop/ui/compose/testing/synchronization)).

Three statements in the earlier version of this article were wrong or unsupported, and they are corrected in the sections below: that tests run "not against an Activity", that there are no race conditions to think about, and a 70/20/10 split of test types that had no source.

## Which Compose test APIs are current?

This changed after the article was first written, and it affects every test file.

The [v2 testing APIs](https://developer.android.com/develop/ui/compose/testing/migrate-v2) replace the entry points you already use: `createComposeRule`, `createAndroidComposeRule` and `runComposeUiTest`. The guide does not hedge: "The v1 APIs are deprecated, and it's strongly recommended to migrate to the new APIs." The v2 functions have the same names in a new package, so `androidx.compose.ui.test.junit4.createComposeRule` becomes `androidx.compose.ui.test.junit4.v2.createComposeRule`.

The behavioural difference is the coroutine dispatcher: "While the v1 APIs relied on the UnconfinedTestDispatcher, the v2 APIs use the StandardTestDispatcher by default for the running composition." Under v1 a coroutine launched by your UI ran eagerly. Under v2 it is queued until the clock advances, which is how [coroutines behave under runTest](https://android-x.com/blog/kotlin-coroutines-deep-dive) everywhere else.

Most tests survive the move untouched, because "Standard Compose test APIs (such as waitForIdle()) already handle this synchronization, so most tests relying on these standard APIs should continue to work with no changes." The ones that break share a shape: the test triggers a load and asserts on the very next line, with no action or assertion from the test rule in between, and now finds the screen still loading.

On the Activity question: `createComposeRule` does host your content in an activity. The [setup guide](https://developer.android.com/develop/ui/compose/testing) says the rule lets you "set Compose content or access the activity", and it marks the `ui-test-manifest` dependency as needed for `createComposeRule()` and not for `createAndroidComposeRule`. Use `createAndroidComposeRule<YourActivity>()` when the test needs your own.

```kotlin
// build.gradle.kts
// androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Needed for createComposeRule(), not for createAndroidComposeRule<A>():
// debugImplementation("androidx.compose.ui:ui-test-manifest")

// v1, deprecated
// import androidx.compose.ui.test.junit4.createComposeRule

// v2: same name, new package
import androidx.compose.ui.test.junit4.v2.createComposeRule

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun continue_showsWelcome() {
        composeTestRule.setContent {
            MyAppTheme { MainScreen(uiState = fakeUiState) }
        }

        composeTestRule.onNodeWithText("Continue").performClick()
        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}
```

## Finders, Actions and Waiting

A Compose test is three verbs: find a node, act on it, assert on it. Prefer finders that match what the user perceives, `onNodeWithText` and `onNodeWithContentDescription`, and keep `onNodeWithTag` for nodes that have neither. The [cheat sheet](https://developer.android.com/develop/ui/compose/testing/testing-cheatsheet) lists the rest. A screen that is hard to query this way is usually hard to use with a screen reader, so the test is telling you something about [accessibility](https://android-x.com/blog/jetpack-compose-accessibility).

Synchronization covers Compose. It does not cover work Compose cannot see. The guide draws the line: "Any condition that depends on external work, such as data loading or Android's measure or draw (that is, measure or draw external to Compose), should use a more general concept such as waitUntil()". There are helpers for the common waits, such as `waitUntilExactlyOneExists`.

The earlier version said `waitUntil` replaces Espresso's idling resources. It does not. Compose has its own idling resource API for background work the test should account for, which the guide describes as "very similar to Espresso's Idling Resources". Use `waitUntil` for a condition and an idling resource for a subsystem.

Two smaller corrections to the old sample. It asserted with Kotlin's `assert()`, which throws only when "runtime assertions have been enabled on the JVM using the -ea JVM option" ([Kotlin](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/assert.html)). Use `assertTrue`. And its FAQ recommended a function called `awaitUntil()`, which does not exist. The function is `waitUntil()`.

```kotlin
class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun validLogin_callsOnLoginSuccess() {
        var loggedIn = false

        composeTestRule.setContent {
            LoginScreen(onLoginSuccess = { loggedIn = true })
        }

        composeTestRule.onNodeWithText("Email")
            .performTextInput("user@example.com")
        composeTestRule.onNodeWithText("Password")
            .performTextInput("secure123")
        composeTestRule.onNodeWithText("Sign In").performClick()

        // The login call is external work: wait for the condition
        composeTestRule.waitUntil(5_000) { loggedIn }

        assertTrue(loggedIn)
    }

    @Test
    fun emptyEmail_showsValidationError() {
        composeTestRule.setContent {
            LoginScreen(onLoginSuccess = {})
        }

        composeTestRule.onNodeWithText("Password")
            .performTextInput("secure123")
        composeTestRule.onNodeWithText("Sign In").performClick()

        composeTestRule.onNodeWithText("Email is required")
            .assertIsDisplayed()
    }

    @Test
    fun results_appearAfterLoading() {
        composeTestRule.setContent { SearchScreen() }

        composeTestRule.onNodeWithText("Search").performClick()

        composeTestRule.waitUntilExactlyOneExists(hasText("3 results"), 5_000)
    }
}
```

## Test the Screen, Not the ViewModel

The cheapest way to test a screen in every state is to make the screen a function of its state. Hoist the state: the screen composable takes a `ProfileState` and callbacks, and a thin wrapper connects it to the ViewModel. Then a test passes `ProfileState.Error("...")` directly. No fake ViewModel, no repository, no coroutine.

The documentation's pattern is the same idea under the heading of isolation: "ComposeTestRule lets you start an activity displaying any composable: your full application, a single screen, or a small element" ([common patterns](https://developer.android.com/develop/ui/compose/testing/common-patterns)).

The earlier version of this section built a `FakeProfileViewModel` that extended `ViewModel` and then never passed it to the screen. The tests only ever used its state. The fake was dead weight, and it has been removed. The ViewModel still gets tested, as a plain unit test with no UI.

```kotlin
sealed interface ProfileState {
    data object Loading : ProfileState
    data class Success(val user: User) : ProfileState
    data class Error(val message: String) : ProfileState
}

// Stateless: everything it shows comes in as a parameter
@Composable
fun ProfileScreen(
    state: ProfileState,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier,
) { /* ... */ }

// Stateful wrapper: the only place that knows the ViewModel
@Composable
fun ProfileRoute(viewModel: ProfileViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    ProfileScreen(state = state, onRetry = viewModel::load)
}

class ProfileScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun success_showsUser() {
        val user = User(id = "1", name = "Rocky", email = "r@test.com")

        composeTestRule.setContent {
            ProfileScreen(state = ProfileState.Success(user), onRetry = {})
        }

        composeTestRule.onNodeWithText("Rocky").assertIsDisplayed()
        composeTestRule.onNodeWithText("r@test.com").assertIsDisplayed()
    }

    @Test
    fun error_offersRetry() {
        var retried = false

        composeTestRule.setContent {
            ProfileScreen(
                state = ProfileState.Error("Network error"),
                onRetry = { retried = true },
            )
        }

        composeTestRule.onNodeWithText("Network error").assertIsDisplayed()
        composeTestRule.onNodeWithText("Retry").performClick()
        assertTrue(retried)
    }
}
```

## Screenshot Testing

Semantic tests cannot see a wrong colour or a clipped label. Screenshot tests can, and Android's guidance has become direct about them: "Screenshot testing is the recommended way to verify visual attributes in Compose UIs" ([Android](https://developer.android.com/training/testing/ui-tests/screenshot)).

There are two routes. The first-party one, "the official Compose Preview Screenshot testing tool", renders your `@Preview` functions and is covered in its own [guide](https://android-x.com/blog/compose-screenshot-testing). The route this section shows is [Roborazzi](https://github.com/takahirom/roborazzi), a third-party library that captures from ordinary Compose tests on the JVM through Robolectric. It fits when you want a screenshot at a specific point in an interaction, not only of a static preview.

The versions in the earlier sample were stale. Roborazzi is at 1.76.0 (29 September 2026) and Robolectric at 4.17 (10 September 2026). The requirement from the Roborazzi README still stands: "please use Robolectric 4.10 alpha 1 or later and please add @GraphicsMode(GraphicsMode.Mode.NATIVE) to your test class."

Three Gradle tasks do the work. `recordRoborazziDebug` writes the reference images. `verifyRoborazziDebug` is the CI gate: "If there is any difference between the current image and the saved one, the test will fail." `compareRoborazziDebug` produces the diff images for review without failing.

The old sample looped over light and dark and called `setContent` twice inside one test. The version below is one test per theme, which also gives each image its own pass or fail.

```kotlin
// build.gradle.kts (module)
plugins {
    id("io.github.takahirom.roborazzi")
}

dependencies {
    testImplementation("io.github.takahirom.roborazzi:roborazzi:1.76.0")
    testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.76.0")
    testImplementation("org.robolectric:robolectric:4.17")
}

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ProfileCardScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private val user = User(id = "1", name = "Rocky", email = "r@test.com")

    @Test
    fun profileCard_light() {
        composeTestRule.setContent {
            AppTheme(darkTheme = false) { ProfileCard(user = user) }
        }

        composeTestRule.onNodeWithTag("profile_card")
            .captureRoboImage("screenshots/profile_card_light.png")
    }

    @Test
    fun profileCard_dark() {
        composeTestRule.setContent {
            AppTheme(darkTheme = true) { ProfileCard(user = user) }
        }

        composeTestRule.onNodeWithTag("profile_card")
            .captureRoboImage("screenshots/profile_card_dark.png")
    }
}

// ./gradlew recordRoborazziDebug    record reference images
// ./gradlew verifyRoborazziDebug    fail on any difference (CI)
// ./gradlew compareRoborazziDebug   write diff images for review
```

## The Testing Pyramid for Compose Apps

The earlier version of this section gave a 70/20/10 split between unit, UI and screenshot tests. Those numbers had no source and have been removed. Android's guidance gives a shape, not percentages: "Most apps should have many small tests and relatively few big tests" ([testing strategies](https://developer.android.com/training/testing/fundamentals/strategies)). The reason is a trade it states plainly. Small tests are "fast and reliable". Big tests "have more fidelity" and "require more complex setups that are difficult to maintain".

Applied to a Compose app:

| Layer | What it covers | Runs |
|---|---|---|
| Unit tests | ViewModels, reducers, mappers, validation | JVM, every commit |
| Screen tests | One stateless screen in each state, with its interactions | JVM or device, every pull request |
| Screenshot tests | Design system components and key screens, light and dark | JVM, every pull request |
| End-to-end | A handful of journeys through the real app: sign in, purchase | Device, before release |

Push logic down. A rule about what a valid email is belongs in a unit test, and where the input space is large, in a [property-based test](https://android-x.com/blog/property-based-testing-kotlin). A screen test should check that the error is shown, not re-derive when it should be.

Then wire the first three layers into [CI](https://android-x.com/blog/github-actions-android-cicd) so they run without anyone remembering to.

## Key takeaways

- Compose tests query the semantics tree, the same structure accessibility services read.
- The v1 test entry points are deprecated. The v2 versions live in androidx.compose.ui.test.junit4.v2 and use StandardTestDispatcher.
- Tests are synchronized with Compose automatically. Use waitUntil for work Compose cannot see.
- Make screens stateless and test them by passing state. A fake ViewModel is rarely needed.
- Use screenshot tests for visual attributes: the official preview tool, or Roborazzi for captures inside a test.
- Android recommends many small tests and few big ones. It gives no percentages, and neither should a strategy document.

## Frequently asked questions

### How much Compose UI testing is enough?

Enough that each screen is exercised in every state it can show, and each design system component has a screenshot. Keep logic in unit tests, where it is cheap to cover. Reserve end-to-end tests for a handful of journeys. Android guidance is many small tests and relatively few big ones.

### How do I test asynchronous work in a Compose test?

Recomposition and animation are synchronized for you: an action or assertion waits until Compose is idle. For work outside Compose, such as a data load, use waitUntil with a condition or a helper like waitUntilExactlyOneExists. Never sleep. Under the v2 APIs coroutines are queued, so assert through the test rule.

### Do I need to migrate to the Compose v2 testing APIs?

Yes, in time. The documentation calls the v1 entry points deprecated and strongly recommends migrating. The change is an import, to the v2 package, plus fixes for tests that assumed coroutines run eagerly. Most tests that act and assert through the test rule keep passing without edits.

## Related articles

- [Your UI Tests Pass While Your UI Breaks: Screenshot Testing for Compose](https://android-x.com/blog/compose-screenshot-testing.md)
- [Property-Based Testing in Kotlin: Beyond Unit Tests](https://android-x.com/blog/property-based-testing-kotlin.md)
- [Accessibility in Jetpack Compose: Building Apps Everyone Can Use](https://android-x.com/blog/jetpack-compose-accessibility.md)
