Why Compose Changes the Testing Game
Which Compose test APIs are current?
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.// 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
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().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
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.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
@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.// 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 reviewThe Testing Pyramid for Compose Apps
| 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 |
Key Takeaways
- 1Compose tests query the semantics tree, the same structure accessibility services read.
- 2The v1 test entry points are deprecated. The v2 versions live in androidx.compose.ui.test.junit4.v2 and use StandardTestDispatcher.
- 3Tests are synchronized with Compose automatically. Use waitUntil for work Compose cannot see.
- 4Make screens stateless and test them by passing state. A fake ViewModel is rarely needed.
- 5Use screenshot tests for visual attributes: the official preview tool, or Roborazzi for captures inside a test.
- 6Android recommends many small tests and few big ones. It gives no percentages, and neither should a strategy document.
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.
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.