7 min read

Compose UI Tests: Synchronisation, Not Sleeping

Every Thread.sleep in a UI test is a flake you haven't seen yet plus time you pay on every CI run. The three real causes of Compose test flakiness — invisible work, infinite animations, interop — and the synchronisation primitives that replace guessing.

Jetpack ComposeTestingAndroidCoroutinesCI
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — The Compose test rule already waits for composition to be idle before each assertion, so a sleeping test isn't fixing a timing problem — it's betting that CI won't be slower next week. Flakiness has three real causes: work Compose can't see (coroutines on dispatchers you didn't inject), infinite animations (the clock is never idle, so waitForIdle times out), and Espresso/Compose interop. The replacements: injected TestDispatchers, mainClock.autoAdvance = false, and waitUntil { } with a condition instead of a duration. Never assert on time; assert on state.

What the test rule already does for you

createComposeRule() isn't a passive host. Before every assertion and after every action it waits for the composition to become idle: pending recompositions applied, pending animations settled, the snapshot system quiet.

That's why a naive Compose test often passes without any explicit waiting, and it's why reaching for Thread.sleep should immediately raise the question "what is happening that the rule can't see?" — because the answer is always specific, and always fixable.

Cause 1: work Compose can't see

This is the overwhelming majority of real cases.

@Composable
fun OrderScreen(viewModel: OrderViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    // ...
}

class OrderViewModel(private val repo: OrderRepo) : ViewModel() {
    val state = repo.observeOrder()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Loading)
}

If repo does its work on a hard-coded Dispatchers.IO, the test framework has no handle on it. Compose is genuinely idle — it has rendered everything it was given — while your data layer is mid-flight on a thread pool nobody is tracking. The assertion runs, finds a loading spinner, and fails. Add a sleep and it passes on your laptop and fails on a loaded CI agent.

The fix is architectural and pays for itself many times over: inject your dispatchers.

class OrderRepo(
    private val api: OrderApi,
    private val io: CoroutineDispatcher,   // injected, not Dispatchers.IO
)

// test
private val dispatcher = StandardTestDispatcher()

@Test
fun showsDeliveredState() = runTest(dispatcher) {
    composeRule.setContent { OrderScreen(viewModel) }

    advanceUntilIdle()          // the test controls the clock now

    composeRule.onNodeWithText("Delivered").assertIsDisplayed()
}

Now "wait for the work to finish" is a deterministic operation rather than a hope. Almost every long thread titled "flaky Compose test" ends at a hard-coded dispatcher.

Note the pairing: StandardTestDispatcher queues work until you advance it, which makes ordering explicit. UnconfinedTestDispatcher runs eagerly, which is convenient for simple cases and hides ordering bugs. Prefer the standard one and be explicit.

Cause 2: the infinite animation

val transition = rememberInfiniteTransition()
val alpha by transition.animateFloat(
    initialValue = 0.3f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
)

A pulsing skeleton loader, a spinner, a shimmer. The composition never becomes idle, because there is always another frame pending. waitForIdle() therefore waits until the timeout, and the failure message points at your assertion rather than at the animation, which is why this one costs people an afternoon.

Take manual control of the clock:

@Test
fun showsContentAfterLoading() {
    composeRule.mainClock.autoAdvance = false

    composeRule.setContent { OrderScreen(viewModel) }

    composeRule.mainClock.advanceTimeBy(500)     // let the shimmer run a bit
    composeRule.onNodeWithTag("skeleton").assertIsDisplayed()

    viewModel.emitLoaded()
    composeRule.mainClock.advanceTimeBy(1_000)
    composeRule.onNodeWithText("Delivered").assertIsDisplayed()
}

autoAdvance = false turns time into something you step through deliberately, which also makes animation assertions possible at all — you can check the intermediate frame instead of only the end state.

Cause 3: Espresso and Compose interop

In a hybrid screen you have two idling systems: Espresso's IdlingResource machinery and Compose's clock. They mostly cooperate, but a custom IdlingResource that never goes idle will hang the Compose side too, and the resulting timeout blames whichever assertion happened to be next.

If you have hand-written idling resources from the View era, audit them when you start seeing Compose timeouts — they're often the real culprit and rarely the first suspect.

The replacement for sleeping: waitUntil

For genuinely external asynchrony you can't inject — a real network call in an end-to-end test, a system dialog, a third-party SDK callback:

composeRule.waitUntil(timeoutMillis = 5_000) {
    composeRule
        .onAllNodesWithText("Delivered")
        .fetchSemanticsNodes()
        .isNotEmpty()
}

The semantic difference matters more than the API:

  • Thread.sleep(500) means "I hope 500ms is enough on the slowest agent in the fleet, forever." It is simultaneously a flake and a 500ms tax on every run, and it never gets revisited.
  • waitUntil { … } means "continue the moment this is true; fail if it never becomes true." It's fast on a fast machine, patient on a slow one, and when it fails it fails for a stated reason.

Newer Compose test versions ship convenience wrappers — waitUntilExactlyOneExists, waitUntilAtLeastOneExists — which read better and do the same thing.

Note onAllNodesWithText rather than onNodeWithText: the plural form doesn't throw when nothing matches, which is exactly what you need inside a polling predicate.

Query through semantics, not internals

// good — asserts what a user (and a screen reader) perceives
composeRule.onNodeWithText("Delivered").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Order status").assertExists()

// last resort
composeRule.onNodeWithTag("order_status_text").assertExists()

Semantics-based queries are more robust across refactors, and they double as accessibility pressure: if there's nothing meaningful to query, a screen reader user has nothing to hear either. testTag is the escape hatch for when there is genuinely no user-visible handle — a decorative container, a layout wrapper — not the default way to find things.

The debugging tool you should reach for first

composeRule.onRoot().printToLog("TREE")

That dumps the entire semantics tree — every node, its text, its role, its state. Thirty seconds reading it beats twenty minutes of adding assertions to guess which node didn't appear, and it usually reveals the real answer: the node exists but isn't displayed, or there are two of them, or the text has a trailing space from a string resource.

For the "is it visible or merely present" distinction, assertIsDisplayed() and assertExists() are different assertions and the gap between them is a common source of confusion.

The rule

A flaky test isn't a slow test — it's a test that doesn't know what it's waiting for. Inject your dispatchers so the test owns the clock, take manual control when animations never settle, and replace every duration with a condition. If a test contains a number of milliseconds, it contains a guess, and guesses fail on the day CI is busy.

Keep reading