Lesson 81 of 83 advanced

Screenshot Testing: Paparazzi, Roborazzi & Visual Regression

Catch pixel-level regressions before your users do — master every major Android screenshot testing framework

Open interactive version (quiz + challenge)

Real-world analogy

Screenshot testing is like a museum curator who photographs every painting in exact lighting conditions. If someone accidentally bumps a frame or a cleaning crew leaves a smudge, comparing today's photo against the original instantly reveals the change — no human needs to walk through every gallery room each day.

What is it?

Screenshot testing (also called visual regression testing) is an automated testing approach where you capture rendered UI as image files, store approved versions as golden baselines, and compare future renders against those baselines to detect unintended visual changes. On Android, frameworks like Paparazzi (JVM-based, no emulator), Roborazzi (Robolectric-based), and Dropshots (device-based) automate this process, enabling teams to catch broken layouts, wrong colors, missing icons, and theme inconsistencies before they ship to users.

Real-world relevance

At Cash App, Paparazzi was created because their design system team needed to validate hundreds of UI components across themes and screen sizes without maintaining a fleet of emulators. Companies like Dropbox, Square, and Google use screenshot testing to enforce design consistency across feature teams — when any PR changes a shared component, the screenshot tests immediately show which screens are affected. In large-scale apps with 50+ contributors, screenshot testing is the primary defense against 'it looked fine on my machine' visual bugs.

Key points

Code example

// ============================================
// 1. PAPARAZZI SETUP — build.gradle.kts (module)
// ============================================
plugins {
    id("app.cash.paparazzi") version "1.3.4"
}

// ============================================
// 2. PAPARAZZI TEST — Compose Component
// ============================================
class PaymentCardScreenshotTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5,
        theme = "android:Theme.Material3.DayNight",
        renderingMode = SessionParams.RenderingMode.NORMAL,
        maxPercentDifference = 0.1 // 0.1% tolerance
    )

    @Test
    fun paymentCard_defaultState() {
        paparazzi.snapshot {
            MyAppTheme(darkTheme = false) {
                PaymentCard(
                    amount = "$42.00",
                    recipient = "Jane Doe",
                    status = PaymentStatus.COMPLETED
                )
            }
        }
    }

    @Test
    fun paymentCard_darkMode() {
        paparazzi.snapshot {
            MyAppTheme(darkTheme = true) {
                PaymentCard(
                    amount = "$42.00",
                    recipient = "Jane Doe",
                    status = PaymentStatus.PENDING
                )
            }
        }
    }
}

// ============================================
// 3. PARAMETERIZED MULTI-CONFIG TEST
// ============================================
@RunWith(TestParameterInjector::class)
class DesignSystemScreenshotTest(
    @TestParameter val darkMode: Boolean,
    @TestParameter val isRtl: Boolean,
    @TestParameter val fontScale: FontScale
) {
    enum class FontScale(val value: Float) {
        NORMAL(1.0f), LARGE(1.3f), HUGE(1.5f)
    }

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5.copy(
            fontScale = fontScale.value,
            layoutDirection = if (isRtl)
                LayoutDirection.RTL else LayoutDirection.LTR
        )
    )

    @Test
    fun primaryButton_allConfigs() {
        paparazzi.snapshot {
            MyAppTheme(darkTheme = darkMode) {
                PrimaryButton(
                    text = "Submit Payment",
                    onClick = {}
                )
            }
        }
    }
    // Generates 12 screenshots:
    // 2 themes x 2 directions x 3 font scales
}

// ============================================
// 4. ROBORAZZI — Robolectric Screenshot Test
// ============================================
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33])
class ProfileScreenRoborazziTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun profileScreen_loaded() {
        composeRule.setContent {
            MyAppTheme {
                ProfileScreen(
                    user = User("Alex", "alex@dev.com"),
                    isEditing = false
                )
            }
        }
        composeRule.onRoot()
            .captureRoboImage(
                filePath = "src/test/snapshots/profile_loaded.png",
                roborazziOptions = RoborazziOptions(
                    compareOptions = CompareOptions(
                        changeThreshold = 0.01 // 1% threshold
                    )
                )
            )
    }

    @Test
    fun profileScreen_editMode_diff() {
        composeRule.setContent {
            MyAppTheme {
                ProfileScreen(
                    user = User("Alex", "alex@dev.com"),
                    isEditing = true
                )
            }
        }
        // In compare mode, generates a 3-panel diff:
        // [baseline] [actual] [highlighted diff]
        composeRule.onRoot()
            .captureRoboImage("src/test/snapshots/profile_edit.png")
    }
}

// ============================================
// 5. COMPOSE PREVIEW SCREENSHOT TESTING
// ============================================
// In your UI module — define previews as usual
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES)
@Composable
fun OrderSummaryPreview() {
    MyAppTheme {
        OrderSummary(
            items = listOf("Burger", "Fries"),
            total = "$12.99"
        )
    }
}

// In your test module — auto-scan and test all previews
@RunWith(TestParameterInjector::class)
class ComposePreviewTests(
    @TestParameter(valuesProvider = PreviewProvider::class)
    val preview: ComposablePreview<AndroidPreviewInfo>
) {
    @get:Rule
    val paparazzi = Paparazzi()

    class PreviewProvider : TestParameter.TestParameterValuesProvider {
        override fun provideValues(): List<ComposablePreview<AndroidPreviewInfo>> =
            AndroidComposablePreviewScanner()
                .scanPackageTrees("com.myapp.ui")
                .getPreviews()
    }

    @Test
    fun previewScreenshot() {
        paparazzi.snapshot {
            preview()
        }
    }
}

// ============================================
// 6. CI WORKFLOW — GitHub Actions
// ============================================
// .github/workflows/screenshot-tests.yml
//
// name: Screenshot Tests
// on:
//   pull_request:
//     paths: ['app/src/**', 'design-system/**']
//
// jobs:
//   verify-screenshots:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//         with:
//           lfs: true  # golden images in Git LFS
//
//       - uses: actions/setup-java@v4
//         with:
//           java-version: '17'
//           distribution: 'temurin'
//
//       - name: Verify Paparazzi Screenshots
//         run: ./gradlew verifyPaparazziDebug
//
//       - name: Upload diff artifacts on failure
//         if: failure()
//         uses: actions/upload-artifact@v4
//         with:
//           name: screenshot-diffs
//           path: '**/build/paparazzi/failures/'
//
//       - name: Comment PR with diff images
//         if: failure()
//         uses: marocchino/sticky-pull-request-comment@v2
//         with:
//           message: |
//             Screenshot tests failed!
//             Download diff artifacts to see changes.

Line-by-line walkthrough

  1. 1. We start with the Gradle plugin declaration `id("app.cash.paparazzi")` which adds the Paparazzi framework to our module — this gives us the Paparazzi test rule and the record/verify Gradle tasks.
  2. 2. The `PaymentCardScreenshotTest` class demonstrates basic Paparazzi usage. The `@get:Rule val paparazzi = Paparazzi(...)` creates a JUnit rule that handles the LayoutLib rendering engine lifecycle — no emulator needed.
  3. 3. In the Paparazzi constructor, `DeviceConfig.PIXEL_5` sets the virtual screen dimensions, density, and form factor. `maxPercentDifference = 0.1` means up to 0.1% pixel difference is tolerated before a test fails.
  4. 4. The `paparazzi.snapshot { ... }` block takes a Composable lambda. Paparazzi renders this Composable using Android's LayoutLib (the same engine Android Studio uses for previews) and saves it as a PNG file.
  5. 5. The `DesignSystemScreenshotTest` uses `@RunWith(TestParameterInjector::class)` with `@TestParameter` annotations to create a cartesian product of all parameter combinations — 2 themes x 2 directions x 3 font scales = 12 test runs from one test method.
  6. 6. The `DeviceConfig.PIXEL_5.copy(fontScale = ..., layoutDirection = ...)` call creates a modified device configuration for each parameter combination, letting us test accessibility and RTL scenarios without any emulator setup.
  7. 7. In the Roborazzi test, `@GraphicsMode(GraphicsMode.Mode.NATIVE)` tells Robolectric to use native graphics rendering instead of shadow implementations — this is essential for accurate screenshot capture.
  8. 8. The `captureRoboImage()` extension function captures the current state of a Compose node or View and saves it to the specified file path. The `RoborazziOptions` configure comparison behavior including the change threshold.
  9. 9. The Compose Preview Screenshot Testing section shows how `AndroidComposablePreviewScanner().scanPackageTrees("com.myapp.ui")` automatically discovers all @Preview composables in the given package, converting your existing preview catalog into a screenshot test suite.
  10. 10. The CI workflow runs `verifyPaparazziDebug` which compares current renders against committed golden images. On failure, the `upload-artifact` step preserves diff images (showing baseline, actual, and highlighted differences) for developer review.
  11. 11. The `lfs: true` option in the checkout step ensures Git LFS files (golden images) are downloaded — without this, CI would have only LFS pointer files and all screenshot comparisons would fail.
  12. 12. The sticky PR comment step provides immediate visual feedback to developers when screenshots change, eliminating the need to manually download and inspect diff artifacts for every failed test.

Spot the bug

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class HomeScreenScreenshotTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun homeScreen_defaultState() {
        composeRule.setContent {
            MyAppTheme {
                HomeScreen(viewModel = HomeViewModel())
            }
        }
        composeRule.onRoot()
            .captureRoboImage("snapshots/home_default.png")
    }
}
Need a hint?
Roborazzi requires a specific annotation to enable native graphics rendering on Robolectric. Without it, the screenshot will be blank or use shadow rendering that produces incorrect output.
Show answer
The test is missing `@GraphicsMode(GraphicsMode.Mode.NATIVE)` annotation on the class. Without this annotation, Robolectric uses its default shadow-based rendering which does not support real graphics capture. Add `@GraphicsMode(GraphicsMode.Mode.NATIVE)` above the class declaration for Roborazzi to produce accurate screenshots.

Explain like I'm 5

Imagine you draw a really nice picture and take a photo of it. The next day, your little brother adds a tiny scribble in the corner. You compare today's photo with yesterday's photo and instantly spot the scribble! Screenshot testing does the same thing for app screens — it takes photos of your app's pages, saves them, and whenever someone changes the code, it takes new photos and compares them to find anything that looks different. If something changed on purpose, you say 'that's fine, save the new photo.' If it changed by accident, you caught a bug!

Fun fact

Paparazzi was named after the relentless photographers who chase celebrities — because like paparazzi, the tool relentlessly captures every visual detail of your UI! Cash App's team reported that after adopting Paparazzi, they caught over 200 visual regressions in their first quarter that had previously slipped through code review. The Android screenshot testing ecosystem has grown so fast that by 2025, there were more than 7 competing frameworks — leading to the community joke that 'choosing a screenshot testing library is harder than the actual testing.'

Hands-on challenge

Set up a complete screenshot testing suite using Paparazzi for a design system module: (1) Add the Paparazzi Gradle plugin to a module. (2) Write a parameterized screenshot test for a Button composable that covers light/dark theme and normal/large font scale (4 configs). (3) Record the golden images locally. (4) Intentionally change the button's padding by 2dp and run verify to see the diff. (5) Write a GitHub Actions workflow that runs screenshot verification on PRs and uploads diff artifacts on failure. (6) Bonus: Integrate ComposablePreviewScanner to auto-generate screenshot tests from all @Preview functions in your module.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery