Why Your Compose Screenshot Tests Are Flaky (And the Fix Isn't "Add Sleep")
Your Compose screenshot tests pass locally, then flake red on CI for no reason anyone can point at. The instinct is to add a retry, bump a timeout, or slap Thread.sleep into the test rule and move on. That just hides the actual bug: something in the test is non-deterministic, and a screenshot test with any non-determinism in it is not testing anything — it's a coin flip with extra steps.
Here are the four sources of non-determinism that account for almost every flaky Compose screenshot test I've run into, and the actual fix for each — not a retry, a fix.
1. The animation clock is running free
By default, Compose's test clock auto-advances in real time. If your composable kicks off any animation — a ripple, a Crossfade, an AnimatedVisibility, even an implicit one from a state change — the screenshot gets taken at whatever frame the clock happens to be on when the test thread catches up. Locally that's usually frame 0. On a loaded CI runner, it can be mid-transition.
Fix: take manual control of the clock and force it to the end state before capturing.
class DeterministicScreenshotRule(
private val composeRule: ComposeContentTestRule
) : TestRule {
override fun apply(base: Statement, description: Description) = object : Statement() {
override fun evaluate() {
composeRule.mainClock.autoAdvance = false
base.evaluate()
}
}
}
// in the test
composeRule.setContent { ProfileScreen(state = loadedState) }
composeRule.mainClock.advanceTimeBy(10_000) // past any entry animation
composeRule.onRoot().captureToImage()
advanceTimeBy with a large value rather than stepping frame-by-frame is deliberate — you want the animation fully settled, not a mid-transition frame that happens to be reproducible. Reproducible-but-wrong is not the goal.
2. Fonts render differently on CI than on your machine
This is the one that costs the most debugging time because the diff looks like nothing changed — a few pixels off around glyph edges, sometimes just in one corner of the screen. It's font fallback: your CI runner's system image doesn't have the same font as your local emulator or physical device, so text falls back to a different glyph renderer, and every screenshot with text on it gets slightly different anti-aliasing.
Fix: pin a bundled font for screenshot tests specifically, so the test never depends on what's installed on the runner.
val ScreenshotTestFontFamily = FontFamily(
Font(R.font.roboto_regular, FontWeight.Normal),
Font(R.font.roboto_medium, FontWeight.Medium),
Font(R.font.roboto_bold, FontWeight.Bold),
)
@Composable
fun ScreenshotTestTheme(content: @Composable () -> Unit) {
MaterialTheme(
typography = Typography().let { base ->
base.copy(
bodyLarge = base.bodyLarge.copy(fontFamily = ScreenshotTestFontFamily),
titleLarge = base.titleLarge.copy(fontFamily = ScreenshotTestFontFamily),
labelMedium = base.labelMedium.copy(fontFamily = ScreenshotTestFontFamily),
// ...repeat for whichever styles your screens actually use
)
}
) { content() }
}
Wrap every screenshot test's content in ScreenshotTestTheme instead of your app's real theme. It's not a visual downgrade for the test — the bundled font renders identically on every machine, which is the entire point.
3. Pixel-exact diffing punishes anti-aliasing, not real regressions
Even with the clock and fonts pinned, GPU driver differences between CI and local hardware can shift anti-aliasing by a channel value or two on curved edges. A byte-exact ByteArray.contentEquals comparison will flag that as a failure even though no human would ever see a difference.
Fix: diff with a tolerance, not exact equality — count the proportion of pixels that differ beyond a threshold, and fail only past a small percentage.
fun assertImagesMatch(expected: Bitmap, actual: Bitmap, toleranceFraction: Double = 0.01) {
require(expected.width == actual.width && expected.height == actual.height) {
"Size mismatch: expected ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}"
}
var mismatched = 0
val totalPixels = expected.width * expected.height
for (x in 0 until expected.width) {
for (y in 0 until expected.height) {
val e = expected.getPixel(x, y)
val a = actual.getPixel(x, y)
val dr = abs(Color.red(e) - Color.red(a))
val dg = abs(Color.green(e) - Color.green(a))
val db = abs(Color.blue(e) - Color.blue(a))
if (dr > 8 || dg > 8 || db > 8) mismatched++
}
}
val mismatchFraction = mismatched.toDouble() / totalPixels
assertTrue(
"Images differ in ${(mismatchFraction * 100).format(2)}% of pixels (tolerance ${toleranceFraction * 100}%)",
mismatchFraction <= toleranceFraction
)
}
The per-channel threshold of 8 and the 1% overall tolerance aren't universal constants — tune them against your own known-good baselines. The point is that the failure mode changes from "any pixel differs" to "a real visual change happened," which is the thing you actually want to know about.
4. Async state races the composition
The most common one: a screen collects a StateFlow from a ViewModel, the test sets content, and immediately calls captureToImage() — but the first emission from the flow hasn't landed in composition yet, so the screenshot captures the loading state, not the state the test thinks it configured.
Fix: don't screenshot immediately after setContent. Use waitForIdle() (or, better, a synchronization point tied to a testTag that only appears in the final state) before capturing.
composeRule.setContent { ProfileScreen(viewModel = fakeViewModel) }
composeRule.waitUntil(timeoutMillis = 2_000) {
composeRule.onAllNodesWithTag("profile-loaded").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureToImage()
waitForIdle() alone works for simple cases, but a waitUntil gated on a real semantic marker in the loaded UI is worth the extra line — it fails loudly with a clear timeout message instead of silently screenshotting the wrong state.
None of these four fixes are exotic. They're also, in my experience, never all four applied consistently across a real test suite — usually a codebase has the clock one nailed down and still gets bitten by font fallback six months later when a new CI image rolls out. Worth auditing your existing screenshot tests against this list rather than waiting for the next flake to show up.
If you want the fuller version of this — including how these four checks compose into a single reusable JUnit rule, plus the Paparazzi-specific variants of each fix (Paparazzi skips the emulator entirely, which removes GPU-driver flakiness but reintroduces its own font-loading gotchas) — I wrote all of it up in the Android & Compose Testing Playbook.
Top comments (0)