Snapshot testing for iOS and Android: a field guide

How snapshot tests work on iOS and Android, which libraries to use, how to record and verify, where flaky diffs come from and what is not worth snapshotting.

Snapshot testing for iOS and Android: a field guide

The first snapshot suite I set up caught a bug within a week, and the bug was mine. I had changed the default padding of a shared button, checked the screen I was working on, and pushed. The suite failed on components I had not opened in months. That is the whole pitch, really: snapshot tests notice the things you did not think to look at.

They also have a reputation for flaky failures, noisy diffs and repositories bloated with PNG files. Most of that comes from a handful of avoidable causes. This guide covers the main tools on both platforms as of spring 2025, the record and verify loop, where flaky diffs come from, how to review them in CI, and what to leave out entirely.

What a snapshot test checks

A snapshot test renders a view, a screen or a component in a fixed configuration and saves the result as a reference, usually an image, sometimes text. On every later run it renders again and compares. If the output differs beyond a tolerance, the test fails and you get the old picture, the new picture and a diff.

The test has no idea whether the new picture is wrong. It only knows something changed, and a person decides. That makes snapshots good at catching unintended visual changes: spacing, colors, truncated labels, the dark mode color that never got its dark variant. They are poor at checking behavior. A snapshot of a login screen tells you nothing about whether the login button logs anyone in.

The tools, by platform

On iOS, the usual choice is swift-snapshot-testing by Point-Free. It snapshots views and view controllers as images, and it can also snapshot as text: a recursive view description, JSON, a property list, or a plain dump of any value. Reference files land in a __Snapshots__ folder next to the test file. Version 1.17, released in June 2024, added support for Apple's Swift Testing framework alongside XCTest and replaced the old global recording flag with scoped record modes.

On Android, the three serious options all run on the JVM, without an emulator. Paparazzi, from Cash App, renders layouts and composables with the same layout rendering library Android Studio uses for previews, inside ordinary unit tests. Roborazzi builds on Robolectric's native graphics mode, so a test can click through a flow with Espresso or the Compose testing APIs and capture images along the way. Google's own Compose Preview Screenshot Testing turns @Preview functions into screenshot tests through an Android Gradle plugin, and is still marked experimental at the time of writing.

If your team went with Flutter after the trade-offs in our cross-platform framework guide, golden tests are already built into the test package through matchesGoldenFile, and everything below applies to them as well.

Tool Platform Renders on Record Verify
swift-snapshot-testing iOS Simulator, XCTest or Swift Testing record: .all or .failed Normal test run
Paparazzi Android JVM, layout renderer recordPaparazziDebug verifyPaparazziDebug
Roborazzi Android JVM, Robolectric recordRoborazziDebug verifyRoborazziDebug
Compose Preview Screenshot Testing Android JVM, from previews updateDebugScreenshotTest validateDebugScreenshotTest
Golden tests Flutter Test environment Test run with the update-goldens flag Normal test run

Recording and verifying

Every tool has the same two modes. Recording writes new reference images. Verifying compares against them and fails on a difference. The discipline that keeps a suite useful fits in one sentence: verify everywhere, record only on purpose.

In swift-snapshot-testing the record mode decides what happens. With .missing, the default, a test with no reference on disk records one and fails, so you notice. .failed re-records only the assertions that fail, .all re-records everything, and .never writes nothing and fails when a reference is missing. The library's own documentation suggests .never for CI, so a retried job cannot quietly pass on a snapshot it just generated. You can set the mode for a scope in code, or with the SNAPSHOT_TESTING_RECORD environment variable on the CI machine.

func testProfileCardLargeText() {
  withSnapshotTesting(record: .missing) {
    let card = ProfileCard(user: .preview)
      .environment(\.dynamicTypeSize, .accessibility2)
    assertSnapshot(of: card, as: .image(layout: .device(config: .iPhoneSe)))
  }
}

On Android, Paparazzi is a JUnit rule with a device configuration. The configuration is a data class, so a large font scale or night mode is one copy away.

class ProfileCardTest {
  @get:Rule
  val paparazzi = Paparazzi(deviceConfig = PIXEL_5.copy(fontScale = 1.5f))

  @Test
  fun largeText() {
    paparazzi.snapshot { ProfileCard(user = previewUser) }
  }
}

Then ./gradlew :feature:profile:recordPaparazziDebug writes references to src/test/snapshots, and the verify task compares against them. Roborazzi follows the same pattern with its record and verify tasks, plus a compare task that writes comparison images, and it can also be switched into recording with a Gradle property.

When references do need to change, re-record in a separate commit with a message that says why ("Re-record after button padding change"). Reviewers then see the image changes on their own, not buried in a commit that also touches forty Kotlin files.

Where flaky diffs come from

A snapshot that fails without a real change is worse than no snapshot, because people learn to re-record without looking. These are the usual causes, roughly in the order I meet them.

Simulators, devices and machines

On iOS, text and shadows render slightly differently across iOS runtime versions and simulator models. Pick one simulator model and one runtime, write them down in the repository, and use them both locally and in CI. Apple silicon and Intel Macs can also render gradients and blurs a little differently, which is the reason the image strategy has a perceptualPrecision parameter next to the plain precision. A perceptual precision just below 1 tolerates color shifts the eye cannot see while still failing on a moved label. Keep the tolerance as tight as your setup allows, because a loose one hides exactly the changes you wanted to catch.

The JVM tools on Android avoid most of this, since they render with a bundled renderer on any machine. They have their own version of the problem: upgrading Paparazzi, Roborazzi or the compile SDK can change rendering. Do library upgrades in their own pull request with a full re-record and nothing else in it.

Fonts, locales, time zones and clocks

A label that says "2 hours ago" is a snapshot with an expiry date. So is anything formatted with the machine's locale or time zone. Inject a fixed clock, a fixed locale and a fixed time zone into the code under test, and give fonts a chance to load before capture. If the CI machine runs in a different language than your laptop, you will find out on the first run.

Animations, images and randomness

Capture before an image finishes loading and you snapshot a placeholder on one run and a photo on the next. Use local fixture images, or a loader configured to return synchronously in tests. Turn animations off, or with Compose tests set mainClock.autoAdvance = false and advance the clock yourself. Random avatar colors, shuffled lists and live network responses all belong in fixtures, not in snapshots.

Reviewing diffs in CI

Store reference images with Git LFS. Paparazzi's documentation strongly recommends it, and the advice applies to every tool here: a few hundred PNGs in plain Git history make every clone slower, forever.

Make the verify job upload its failures as build artifacts so nobody has to reproduce a failure locally just to see it. Paparazzi writes failure images to build/paparazzi/failures and an HTML report under build/reports/paparazzi. Roborazzi's compare task produces side-by-side comparison images. swift-snapshot-testing attaches the reference, the failing image and a difference image to the test result, so they show up in Xcode's report navigator and in exported result bundles.

Then agree on who approves a re-record. On small teams the author re-records and the reviewer looks at every changed image before approving. Not a sample. Every image. If that is too many images to look at, you have too many snapshots, which brings us to the next section.

What not to snapshot

Skip full screens fed with real or changing data. Snapshot the screen with fixture data, or snapshot its parts.

Skip every possible combination. Ten components times every theme, font size, locale and device is a folder nobody reviews. Pick configurations that have found bugs: default, dark mode, the largest text size you support, right to left, and one small screen. The largest text size is the most valuable of these, and if you work through an accessibility checklist for each release, it hands you that configuration for free.

Skip logic that a unit test can check, such as a price formatter or a date string. Skip maps, web views, video players and other views that render content you do not control. Skip animations in mid-flight. Skip system UI such as permission dialogs and share sheets, which you cannot render in a test anyway.

What remains is the good part: design system components in all their states, empty, loading and error states for each screen, and anything that has broken visually before.

A checklist for the first week

  • One tool per platform, one module to start with
  • Simulator model and runtime (iOS) or device configuration (Android) written down in the repository
  • Fixed clock, locale and time zone injected in tests
  • Reference images tracked with Git LFS
  • CI verifies only, never records, and uploads failure images as artifacts
  • Re-records in separate commits, with the reason in the message
  • Library and SDK upgrades in their own pull request
  • About ten components in four or five configurations, not more

Start with the button. It is the most reused component in nearly every app, it has the most states, and it is exactly where my padding change would have been caught in the pull request instead of a week later. More testing and build notes are collected on the mobile development track.