Take the clock away and animations become testable
Compose tests idle-wait by default, which makes animated UI either flaky or untested. Disabling autoAdvance gives you frame-level control, turning 'wait and hope' into deterministic assertions at chosen moments.

Day 66 of 100, closing the animation pillar. Animated UI has a reputation for being untestable, and it comes from one default that can be switched off.
The symptom
A test that passes on a fast machine and fails in CI:
@Test fun bannerAppears() = runComposeUiTest {
setContent { NotificationScreen() }
onNodeWithText("Show").performClick()
onNodeWithTag("banner").assertIsDisplayed() // sometimes fails
}
The banner animates in over 300ms. The assertion runs when Compose reports idle — but an
infinite animation elsewhere on the screen means it never reports idle, so the test
times out. Or the banner is mid-fade and assertIsDisplayed sees an alpha the assertion
doesn't like.
The usual response is a Thread.sleep(500), which makes the suite slower and the flake
rarer rather than absent.
Why the obvious fix fails
The obvious fix is to wait for a condition:
composeRule.waitUntil(timeoutMillis = 2000) {
onAllNodesWithTag("banner").fetchSemanticsNodes().isNotEmpty()
}
Better than a sleep, and it still tests "eventually" rather than "correctly". It can't assert that the banner took 300ms rather than 3 seconds, that it entered from the top rather than the side, or that it was still present at 50ms while animating out — which is the assertion Day 64 needed.
And an infinite animation still prevents idle, so any test on a screen with a loading spinner remains at the mercy of timeouts.

The actual mechanism
Compose tests drive a virtual clock. By default it auto-advances until the composition is idle. Turning that off makes time an explicit parameter:
@Test fun bannerAnimatesIn() = runComposeUiTest {
mainClock.autoAdvance = false
var visible by mutableStateOf(false)
setContent {
AnimatedVisibility(visible, enter = fadeIn(tween(300))) {
Banner(Modifier.testTag("banner"))
}
}
visible = true
mainClock.advanceTimeByFrame() // let the change compose
onNodeWithTag("banner").assertExists() // present from the first frame
mainClock.advanceTimeBy(150)
// …assert the mid-animation state
mainClock.advanceTimeBy(200)
onNodeWithTag("banner").assertIsDisplayed() // settled
}
Three calls do the work:
advanceTimeBy(ms)— move the clock forward a specific amount.advanceTimeByFrame()— exactly one frame, for "let the recomposition happen".autoAdvance = true— hand control back, for the parts of a test that don't care.
With the clock stopped, an infinite animation is no longer a problem: it advances only when you say so, and never blocks idle.
Asserting on animated values
Assertions about appearance usually go through semantics or a screenshot. For a numeric animated value, exposing it to the test is the honest approach:
@Test fun cardExpandsToFullHeight() = runComposeUiTest {
mainClock.autoAdvance = false
setContent { ExpandableCard(expanded = expanded) }
expanded = true
mainClock.advanceTimeBy(1000)
onNodeWithTag("card").assertHeightIsEqualTo(200.dp)
}
assertHeightIsEqualTo, assertWidthIsEqualTo and assertPositionInRootIsEqualTo read
the layout, so they work on the settled state without any instrumentation of your own.
For mid-animation values, a screenshot at a chosen frame is more robust than asserting an exact interpolated number — a spring's value at 150ms depends on its stiffness, and pinning it in a test makes the test fail whenever a designer tunes the spec.
Screenshot tests at a chosen frame
The combination that makes animation regressions catchable:
@Test fun expandMidpoint() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent { AppTheme { ExpandableCard(expanded = true) } }
composeRule.mainClock.advanceTimeBy(150)
captureRoboImage("card_expand_150ms.png")
}
A golden image at a fixed frame catches "the animation changed" in a way no value
assertion does, and it's deterministic because the clock is. Without autoAdvance = false
the same test would capture whatever frame the machine happened to reach.
What's worth testing
Not everything. Three categories that earn a test:
Presence over time — is the element still composed while exiting, and gone afterwards?
That's Day 64's assertion, and it catches the if-instead-of-AnimatedVisibility bug.
Terminal state — does it settle at the right size, position and content? The most
valuable and the easiest, since it needs only advanceTimeBy past the duration.
Direction and identity — did the shared element match, did the counter roll the right way? Usually a screenshot at a mid-frame.
What isn't worth testing: exact interpolated values, and easing curves. Both are specifications a designer will tune, and a test that pins them turns every design adjustment into a test failure with no bug behind it.
Making the clock a rule
Repeating autoAdvance = false in every test invites forgetting it in the one that
matters. A rule captures it once:
class AnimationTestRule : TestRule {
val compose = createComposeRule()
override fun apply(base: Statement, description: Description) =
compose.apply(object : Statement() {
override fun evaluate() {
compose.mainClock.autoAdvance = false
base.evaluate()
}
}, description)
}
Tests that don't care can turn it back on in their first line, which is a better default than the reverse — an accidental implicit wait is silent, while an accidentally stopped clock fails loudly and immediately.
The same reasoning applies to disabling system animations on the test device. Instrumented
tests should run with window_animation_scale and friends at zero, since those scales are
outside the virtual clock's control and are a genuine source of CI-only flake.
The infinite-animation trap in ordinary tests
Worth knowing even if you never test an animation: a screen with rememberInfiniteTransition
— a shimmer, a pulsing dot — never goes idle. Every waitForIdle, every implicit wait
before an assertion, and every Espresso interaction on that screen will time out.
The fix in an ordinary test is the same switch:
composeRule.mainClock.autoAdvance = false
That single line is why this post is worth reading even for a team that doesn't test motion. It's the answer to "why does this one screen's tests always time out", and the cause is rarely obvious from the failure message.
How to prove it
Take an existing flaky animation test, add autoAdvance = false and explicit
advanceTimeBy calls, then run it two hundred times:
./gradlew connectedCheck --tests "*.BannerTest" -Pandroid.testInstrumentationRunnerArguments.iterations=200
A timing-dependent test fails somewhere in that run. A clock-controlled one doesn't, because there is no timing left to depend on.
There's a cheaper signal too: a suite that gets slower as animated screens are added is a suite full of implicit waits. Total runtime is a reasonable proxy for how much of it is waiting rather than asserting.
What this generalizes to
The animation pillar's closing point: determinism comes from controlling the input, and time is an input. A test that waits is a test whose result depends on the machine; a test that advances a clock is a test about behaviour.
Six days of animation reduce to a small set of ideas — declare the target rather than the path, group what changes together, let a component own the lifecycle of things that disappear, match identity rather than move things, and take the clock away when you want to check any of it. The APIs are many; the ideas are few.
That ratio is worth noticing across the whole series so far. Nearly every pillar has had one or two load-bearing ideas and a wide API surface expressing them — and the surface stops being intimidating once the idea underneath is clear.
Tomorrow, Day 67 opens the accessibility pillar.
Day 66 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Test animations.