DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Your Compose Screenshot Tests Never Run at fontScale 1.3 — and That's Where Your Layout Breaks

Spent a while debugging a Compose screenshot test suite that was green on every PR for months, then watched the actual app clip a button label the first time someone bumped their phone's font size to "Larger."

The test suite "passed" the entire time. That's the part worth writing up.

The gap: your snapshots only exist at fontScale 1.0

Most Compose screenshot setups — Paparazzi, Roborazzi, Compose UI's own snapshot tooling — render each @Preview or test composable through one fixed device config. Unless someone deliberately parameterizes it, that config uses fontScale = 1.0f, because that's the default on both the emulator image and DeviceConfig's own defaults.

That's a real gap, not a hypothetical one: Android's accessibility settings let users scale system font size up to 1.3x ("Larger"), and some OEM skins + the newer non-linear font scaling in Android 14+ push effective scale even higher. None of that ever touches a snapshot test that only renders at 1.0.

Here's a layout that looks completely fine in every screenshot:

@Composable
fun OrderRow(item: String, price: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = item,
            modifier = Modifier.weight(1f),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis
        )
        Spacer(Modifier.width(8.dp))
        Text(
            text = price,
            modifier = Modifier.width(72.dp),   // fixed width, "it always fits"
            maxLines = 1,
            overflow = TextOverflow.Ellipsis
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

At fontScale = 1.0f a price like $1,249.00 fits inside 72dp with room to spare. At fontScale = 1.3f it doesn't, and TextOverflow.Ellipsis quietly truncates it to $1,24… — a price, silently wrong, with no crash, no lint warning, and a fully green test suite, because nothing ever rendered that composable at anything other than the default scale.

Making the gap visible: parameterize the device config, not just the composable

The fix isn't a new testing library, it's actually using the config knob that's already there. Paparazzi's DeviceConfig has a fontScale field — most setups just never touch it:

class OrderRowScreenshotTest {
    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5
    )

    @Test
    fun orderRow_defaultScale() {
        paparazzi.snapshot {
            OrderRow(item = "Wireless Keyboard", price = "$1,249.00")
        }
    }

    @Test
    fun orderRow_largeFontScale() {
        val scaledConfig = paparazzi.deviceConfig.copy(fontScale = 1.3f)
        paparazzi.unsafeUpdateConfig(deviceConfig = scaledConfig)
        paparazzi.snapshot {
            OrderRow(item = "Wireless Keyboard", price = "$1,249.00")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run that second test once and the snapshot shows the clipped price immediately — no manual device testing, no accessibility scanner, just a second rendering pass at a scale real users actually use.

If you're not on Paparazzi, the same idea works with a plain Compose UI test by overriding LocalDensity directly, which is the mechanism fontScale ends up driving anyway:

@Test
fun orderRow_largeFontScale_composeTest() {
    composeTestRule.setContent {
        val scaledDensity = Density(
            density = LocalDensity.current.density,
            fontScale = 1.3f
        )
        CompositionLocalProvider(LocalDensity provides scaledDensity) {
            OrderRow(item = "Wireless Keyboard", price = "$1,249.00")
        }
    }
    // assert on layout bounds / semantics instead of a snapshot if you're not on Paparazzi/Roborazzi
}
Enter fullscreen mode Exit fullscreen mode

What actually to do with this

Don't try to snapshot every screen at every scale — that's a combinatorial mess and most of your screens don't have the fixed-width-next-to-dynamic-text shape that breaks. Instead:

  • Audit for the specific pattern: any Row/Box pairing a weight()-based text with a fixed-width sibling (price columns, trailing badges, icon+label chips) is the shape that breaks first.
  • Add one fontScale = 1.3f variant per screen that has that pattern, not per composable. You're testing "does this layout survive," not re-testing visual design at every scale.
  • If you're on Paparazzi, deviceConfig.copy(fontScale = ...) plus unsafeUpdateConfig is enough — no new dependency.
  • Treat a passing snapshot suite as proof your layout works at the scales you actually tested, not proof it works for users who turn on accessibility text scaling. Those are different claims, and only one of them is usually true.

The fuller version of this — plus three other classes of screenshot-test blind spots (RTL layout direction, dark theme state capture, and dynamic color token drift) — is in the Modern Android Screenshot Testing Playbook if you want the rest written up together.

Top comments (0)