Paparazzi: Screenshot Testing for Android Without an Emulator

Paparazzi: Screenshot Testing for Android Without an Emulator

Paparazzi is a Cash App library that renders Android views and Compose UIs to PNG screenshots on the JVM — no emulator, no device, no ADB. Tests run in seconds instead of minutes. You record a baseline, then any future change that alters the visual output fails the test.

This is screenshot testing done right for Android: fast, reliable, deterministic.

Why Paparazzi Over Espresso Screenshots

Espresso screenshot tests:

  • Require a running emulator or device
  • Take 5-10 minutes to boot + run
  • Have flaky timing issues
  • Can't easily test dark mode, font sizes, locales in isolation

Paparazzi:

  • Runs on JVM with no emulator
  • Tests run in ~1 second each
  • Deterministic (same output every time)
  • Easy multi-configuration testing (dark mode, RTL, font scale)

Setup

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("app.cash.paparazzi") version "1.3.4"
}

dependencies {
    testImplementation("app.cash.paparazzi:paparazzi:1.3.4")
}

Basic Screenshot Test

// src/test/java/com/example/OrderCardTest.kt
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test

class OrderCardTest {

    @get:Rule
    val paparazzi = Paparazzi()

    @Test
    fun orderCard_completed() {
        paparazzi.snapshot {
            OrderCardView(
                order = Order(
                    id = "ORD-123",
                    amount = 49.99,
                    status = OrderStatus.COMPLETED,
                    customerName = "Jane Smith"
                )
            )
        }
    }

    @Test
    fun orderCard_pending() {
        paparazzi.snapshot {
            OrderCardView(
                order = Order(
                    id = "ORD-124",
                    amount = 12.50,
                    status = OrderStatus.PENDING,
                    customerName = "Bob Jones"
                )
            )
        }
    }

    @Test
    fun orderCard_empty_name() {
        paparazzi.snapshot {
            OrderCardView(
                order = Order(
                    id = "ORD-125",
                    amount = 0.00,
                    status = OrderStatus.COMPLETED,
                    customerName = ""
                )
            )
        }
    }
}

Run to record baselines:

./gradlew recordPaparazziDebug

Recorded images appear in src/test/snapshots/images/.

Run to verify (detect regressions):

./gradlew verifyPaparazziDebug

If the rendered output differs from the baseline PNG, the test fails with a diff image showing what changed.

Jetpack Compose Tests

class CheckoutScreenTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6,
        theme = "Theme.MyApp"
    )

    @Test
    fun checkoutScreen_withItems() {
        paparazzi.snapshot {
            MyAppTheme {
                CheckoutScreen(
                    cartItems = listOf(
                        CartItem("Widget A", 2, 9.99),
                        CartItem("Widget B", 1, 24.99),
                    ),
                    total = 44.97,
                    onCheckout = {},
                    onRemoveItem = {}
                )
            }
        }
    }

    @Test
    fun checkoutScreen_empty() {
        paparazzi.snapshot {
            MyAppTheme {
                CheckoutScreen(
                    cartItems = emptyList(),
                    total = 0.0,
                    onCheckout = {},
                    onRemoveItem = {}
                )
            }
        }
    }
}

Testing Multiple Configurations

The key Paparazzi advantage: test different configurations without multiple emulators.

Dark Mode

class OrderCardDarkModeTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6.copy(
            nightMode = NightMode.NIGHT
        ),
        theme = "Theme.MyApp"
    )

    @Test
    fun orderCard_darkMode() {
        paparazzi.snapshot {
            MyAppTheme(darkTheme = true) {
                OrderCard(order = sampleOrder())
            }
        }
    }
}

Multiple Screen Sizes

@RunWith(ParameterizedRobolectricTestRunner::class)
class OrderCardSizesTest(private val deviceConfig: DeviceConfig) {

    companion object {
        @JvmStatic
        @Parameters(name = "{0}")
        fun devices() = listOf(
            DeviceConfig.PIXEL_4,
            DeviceConfig.PIXEL_6,
            DeviceConfig.PIXEL_TABLET,
            DeviceConfig.PIXEL_FOLD,
        )
    }

    @get:Rule
    val paparazzi = Paparazzi(deviceConfig = deviceConfig)

    @Test
    fun orderCard_allSizes() {
        paparazzi.snapshot {
            OrderCard(order = sampleOrder())
        }
    }
}

Font Scale and Accessibility

class OrderCardAccessibilityTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6.copy(
            fontScale = 1.5f  // large text accessibility setting
        )
    )

    @Test
    fun orderCard_largeFont() {
        paparazzi.snapshot {
            OrderCard(order = sampleOrder())
        }
    }
}

RTL Layout

class OrderCardRTLTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6.copy(
            layoutDirection = LayoutDirection.RTL
        )
    )

    @Test
    fun orderCard_rtl() {
        paparazzi.snapshot {
            OrderCard(order = sampleOrder())
        }
    }
}

Snapshot Naming

By default, snapshots are named after the test method. Provide explicit names for clarity:

@Test
fun orderCard_states() {
    paparazzi.snapshot(name = "state_completed") {
        OrderCard(order = sampleOrder(status = OrderStatus.COMPLETED))
    }
    paparazzi.snapshot(name = "state_pending") {
        OrderCard(order = sampleOrder(status = OrderStatus.PENDING))
    }
    paparazzi.snapshot(name = "state_cancelled") {
        OrderCard(order = sampleOrder(status = OrderStatus.CANCELLED))
    }
}

Custom Device Configs

val customPhone = DeviceConfig(
    screenHeight = 2340,
    screenWidth = 1080,
    xdpi = 395,
    ydpi = 395,
    orientation = ScreenOrientation.PORTRAIT,
    density = Density.create(395),
    ratio = ScreenRatio.NOTLONG,
    size = ScreenSize.NORMAL,
    keyboard = Keyboard.NOKEY,
    touchScreen = TouchScreen.FINGER,
    keyboardState = KeyboardState.SOFT,
    softButtons = true,
    navBar = Navigation.NONAV,
    released = "November 2022"
)

val paparazzi = Paparazzi(deviceConfig = customPhone)

Handling Dynamic Content

Tests must produce deterministic output. Handle dynamic content:

// Bad: uses current time — will fail every day
@Test
fun orderCard_withTimestamp() {
    paparazzi.snapshot {
        OrderCard(
            order = sampleOrder(createdAt = Instant.now())  // non-deterministic!
        )
    }
}

// Good: use fixed test data
@Test
fun orderCard_withTimestamp() {
    val fixedDate = LocalDate.of(2024, 1, 15)
    paparazzi.snapshot {
        OrderCard(
            order = sampleOrder(orderDate = fixedDate)
        )
    }
}

For animations, Paparazzi captures a single frame. Verify the correct frame:

@Test
fun loadingState() {
    paparazzi.snapshot {
        // Loading state before data loads
        OrderCard(isLoading = true, order = null)
    }
}

CI Integration

# .github/workflows/screenshots.yml
name: Screenshot Tests

on:
  pull_request:
    paths:
      - 'app/src/main/**'
      - 'app/src/test/**'

jobs:
  screenshot-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: gradle

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

      - name: Upload diff images on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: screenshot-diffs
          path: |
            **/build/paparazzi/failures/

When a test fails, Paparazzi saves:

  • expected.png — the recorded baseline
  • actual.png — what the current code produces
  • delta.png — highlighted diff showing what changed

Updating Baselines

When a visual change is intentional (redesign, theme update):

# Re-record all screenshots
./gradlew recordPaparazziDebug

# Record only specific test class
./gradlew recordPaparazziDebug --tests "*.OrderCardTest"

Commit the new baseline PNGs:

git add src/test/snapshots/
git commit -m "Update Paparazzi baselines after order card redesign"

Paparazzi vs Screenshot Tests with Emulators

Paparazzi Espresso/Compose Screenshot
Speed ~1s/test 5-30min total (emulator boot)
Emulator required No Yes
CI cost Low High (emulator runner)
Pixel-perfect Yes Yes
Real device behavior No No
Dynamic animations Single frame Can capture animations
Configuration variants Easy (code) Requires separate test runs

Use Paparazzi for: component-level visual regression, dark mode, RTL, font scale testing. Use emulator screenshots for: full screen flows, real animation verification.

Summary

Paparazzi removes the biggest friction in Android screenshot testing: the emulator. Snapshot your components at every state — loading, error, empty, populated — across dark mode, RTL, and font scale variants, all in the JVM test suite that runs in seconds. Record once, verify on every PR. The diff image when a test fails is immediate and actionable. For component libraries and design systems especially, Paparazzi is the correct tool for visual regression protection.

Read more

Start now free