Screenshot testing captures a rendered image of a UI component and compares it to a stored baseline. Any pixel difference fails the test, catching visual regressions without manual visual inspection.
Why Screenshot Tests?
Standard unit and integration tests verify behavior. Screenshot tests verify appearance:
- Text truncation changed
- Padding accidentally removed
- Color or font changed after a theme update
- Dark mode rendering broken
Paparazzi: JVM Screenshot Tests (No Device Needed)
Paparazzi renders Compose components on the JVM using Android's layout engine — no emulator required:
// testImplementation("app.cash.paparazzi:paparazzi:1.3.4")
@RunWith(JUnit4::class)
class ArticleCardSnapshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "Theme.App"
)
@Test
fun `article card renders correctly`() {
val article = Article(
id = "a1",
title = "How to build Android apps",
author = "Akshay Nandwana",
publishedAt = "July 28, 2026",
imageUrl = null
)
paparazzi.snapshot {
AppTheme {
ArticleCard(article = article, onClick = {})
}
}
// First run: creates baseline image
// Subsequent runs: compares to baseline — fails on any pixel difference
}
@Test
fun `article card dark mode`() {
paparazzi.snapshot(name = "dark_mode") {
AppTheme(darkTheme = true) {
ArticleCard(article = testArticle, onClick = {})
}
}
}
}
Baseline Workflow
# Record baselines (first run / after intentional visual change)
./gradlew recordPaparazziDebug
# Verify against baselines (CI — fails if any pixel changed)
./gradlew verifyPaparazziDebug
# Baseline images are committed to git
# git add src/test/snapshots/
Roborazzi: Alternative with Robolectric
Roborazzi captures screenshots via Robolectric — can test more complex interactions:
// testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.19.0")
// testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.19.0")
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ProfileScreenSnapshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `profile screen matches baseline`() {
composeTestRule.setContent {
AppTheme {
ProfileScreen(profile = testProfile)
}
}
composeTestRule.onRoot()
.captureRoboImage("profile_screen.png")
}
}
What to Screenshot Test
| ✅ Good candidates | ❌ Poor candidates |
|---|---|
| Reusable UI components (cards, buttons) | Screens with dynamic content (timestamps, avatars) |
| Complex layout combinations | Loading states |
| Dark/light theme variants | Error states with variable messages |
| RTL layout variants | Animated elements |
| Typography-heavy screens | Screens that depend on real data |
Handling Dynamic Content
@Test
fun `article card with fixed test data`() {
paparazzi.snapshot {
AppTheme {
// Use fixed data — no timestamps, no random content
ArticleCard(
article = Article(
id = "a1",
title = "Fixed Title for Snapshot",
author = "Test Author",
publishedAt = "Jan 1, 2026", // fixed date, not today's date
imageUrl = null // no image loading
)
)
}
}
}
CI Integration
Screenshot tests typically run in two modes:
- PR:
verifyPaparazzi— fails on any change - Main branch after merge:
recordPaparazzi— update baselines if tests pass
Key Takeaways
| Rule | Why |
|---|---|
| Commit baselines to git | Needed for CI comparison |
| Fixed test data | No timestamps or dynamic values — same image on every run |
| Test components, not full screens | Smaller scope = less noise from unrelated changes |
| Dark + light themes | Most apps support both; screenshot both separately |
| Paparazzi for speed | JVM-based — no device or emulator needed |