What a Compose test suite should actually contain

A useful Compose suite is mostly unit tests, a layer of state-driven component tests, screenshot tests for visual regressions, and a handful of journeys. The shape matters more than the count.

5 min read
androidcomposekotlintesting

Day 87 — What a Compose test suite should actually contain

Day 87 of 100, closing the testing pillar. Three days of mechanism; today, what to build with it.

The symptom

A suite with high coverage and low confidence:

@Test fun screenRenders() = runComposeUiTest {
    setContent { OrderScreen(sample) }
    onNodeWithTag("order-screen").assertExists()
}

Forty tests of this shape, one per screen. Coverage looks respectable, the suite is green, and a release still breaks the empty state, the error retry and a button that stopped firing.

Every test asserts that a screen exists. None asserts that it works.

Why the obvious approach fails

The obvious approach is to add more tests of the same kind — one per component rather than per screen.

That multiplies an assertion that was never load-bearing. "It rendered without crashing" is worth having exactly once per screen state; repeating it at every level of the tree adds runtime and no information.

The useful question isn't how many tests, it's which failures would a test have caught.

Four patterns, four failure classes: logic, state rendering, visual regression, journeys

The actual mechanism

Four patterns cover most of what goes wrong, and they map to Day 85's tiers.

1. State-driven component tests

The workhorse. For each state a component can be in, assert what the user should see:

@Test fun loadingShowsSkeleton() = runComposeUiTest {
    setContent { AppTheme { OrderContent(state = Loading) } }
    onNodeWithTag("skeleton").assertIsDisplayed()
    onNodeWithText("Try again").assertDoesNotExist()
}

@Test fun failureOffersRetry() = runComposeUiTest {
    var retried = false
    setContent { AppTheme { OrderContent(state = Failed("Network"), onRetry = { retried = true }) } }
    onNodeWithText("Network").assertIsDisplayed()
    onNodeWithText("Try again").performClick()
    assertTrue(retried)
}

Note both assert what shouldn't be there as well as what should. A loading state that also renders a stale retry button passes a positive-only test.

Day 12's sealed state hierarchy is what makes this tractable — a when over four states is four tests, and adding a fifth state makes the omission obvious.

2. Interaction tests that record

Day 85's pattern, stated as a rule: assert on what the callback received, not that it fired.

@Test fun selectingPassesTheRightId() = runComposeUiTest {
    val selected = mutableListOf<String>()
    setContent { OrderList(sample, onSelect = { selected += it }) }

    onNodeWithText("Order #4711").performClick()

    assertEquals(listOf("4711"), selected)
}

The list catches double-firing and the value catches Day 71's captured-id bug. A boolean catches neither.

3. Screenshot tests for the visual layer

The category assertions cannot reach. Shadows matching shapes (Day 59), spacing, alignment, truncation, dark mode:

@Test fun orderRowVariants() {
    listOf(shortOrder, longTitleOrder, overdueOrder).forEach { order ->
        captureRoboImage("order_row_${order.id}.png") {
            AppTheme { OrderRow(order) }
        }
    }
}

Roborazzi or Paparazzi run these on the JVM, so they're fast enough for every commit. The value is entirely in the test data — Day 51's point, and worth repeating because it's the part people get wrong: uniform sample data hides the layout bugs that varied real data exposes.

Pair them with the configurations that break things:

@Preview(fontScale = 2f)
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Preview(locale = "ar")

Three renders that catch clipping, dark-mode contrast and RTL mirroring, none of which any assertion in this pillar detects.

4. A few journeys

Tier 3, deliberately few. Sign in, the primary flow, checkout — the paths where a regression is unacceptable:

@Test fun signInAndPlaceOrder() {
    composeRule.onNodeWithText("Email").performTextInput("user@example.com")
    composeRule.onNodeWithText("Password").performTextInput("hunter2")
    composeRule.onNodeWithText("Sign in").performClick()
    composeRule.waitUntilExactlyOneExists(hasText("Your orders"))
    …
}

These are slow and worth it for a handful of paths. Twenty of them is a suite nobody runs.

The waitUntilExactlyOneExists above is doing real work: it waits for the destination to appear rather than assuming a duration, which is Day 86's rule applied where a waitUntil is legitimately the right tool.

What to skip

Being explicit, because the omissions save more time than the additions:

"It renders" tests, beyond one per screen state. Covered by the state-driven tests above.

Testing Compose itself. That a Button fires onClick, that Text shows its string, that LazyColumn scrolls — the framework's tests cover these.

Exact animation values. Day 66: a designer will tune the spec, and the test becomes a failure with no bug behind it.

Private composables individually. Test the component's public behaviour. Internal structure should be free to change without breaking tests, which is the point of testing the interface.

The suite shape

Roughly, for an app of any size:

Tier Proportion Runs in
Unit tests (logic, state holders) ~70% milliseconds
Component tests (states, interactions) ~20% milliseconds on Robolectric
Screenshot tests ~8% seconds, JVM
Journeys ~2% minutes, device

The exact numbers matter less than the shape: if the bottom two rows dominate, the suite is slow and its failures are hard to diagnose — Day 85's argument.

The test that earns the most

If a codebase could have only one kind of test from this pillar, the state-driven component test is the one. It's fast, it covers the states that break in production, it fails with a readable message, and — because it needs stateless composables taking state and callbacks — writing it pushes the architecture toward Day 16's layering.

That last effect is worth as much as the coverage. A component that's awkward to test is usually a component doing two jobs.

How to prove it

Take your last three production bugs and ask, for each, which test would have caught it. Most teams find the answer is a state-driven component test or a screenshot test, and that the existing suite has neither.

Then check the inverse: run the suite with a deliberate bug introduced — remove a retry button, break a formatter, invert a boolean. A suite that stays green against three injected bugs is measuring something other than correctness.

That exercise has a formal name — mutation testing — and tools exist for it. Doing it by hand three times is usually enough to recalibrate what a suite is actually worth, and it takes ten minutes rather than a tooling project.

What this generalizes to

The testing pillar's conclusion: a test's value is the failure it would catch, and nothing else. Coverage counts lines executed; it says nothing about whether an assertion would have noticed the thing going wrong.

Four days come down to a short list — the tree you query is the tree users perceive, test at the level the behaviour lives, control time rather than waiting for it, and write the tests that correspond to real failure classes. The API is small enough to learn in an afternoon; the judgment is the part that takes a while.

And the judgment compounds with the rest of the series. The suite shape above is only achievable if the architecture allows it — stateless components to render states into, sealed state types to enumerate, injected dispatchers to control. Days 12, 16 and 48 were describing a testable architecture without saying so.

Tomorrow, Day 88 opens the interop pillar — Compose and Views in the same app.


Day 87 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Common testing patterns.