SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:22
UP00:01
MOD · ARTICLE · TESTINGS/N · AX-COMPOSPUBLISHED
All Articles
Testing · Updated 12 MIN

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 · Founder & Principal Consultant, Strategia-X

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). 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). 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 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 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 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 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. 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). 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). 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). 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. The route this section shows is 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). 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:
LayerWhat it coversRuns
Unit testsViewModels, reducers, mappers, validationJVM, every commit
Screen testsOne stateless screen in each state, with its interactionsJVM or device, every pull request
Screenshot testsDesign system components and key screens, light and darkJVM, every pull request
End-to-endA handful of journeys through the real app: sign in, purchaseDevice, 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. 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 so they run without anyone remembering to.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

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

Frequently Asked

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.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA