Testing Animations and Time in Jetpack Compose

Quick answer: Compose tests use a virtual
mainClock. Leave its defaultautoAdvance = truewhen you only need the final behavior. SetautoAdvance = falseonly when an intermediate animation frame matters, trigger the state change, advance the clock by frames or a duration, wait for draw work when capturing pixels, then assert the current UI. Never useThread.sleep()to test a Compose animation.
Compose can make motion tests deterministic because it controls animation time rather than waiting for wall-clock milliseconds. The official animation-testing guide exposes this through ComposeTestRule.mainClock, letting a test inspect an in-between animation state or run to completion without taking the animation’s real duration.
Decide whether time control is necessary
Most UI tests do not need a manual clock. An action or assertion through ComposeTestRule synchronizes with Compose by default, and automatic clock advancement lets the UI reach idle. Use that mode when the only meaningful contract is the result after an animation finishes.
| Requirement | Clock strategy | What to test |
|---|---|---|
| A save action eventually shows its confirmed state | Keep auto-advance enabled | Visible final state and semantics. |
| A panel should remain present until its exit finishes | Disable auto-advance | A known intermediate or finished lifecycle state. |
| Motion must be visually correct halfway through | Disable auto-advance | A golden image or measured bounds at a chosen frame. |
| A delayed Compose effect updates UI | Advance the Compose clock | The new Compose state after the known delay. |
| Network, system, or other external work completes | Use a bounded wait or idling resource | The external result, not virtual animation time. |
The synchronization guide explains that Compose actions and assertions synchronize before they run, advancing virtual time as needed. This is why a plain click-and-assert test is usually better than taking manual control.
Test the final behavior with the default clock
This test does not care how the button color moves. It cares that the user ends up with a confirmed result. Leave autoAdvance alone.
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class SaveButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun save_reachesConfirmedState() {
composeTestRule.setContent {
SaveButtonDemo()
}
composeTestRule.onNodeWithText("Save").performClick()
composeTestRule.onNodeWithText("Saved").assertIsDisplayed()
}
}The test remains valid if the animation changes from a tween to a spring, or disappears because reduced motion is enabled. It protects the product outcome rather than an implementation’s timing. The animation itself belongs in a separate, focused test only if intermediate motion is a requirement.
Freeze automatic time for an intermediate frame
Set mainClock.autoAdvance = false before mounting or triggering the UI under test. Then advance time deliberately. The following illustrative test captures a fade at a chosen point; assertAgainstGolden is a project-provided image comparison helper, not a Compose API.
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
class FadeAnimationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun fade_hasExpectedMidpointAppearance() {
composeTestRule.mainClock.autoAdvance = false
val enabled = mutableStateOf(false)
composeTestRule.setContent {
val alpha by animateFloatAsState(
targetValue = if (enabled.value) 1f else 0f,
animationSpec = tween(durationMillis = 240),
label = "status alpha",
)
Box(
Modifier
.size(64.dp)
.graphicsLayer { this.alpha = alpha }
.background(Color.Cyan),
)
}
enabled.value = true
// First frame commits the new target; later frames advance the animation.
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(120)
composeTestRule.waitForIdle()
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("fade_midpoint")
}
}The first frame after a state change gives Compose a chance to recompose and schedule the animation. The animation receives its initial play time on a later frame. Android’s MainTestClock reference documents this ordering in detail, including the reason a one-frame advance is useful before making a timing-sensitive assertion.
advanceTimeBy() also rounds its requested duration up to a frame-duration multiple. Do not treat a request for exactly 120 ms as a guarantee that the rendered frame has exactly 120 ms of play time. Test a stable visual checkpoint or range, not an overly precise implementation detail.
Wait for drawing before image assertions
MainTestClock drives recomposition, animations, and gestures. It does not control Android measure or draw passes. After manually advancing the clock, call waitForIdle() before captureToImage() or another rendering-sensitive assertion so pending layout and draw work is complete.
composeTestRule.mainClock.advanceTimeBy(160)
composeTestRule.waitForIdle()
val image = composeTestRule.onRoot().captureToImage()Pair this with a real image comparison workflow when pixels are the contract. Screenshot Testing for Jetpack Compose covers maintaining and reviewing visual baselines. When only semantics matter, prefer a semantic assertion instead of a golden image.
Advance a duration, one frame, or until Compose state changes
Choose the smallest clock operation that communicates the test’s intent.
// One precise frame for a frame-by-frame check.
composeTestRule.mainClock.advanceTimeByFrame()
// A known animation or delay interval.
composeTestRule.mainClock.advanceTimeBy(300)
// A condition driven by Compose state, with a virtual-time timeout.
composeTestRule.mainClock.advanceTimeUntil(timeoutMs = 1_000) {
animationFinished.value
}advanceTimeUntil is only appropriate when the predicate observes state that this clock can advance. For data loading, platform drawing outside Compose, or another external process, use waitUntil or a registered idling resource instead. A CountDownLatch or fixed sleep can deadlock or create flaky tests because neither advances the Compose clock.
Test animation lifecycle, not only pixels
Some transitions have an important semantic or lifecycle contract. For example, AnimatedVisibility retains content long enough to run its exit animation before it removes the content from composition. A focused test can manually advance time to verify that the content is still present during the exit and gone after the transition completes.
Keep the test attached to that user-relevant behavior. Avoid asserting every internal alpha or offset value unless the exact motion is part of a visual specification. AnimatedVisibility in Jetpack Compose explains why the composable must remain in composition to perform a proper exit transition; animate*AsState: Animate Values in Jetpack Compose covers the target-based values that many of these tests drive.
High-fidelity frame loops are an advanced case
Recent Compose test APIs include runWithoutImplicitWait for a narrow optimization: a test that has disabled auto-advance and is manually checking many known, stable frames. It is available from ui-test and ui-test-junit4 1.12.0-alpha03 according to the current Android documentation.
Do not add it to ordinary UI tests. The documented constraints are strict: invoke it on the UI thread, use it for read-only assertions, and perform state-changing actions outside the block. Start with normal clock advancement and assertions; profile only if a genuinely frame-by-frame test needs it.
Common time-testing mistakes
Disabling auto-advance for every test
It makes ordinary assertions stop progressing animations automatically and turns a simple behavior test into clock bookkeeping. Keep the default unless an intermediate frame matters.
Calling Thread.sleep()
Wall-clock sleeping is slow, timing-dependent, and does not express what the UI should look like when the wait ends. Advance the virtual clock for Compose-driven time; use a bounded condition wait for external work.
Capturing immediately after manual advancement
The animation state may have changed while draw work is still pending. Call waitForIdle() before a bitmap or layout-sensitive check.
Treating an exact millisecond as an exact rendered frame
advanceTimeBy aligns to the test clock’s frame duration, and the first frame schedules a newly triggered animation. Assert meaningful checkpoints rather than fragile raw timing assumptions.
Animation-test checklist
- Default to auto-advance and assert the final product behavior.
- Disable auto-advance only for a required intermediate frame or lifecycle check.
- Advance one frame after a state change before timing-sensitive animation assertions.
- Use
advanceTimeByoradvanceTimeUntilonly for Compose-clock-driven work. - Call
waitForIdle()before screenshot or rendering-sensitive assertions. - Keep low-level frame-loop optimizations isolated and version-gated.
With the test clock in control, motion tests become fast and repeatable while staying centered on what a user can actually perceive.