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 recordPaparazziDebugRecorded images appear in src/test/snapshots/images/.
Run to verify (detect regressions):
./gradlew verifyPaparazziDebugIf 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 baselineactual.png— what the current code producesdelta.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.