Testing Compose
Compose testing runs on semantics nodes — an abstract tree of what your UI means, not what it looks like. This is how screen readers see your app too, so good tests double as accessibility validation.
Dependencies
// build.gradle.kts (for the module under test)
dependencies {
// Core
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// UI tests (instrumentation)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Unit tests (Robolectric + Compose)
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("org.robolectric:robolectric:4.13")
// Screenshot testing
testImplementation("app.cash.paparazzi:paparazzi:1.3.5")
// OR
testImplementation("com.github.takahirom.roborazzi:roborazzi:1.30.1")
}
The three test rule variants
1. createComposeRule — pure Compose
class CounterTest {
@get:Rule val composeRule = createComposeRule()
@Test fun increments_on_click() {
composeRule.setContent { Counter() }
composeRule.onNodeWithText("Count: 0").assertIsDisplayed()
composeRule.onNodeWithText("Increment").performClick()
composeRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
}
2. createAndroidComposeRule<MyActivity> — hosted in an Activity
class ProfileScreenTest {
@get:Rule val composeRule = createAndroidComposeRule<MainActivity>()
@Test fun showsUserName() {
composeRule.onNodeWithText("Aarav").assertIsDisplayed()
}
}
Required when your Composable depends on LocalContext.current (most
real screens do) or Activity-scoped Hilt components.
3. createEmptyComposeRule — full control
class CustomHostTest {
@get:Rule val composeRule = createEmptyComposeRule()
@Test fun custom() {
ActivityScenario.launch(TestActivity::class.java).use {
composeRule.setContent { MyScreen() }
composeRule.onNodeWithText("Hello").assertIsDisplayed()
}
}
}
Finders — locating composables
composeRule.onNodeWithText("Submit") // by visible text
composeRule.onNodeWithContentDescription("Close button") // by a11y description
composeRule.onNodeWithTag("submit_btn") // by testTag
composeRule.onAllNodesWithText("Aarav") // multiple
composeRule.onNodeWithText("Sign in", ignoreCase = true) // case-insensitive
composeRule.onNodeWithText("Email", substring = true) // partial match
testTag — stable selector
Button(
onClick = { /* ... */ },
modifier = Modifier.testTag("submit_btn")
) { Text("Submit") }
// Test
composeRule.onNodeWithTag("submit_btn").performClick()
Matchers — complex selectors
composeRule.onNode(
hasText("Submit") and isEnabled() and hasClickAction()
)
composeRule.onNode(
hasAnyAncestor(hasTestTag("form")) and hasText("Email")
)
Built-in matchers: hasText, hasContentDescription, hasTestTag,
hasClickAction, isEnabled, isFocused, isSelected, isOn,
isToggleable, hasScrollAction, hasSetTextAction, hasProgressBarRangeInfo,
hasImeAction, and many more.
Assertions
// Presence
.assertExists() // in the tree (may be off-screen)
.assertDoesNotExist()
.assertIsDisplayed() // in the tree AND visible
.assertIsNotDisplayed()
// State
.assertIsEnabled() / .assertIsNotEnabled()
.assertIsFocused() / .assertIsNotFocused()
.assertIsSelected()
.assertIsOn() / .assertIsOff() // toggles
.assertIsSelectable()
// Text
.assertTextEquals("Hello")
.assertTextContains("ello")
// Semantics
.assertContentDescriptionEquals("Close")
.assertLabelEquals("Email")
// Count
composeRule.onAllNodesWithText("Item").assertCountEquals(5)
Actions
.performClick()
.performLongClick()
.performDoubleClick()
.performTextInput("hello") // append
.performTextClearance()
.performTextReplacement("new text")
.performScrollTo() // scroll parent until visible
.performScrollToIndex(10) // in LazyColumn
.performScrollToKey("item-123")
.performScrollToNode(hasText("Target"))
.performKeyPress(KeyEvent(NativeKeyEvent(ACTION_DOWN, KEYCODE_ENTER)))
.performImeAction() // trigger keyboard Done/Next/...
.performSemanticsAction(SemanticsActions.SetProgress) { it(0.5f) }
Gesture actions
.performTouchInput {
down(Offset(100f, 100f))
moveBy(Offset(50f, 0f))
up()
}
.performTouchInput {
swipeLeft()
swipeUp(startY = visibleSize.height.toFloat(), endY = 0f)
pinch(
start0 = center,
end0 = Offset(100f, 100f),
start1 = center,
end1 = Offset(200f, 200f)
)
}
Synchronization
By default Compose tests auto-wait for idle (no pending recompositions or animations). Override when needed:
// Pause auto-advance — useful when testing timing or animations step-by-step
composeRule.mainClock.autoAdvance = false
composeRule.mainClock.advanceTimeBy(500)
composeRule.mainClock.advanceTimeByFrame() // next vsync
// Wait for condition
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Loaded").fetchSemanticsNodes().isNotEmpty()
}
// Wait for all animations to finish
composeRule.waitForIdle()
Testing state — exposing it deterministically
Inject the source of truth
@Test fun loadsUser() {
val repo = FakeUserRepository(users = listOf(User("u1", "Aarav", "a@x.com")))
composeRule.setContent {
AppTheme {
ProfileScreen(viewModel = ProfileViewModel(repo, SavedStateHandle(mapOf("userId" to "u1"))))
}
}
composeRule.onNodeWithText("Aarav").assertIsDisplayed()
}
With Hilt
@HiltAndroidTest
class ProfileScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeRule = createAndroidComposeRule<HiltTestActivity>()
@BindValue @JvmField val repo: UserRepository = FakeUserRepository()
@Before fun setup() { hiltRule.inject() }
@Test fun shows_user() {
repo.stub(User("u1", "Aarav", "a@x.com"))
composeRule.setContent { ProfileScreen() }
composeRule.onNodeWithText("Aarav").assertIsDisplayed()
}
}
@BindValue + HiltAndroidTest replaces the Hilt module's UserRepository
with the test fake.
Semantics tree dump — debugging
composeRule.onRoot().printToLog("TEST_TREE")
Output:
Node #1 at (0..1080, 0..2220)px
|-Node #2 at (40..1040, 100..200)px, Tag: 'email_field'
| Focused = 'true'
| Text = '[aarav]'
| Actions = [SetText, ...]
|-Node #3 at (40..1040, 260..360)px
Text = '[Invalid email]'
Great for debugging "why isn't my node found?" — you see exactly what the tree looks like.
Custom matchers
fun hasProgress(progress: Float): SemanticsMatcher = SemanticsMatcher(
description = "has progress $progress"
) { node ->
val info = node.config.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
info?.current == progress
}
composeRule.onNode(hasProgress(0.5f)).assertExists()
Robolectric + Compose — fast JVM tests
@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "w360dp-h640dp-normal-long-notround-any-300dpi-keyshidden-nonav")
class FastComposeTest {
@get:Rule val composeRule = createComposeRule()
@Test fun renders_fast() {
composeRule.setContent { MyScreen() }
composeRule.onNodeWithText("Hello").assertIsDisplayed()
}
}
Runs on your laptop in < 1s per test, no emulator required. Most
Compose-only tests (no ContentResolver, no system services) work fine on
Robolectric.
Enable in build.gradle.kts:
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
}
}
}
Paparazzi — deterministic snapshot testing
class ButtonSnapshotTest {
@get:Rule val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "android:Theme.Material.Light.NoActionBar"
)
@Test fun primary_button() {
paparazzi.snapshot {
AppTheme {
Button(onClick = {}) { Text("Confirm") }
}
}
}
@Test fun primary_button_dark() {
paparazzi.snapshot {
AppTheme(darkTheme = true) {
Button(onClick = {}) { Text("Confirm") }
}
}
}
@Test fun primary_button_large_font() {
paparazzi.snapshot(
deviceConfig = DeviceConfig.PIXEL_6.copy(fontScale = 2f)
) {
AppTheme {
Button(onClick = {}) { Text("Confirm") }
}
}
}
}
Paparazzi renders via layoutlib directly to PNG — no emulator, no ADB, deterministic on CI. Every PR that changes a composable produces a visual diff in the build output.
Recording vs verifying
./gradlew :core:design:recordPaparazziDebug # update baseline PNGs
./gradlew :core:design:verifyPaparazziDebug # fail on any visual diff
Commit the src/test/snapshots/images/ directory. CI runs verify; a
failing diff means the screenshot changed — intentional or regression.
Roborazzi — Paparazzi alternative
Same idea, but runs on Robolectric (so Compose test rule works too):
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ButtonRoborazziTest {
@get:Rule val composeRule = createComposeRule()
@Test fun primary_button() {
composeRule.setContent {
AppTheme { Button(onClick = {}) { Text("Confirm") } }
}
composeRule.onRoot().captureRoboImage()
}
}
Advantage over Paparazzi: you can drive interactions (click a button, expand a card) before snapshotting — test states, not just static renders.
Multi-preview snapshot pattern
@ThemePreviews // @Preview(Light/Dark/Fontscale 2x/RTL)
@Composable
private fun PrimaryButtonPreviews() {
AppTheme { Button(onClick = {}) { Text("Confirm") } }
}
// Using the compose-preview-screenshot plugin (Android Studio Jellyfish+):
// ./gradlew validateScreenshotTest
Android Studio's Compose Preview Screenshot Testing plugin auto-
generates Paparazzi-style tests from every @Preview function. Set it up
once; visual regression is free thereafter.
Testing animations
composeRule.mainClock.autoAdvance = false
composeRule.setContent { ExpandableCard(content = "Hello") }
// Initial state — collapsed
composeRule.onNodeWithText("Hello").assertIsDisplayed()
val initialHeight = composeRule.onNodeWithTag("card").fetchSemanticsNode().size.height
// Trigger expand
composeRule.onNodeWithTag("card").performClick()
// Frame by frame
composeRule.mainClock.advanceTimeByFrame()
composeRule.mainClock.advanceTimeBy(150) // mid-animation
// Final state
composeRule.mainClock.advanceTimeBy(1_000)
val finalHeight = composeRule.onNodeWithTag("card").fetchSemanticsNode().size.height
assertTrue(finalHeight > initialHeight)
Accessibility testing
@Test fun all_buttons_have_accessible_names() {
composeRule.setContent { ProfileScreen() }
composeRule.onAllNodesWithTag("action")
.assertAll(hasContentDescription() or hasText())
}
@Test fun screen_passes_a11y_audit() {
composeRule.setContent { ProfileScreen() }
composeRule.onRoot().performAccessibilityAudit() // Google A11y Test Framework
}
See Module 19 — Enterprise UX for the full accessibility testing playbook.
Test pyramid for Compose
▲
╱ ╲
╱EE2╲ Small number of Espresso/Compose E2E tests on device
╱─────╲
╱ Inte- ╲ Hilt + Compose tests; real Room with in-memory DB
╱ gration ╲
╱───────────╲
╱ Snapshot ╲ Paparazzi / Roborazzi — every design-system component
╱───────────────╲
╱ Composable ╲ Robolectric + ComposeTestRule; fast, many
│ unit tests │
└───────────────────┘
Aim for:
- 100+ Robolectric + Compose tests per feature
- 20-30 snapshot tests across design-system + critical screens
- 5-10 instrumented E2E tests for money paths (login, checkout)
Common pitfalls
What breaks
- testTag on every node (pollutes prod code)
- Tests dependent on exact pixel values
- Tests that Thread.sleep
- Flaky tests left in the suite
- Testing implementation details (composition count)
- Snapshot tests with timestamps / random values
Solid tests
- Prefer semantic matchers (text, contentDescription)
- Assert behavior (visible, enabled), not pixels
- Use waitUntil and mainClock APIs
- Quarantine + delete flaky tests
- Test observable behavior (state changes, actions)
- Freeze clock + seed RNG for deterministic snapshots
Key takeaways
Practice exercises
- 01
Write a Compose unit test
Test a Counter composable with createComposeRule. Assert initial state and post-click state.
- 02
Hilt + Compose test
Use HiltAndroidRule + @BindValue to swap a FakeUserRepository in a ProfileScreen test.
- 03
Paparazzi snapshots
Add Paparazzi tests for your PrimaryButton in Light, Dark, 2x font, and RTL configurations.
- 04
Animation test
Pause the main clock and assert an ExpandableCard's measured height grows across frames after clicking.
- 05
A11y audit
Add an onRoot().performAccessibilityAudit() check to one screen. Fix any violations.
Next
Continue to Compose Interop for AndroidView, ComposeView, and Fragment/Activity hosting.