Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 049 / 100

Screenshot testing in Compose in Modern Android & Kotlin

Catch visual regressions.

Expona Admin7 min read

Screenshot testing in Compose in Modern Android & Kotlin

Visual regressions are silent killers in mobile apps. A single layout shift, color change, or typography adjustment can slip through code review and reach production unnoticed. Screenshot testing in Compose in Modern Android & Kotlin gives your team a safety net: automated image comparisons that catch visual bugs before users do.

In this post, we'll explore how to set up and maintain screenshot tests for your Compose UI, using tools that run on the JVM and integrate seamlessly into your CI/CD pipeline.

Why screenshot testing matters for Compose

Traditional unit tests verify logic. Integration tests verify behavior. But they don't verify what actually appears on the screen. A Compose component might render without crashing, yet look completely wrong due to a theme change, modifier tweak, or dependency update.

Screenshot tests fill that gap. They generate reference images of your UI components, then compare future renders against those references. When a visual change occurs—intentional or not—the test fails and shows you a diff.

For teams shipping e-commerce, travel, or ride-hailing apps where UI consistency directly impacts user trust and conversion, screenshot tests become a critical quality gate.

Paparazzi or Roborazzi run on the JVM

The key insight: both Paparazzi and Roborazzi execute screenshot tests locally and in CI without needing an emulator or device. They use Robolectric under the hood to mock Android framework calls, rendering your Compose UI in a Skia graphics environment on the JVM itself.

Paparazzi is a mature Gradle plugin that snapshots Compose previews. Roborazzi is a newer, lighter alternative that focuses on JUnit 4/5 tests and offers more flexible output handling.

The speed difference is dramatic: a full screenshot test suite runs in seconds on CI, not minutes waiting for emulator boot and frame rendering.

To add Paparazzi to your build.gradle.kts:

kotlin
plugins {
    id("app.cash.paparazzi") version "1.3.4"
}

paparazzi {
    deviceConfig = "pixel_6"
    theme = "MyAppTheme"
}

Then snapshot a Compose preview:

kotlin
@Preview
@Composable
fun LoginButtonPreview() {
    MyAppTheme {
        LoginButton(
            text = "Sign In",
            onClick = {}
        )
    }
}

class LoginButtonScreenshot {
    @get:Rule
    val paparazzi = Paparazzi()

    @Test
    fun loginButtonDefault() {
        paparazzi.snapshot {
            LoginButtonPreview()
        }
    }
}

On first run, this generates a PNG reference image. On subsequent runs, Paparazzi compares new renders against the stored image.

For Roborazzi, the setup is similar but uses a custom Roborazzi composable wrapper:

kotlin
class LoginButtonScreenshot {
    @Test
    fun loginButtonDefault() {
        onScreen {
            LoginButton(
                text = "Sign In",
                onClick = {}
            )
        }.captureRoboImage("login_button_default")
    }
}

Record and compare images

The workflow is straightforward. First, you record baseline images—the "golden" references that define what correct UI looks like. Run your tests with a record flag:

kotlin
./gradlew recordPaparazziDebug

This generates PNG files under src/test/resources/. Commit these to version control so your team shares the same baselines.

On subsequent runs—locally or in CI—Paparazzi compares live renders against these baselines:

kotlin
./gradlew verifyPaparazziDebug

If a component changes visually, the test fails and generates a diff image showing before and after side-by-side. You see exactly what pixels changed.

In a real scenario: a designer updates button padding across the app. You re-record baselines for all affected screenshot tests. The CI system compares new renders and confirms the change is intentional. Without screenshot tests, that padding change might look different on dark mode or when accessibility font scaling is enabled—issues you'd discover in production.

Both tools output diffs as PNGs in a report directory, which can be uploaded to your artifact storage or reviewed directly in pull requests via GitHub Actions or GitLab CI integration.

Test light, dark and font scales

One component, many configurations. Your app must work across:

  • Light and dark themes – color contrast, visibility
  • Large font sizes – text truncation, layout overflow
  • RTL layouts – mirrored UI for Arabic, Hebrew users
  • Screen sizes – phone, tablet, landscape

Screenshot tests let you capture all of these automatically.

Create parameterized tests that run the same component under different configurations:

kotlin
@RunWith(Parameterized::class)
class ProductCardScreenshot(
    private val themeName: String,
    private val theme: @Composable (content: @Composable () -> Unit) -> Unit,
    private val fontScale: Float
) {
    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6,
        theme = MaterialTheme::class,
    )

    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{0}-{2}x")
        fun themes() = listOf(
            arrayOf("light", { content: @Composable () -> Unit -> LightTheme(content) }, 1.0f),
            arrayOf("dark", { content: @Composable () -> Unit -> DarkTheme(content) }, 1.0f),
            arrayOf("light", { content: @Composable () -> Unit -> LightTheme(content) }, 1.3f),
            arrayOf("dark", { content: @Composable () -> Unit -> DarkTheme(content) }, 1.3f),
        )
    }

    @Test
    fun productCard() {
        paparazzi.snapshot {
            CompositionLocalProvider(
                LocalDensity provides Density(LocalDensity.current.density, fontScale)
            ) {
                theme {
                    ProductCard(
                        product = sampleProduct,
                        onAddToCart = {}
                    )
                }
            }
        }
    }
}

Now a single test function generates four baseline images: light and dark, each at 1x and 1.3x font scale. If a future change breaks dark mode rendering or causes text truncation at large scales, the test catches it immediately.

This is invaluable for accessibility compliance. Teams shipping travel or e-commerce apps know that users with low vision rely on large font settings. Screenshot tests ensure your layouts don't break under real-world accessibility configurations.

Review diffs in pull requests

The real value emerges in code review. A PR updates a Card component's shadow. The screenshot test fails. The diff shows:

Before: soft gray shadow, 8dp elevation
After: crisp black shadow, 12dp elevation

The reviewer sees the change visually, not just in code. If the change is intentional, they approve and the test baselines are updated. If it's accidental, they request changes before merge.

Set up your CI to upload diff reports to a pull request comment or artifact. GitHub Actions example:

kotlin
- name: Run screenshot tests
  run: ./gradlew verifyPaparazziDebug

- name: Upload diffs on failure
  if: failure()
  uses: actions/upload-artifact@v3
  with:
    name: paparazzi-diffs
    path: build/paparazzi/failures/

Reviewers click the artifact link and browse the diff images. No ambiguity. No "looks fine to me" when it actually doesn't.

For larger teams, integrate with tools like Percy or Chromatic that host screenshot diffs and comment on PRs automatically, tracking visual changes over time.

Common mistakes

Recording baselines in one environment, verifying in another: Ensure consistent font, theme, and device config between recording and CI. Skia rendering can vary slightly across OS versions. Lock your config in the paparazzi block and don't change it mid-project.

Testing with real data: Screenshot tests should be deterministic. Use hardcoded sample data, not API calls or timestamps. If your component renders different content on each run, the test fails sporadically and loses credibility.

Ignoring flakiness: Animations, shadows, and anti-aliasing can introduce pixel-level noise. Both tools support configuring comparison thresholds. If a test fails due to sub-pixel differences that don't matter visually, raise the tolerance rather than ignoring the test.

Over-testing trivial components: Screenshot tests add maintenance overhead. Don't snapshot every single Composable. Focus on high-value, visually complex components: cards, forms, dialogs, navigation patterns. Skip simple text fields and buttons unless they have custom styling.

Committing baselines without review: Screenshot baselines should go through code review just like production code. Never auto-approve baseline updates in CI. A malicious or careless change to baselines breaks your entire safety net.

Key takeaways

  • Screenshot tests run on the JVM via Paparazzi or Roborazzi, eliminating emulator overhead and enabling fast CI cycles.
  • Record baseline images once, then compare all future renders automatically to catch visual regressions before production.
  • Use parameterized tests to snapshot components across light/dark themes and font scales, ensuring accessibility compliance and cross-config consistency.
  • Review diffs in pull requests to make visual changes explicit and reviewable, not hidden in code diffs.
  • Focus on high-value components. Screenshot tests add maintenance cost; use them where visual consistency matters most.
  • Keep baselines deterministic by using hardcoded sample data and locking device/theme config to prevent flaky test results.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles