Find, act, assert — and the rule that decides which test to write at all
Compose testing is finders, actions and assertions over the semantics tree. The API is small; the judgment is choosing between a unit test, a Compose test with a fake, and a full instrumented test.

Day 85 of 100. The API surface takes about ten minutes to learn. Deciding what to test with it is the part worth writing about.
The symptom
A test suite where everything is an instrumented UI test:
@Test fun totalIsFormattedForGermanLocale() {
composeRule.setContent { AppTheme { CheckoutScreen(viewModel) } }
composeRule.onNodeWithText("1.234,56 €").assertExists()
}
Currency formatting, validation rules, error mapping, retry logic — all verified by launching a screen on a device. The suite takes eleven minutes, fails intermittently, and when it does fail the message says "no node with text …" rather than what actually broke.
Why the obvious approach fails
The obvious approach is to test through the UI because that's where the behaviour is visible.
It's visible there and it doesn't live there. Day 16's layering put formatting in the state holder, which means a JVM test can verify it in three milliseconds with an assertion that names the actual expectation:
@Test fun formatsGermanCurrency() {
assertEquals("1.234,56 €", formatter.format(Money(123456), Locale.GERMANY))
}
Same coverage, a thousand times faster, and a failure message you can act on.

The actual mechanism
The API is three verbs, and they chain:
Finders produce a node or a set of nodes.
onNode(matcher) onAllNodes(matcher)
onNodeWithText("Submit") onAllNodesWithText("Pending")
onNodeWithContentDescription(…) onNodeWithTag(…)
onRoot()
Actions do something to a node.
performClick()
performTextInput("hello") performTextClearance()
performScrollTo() performScrollToIndex(50)
performTouchInput { swipeLeft() }
performSemanticsAction(SemanticsActions.OnLongClick)
performKeyInput { pressKey(Key.Tab) }
performImeAction()
Assertions check it.
assertExists() assertDoesNotExist()
assertIsDisplayed() assertIsNotDisplayed()
assertIsEnabled() assertIsOn() assertIsSelected()
assertTextEquals("…") assertContentDescriptionEquals("…")
assertCountEquals(3)
assertHeightIsEqualTo(48.dp)
performTouchInput is the one worth knowing has depth — it takes a scope with swipeLeft,
swipeUp, pinch, longClick, down/moveTo/up, and a durationMillis that
controls velocity. Day 73's flick test depended on that last parameter.
The three tiers
The judgment the API can't make for you:
Tier 1 — JVM unit tests. Anything in a state holder or below: formatting, validation, state transitions, mapping, business rules. Milliseconds, no device, no Compose runtime. This should be most of your tests.
Tier 2 — Compose UI tests with fakes. One screen or component, real composables, a fake state holder. Verifies that the UI behaves: the right thing renders for each state, a tap calls the right callback, an error state shows a retry.
@Test fun errorStateOffersRetry() = runComposeUiTest {
var retried = false
setContent {
AppTheme { CheckoutContent(state = Failed("Network"), onRetry = { retried = true }) }
}
onNodeWithText("Network").assertIsDisplayed()
onNodeWithText("Try again").performClick()
assertTrue(retried)
}
Note the composable takes state and callbacks — Day 16's stateless content composable is what makes this test trivial. A screen wired to a real ViewModel needs a whole graph.
Tier 3 — instrumented end-to-end. Real navigation, real (or near-real) data, a device. Few of these: the critical journeys — sign in, checkout, the thing that must never break.
The failure mode at the top of this post is doing tier-1 work in tier 3. The opposite failure — no tier-2 tests at all — leaves the UI layer untested between fast unit tests and slow journey tests.
runComposeUiTest and where it runs
@Test fun example() = runComposeUiTest {
setContent { AppTheme { OrderCard(sample) } }
onNodeWithText("Submit").performClick()
}
runComposeUiTest is the multiplatform entry point and the one to prefer for new tests.
createComposeRule() is the older JUnit-rule form and still common;
createAndroidComposeRule<MainActivity>() is what you need when the test requires a real
Activity — for Intent handling, permissions, or activity recreation.
The important practical point: tier-2 tests can run on Robolectric rather than a device, which takes them from seconds to milliseconds and lets them run in ordinary CI without an emulator. For a suite of a hundred component tests that's the difference between running them on every commit and not.
Testing what a callback received
A pattern worth having, because it's most of what a tier-2 test asserts:
@Test fun tappingRowPassesTheId() = runComposeUiTest {
val clicked = mutableListOf<String>()
setContent { OrderList(orders = sample, onSelect = { clicked += it }) }
onNodeWithText("Order #4711").performClick()
assertEquals(listOf("4711"), clicked)
}
Recording into a list rather than a boolean catches the double-invocation bugs — a click
handler wired twice, or Day 71's captured-value bug sending the wrong id. assertEquals
on the list says both that it fired and what it fired with.
The state-driven test
For a component with several states, a parameterised test covers them without repetition:
@Test fun everyStateRenders() = runComposeUiTest {
listOf(Loading, Ready(sample), Failed("oops"), Empty).forEach { state ->
setContent { AppTheme { OrderScreen(state) } }
onNodeWithTag("order-screen").assertExists()
}
}
Thin as an assertion, and it catches the crash-on-empty-state class of bug that reaches production more often than it should — usually because the empty state was added late and nobody navigated to it.
Combining this with screenshot tests (Day 59) gives both "it doesn't crash" and "it looks right" from one list of states.
How to prove it
Time the suite. A tier-1-heavy suite runs in seconds; a tier-3-heavy one runs in minutes and gets skipped locally, which is how it rots.
Then check the failure messages. Pick a random failing test and ask whether the message tells you what broke. "Expected 1.234,56 € but was 1,234.56 €" is a diagnosis; "no node with text 1.234,56 € found" is a starting point for an investigation.
If most failures are the second kind, behaviour is being tested a tier too high.
Fakes over mocks
One convention that makes tier 2 pleasant. A hand-written fake state holder is usually better than a mocking framework:
class FakeOrderViewModel(initial: OrderUiState) : OrderViewModel {
private val _state = MutableStateFlow(initial)
override val uiState: StateFlow<OrderUiState> = _state
val submitted = mutableListOf<Order>()
override fun submit(order: Order) { submitted += order }
}
Twelve lines, readable, and it records what happened so assertions can be about behaviour rather than about call counts. It also compiles — a renamed method breaks the fake at build time, where a mock configured by string or reflection breaks at runtime.
The prerequisite is that the screen depends on an interface rather than a concrete ViewModel, which is Day 48's containment argument arriving in the test suite.
What this generalizes to
The principle is test at the level the behaviour lives. The pyramid is old advice, and Compose adds a specific reason to follow it: a UI test asserts on rendered output, which is a projection of the logic, and a projection loses the detail that makes a failure diagnosable.
Day 16's layering is what makes the choice available. A screen with formatting inlined in the composable cannot be tested at tier 1 — the architecture decides the testing options, which is the strongest practical argument for the layering that pillar described.
It runs the other way too, which is the useful diagnostic. When a behaviour is awkward to test at the tier it belongs in, that's usually a signal about where the code lives rather than about the test framework. "This needs a device to verify" is worth treating as a question about the architecture before it's treated as a fact about the test.
Tomorrow, Day 86: synchronisation — idling, the test clock, and why the flaky test is almost always a timing assumption.
Day 85 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Compose testing APIs.