Snapshot Testing on iOS with iOSSnapshotTestCase

Snapshot Testing on iOS with iOSSnapshotTestCase

Snapshot testing captures a rendered view as a reference image, then on every subsequent test run it re-renders the same view and compares the result pixel by pixel. Any mismatch—a font change, a shifted layout, an unexpected color—fails the test immediately and shows you a diff image. No human reviewer is required to spot the regression.

On iOS, the canonical library for this is iOSSnapshotTestCase, originally released by Facebook as FBSnapshotTestCase. Facebook open-sourced it to catch visual regressions across dozens of app surfaces without manually reviewing every screen after each code change. Uber later took over maintenance under the name iOSSnapshotTestCase, and it remains the most widely adopted snapshot testing library for UIKit-based apps.

How iOSSnapshotTestCase Works

The library extends XCTestCase. When you call FBSnapshotVerifyView, it renders your UIView (or UIViewController's view) into an off-screen graphics context and produces a PNG. On the first run—or whenever you set recordMode = true—it writes that PNG to disk as the reference image. On every subsequent run, it renders the view again, loads the stored reference, and performs a pixel-level comparison. A mismatch above a configurable tolerance threshold fails the test.

The diff output writes two additional images alongside the reference: the actual render and a difference image that highlights changed pixels in red. When a test fails, you open the diff image to see immediately which part of the UI shifted.

The library supports both views and view controllers, handles retina and non-retina devices, and lets you specify a subset of a view hierarchy to snapshot rather than the entire screen.

Installation

CocoaPods

Add the pod to your test target in your Podfile:

target 'MyAppTests' do
  pod 'iOSSnapshotTestCase'
end

Run pod install, then import the module in your test files:

import FBSnapshotTestCase

Swift Package Manager

In Xcode, go to File > Add Packages, enter the repository URL https://github.com/uber/ios-snapshot-test-case, and add the package to your test target. Then import as above.

You also need to configure the environment variables that tell the library where to read and write reference images. In your scheme's Test action, add two environment variables:

  • FB_REFERENCE_IMAGE_DIR — path to the directory where reference images are stored, for example $(SOURCE_ROOT)/Tests/ReferenceImages
  • IMAGE_DIFF_DIR — path where diff images are written on failure, for example $(SOURCE_ROOT)/Tests/FailureDiffs

The reference image directory should be checked into version control. The diff directory is a scratch space and typically belongs in .gitignore.

Writing Your First Snapshot Test

Subclass FBSnapshotTestCase instead of XCTestCase:

import FBSnapshotTestCase

class ProfileCardSnapshotTests: FBSnapshotTestCase {

    override func setUp() {
        super.setUp()
        // recordMode = true  // uncomment to record new references
    }

    func testProfileCardDefaultState() {
        let view = ProfileCardView(frame: CGRect(x: 0, y: 0, width: 375, height: 120))
        view.configure(name: "Jane Smith", role: "Senior Engineer", avatarImage: UIImage(named: "placeholder"))
        FBSnapshotVerifyView(view)
    }
}

The first time you run this test with recordMode = true, it writes ProfileCardSnapshotTests/testProfileCardDefaultState@2x.png inside your reference image directory and the test passes. After that, remove or comment out recordMode = true. Every future test run renders the view and compares it to that stored PNG. If anything in ProfileCardView changes the layout, the test fails.

You can snapshot a view controller's full view hierarchy just as easily:

func testOnboardingFirstScreen() {
    let vc = OnboardingViewController()
    vc.loadViewIfNeeded()
    FBSnapshotVerifyViewController(vc)
}

Recording vs Verifying

The library uses a two-phase workflow.

Recording is the reference-creation step. Set recordMode = true in setUp, run the tests, then commit the generated PNG files to version control. The tests themselves pass during recording because there is nothing to compare against yet. After committing the references, disable record mode.

Verifying is every subsequent run. Record mode is off. The library compares the current render to the committed PNG. Differences fail the test.

A common mistake is leaving recordMode = true in the codebase. That means the library overwrites your references on every run, so regressions are never caught. Some teams add a CI check that scans test files for recordMode = true and fails the build if it finds one.

When you intentionally change a UI—updating the design system, adjusting spacing, changing a font—you need to update the references. The workflow is: enable record mode locally, run the affected tests, review the new PNGs to confirm they match the intended design, commit them, then disable record mode.

Handling Dynamic Content

Snapshot tests break when the rendered output includes content that changes between runs: the current date and time, a user's name pulled from live data, a randomly generated avatar, an animated view in mid-flight.

The correct approach is to control all inputs before rendering. Pass explicit, fixed test data into every view you snapshot. If a view pulls data from a view model or service, inject a stub:

func testInvoiceSummaryView() {
    let viewModel = InvoiceSummaryViewModel(
        invoiceNumber: "INV-00042",
        amount: "$1,200.00",
        dueDate: "2024-06-15",  // hardcoded, not Date()
        status: .pending
    )
    let view = InvoiceSummaryView(frame: CGRect(x: 0, y: 0, width: 375, height: 200))
    view.configure(with: viewModel)
    FBSnapshotVerifyView(view)
}

For views that display images loaded asynchronously from a URL, replace the image-loading layer with a synchronous stub that returns a fixture image. The goal is a view that renders identically every time the test runs, regardless of network state, time, or locale.

If your view reacts to system appearance (light mode vs dark mode), test both explicitly:

func testCardLightMode() {
    let view = makeCardView()
    view.overrideUserInterfaceStyle = .light
    FBSnapshotVerifyView(view)
}

func testCardDarkMode() {
    let view = makeCardView()
    view.overrideUserInterfaceStyle = .dark
    FBSnapshotVerifyView(view, identifier: "dark")
}

The identifier parameter appends a suffix to the reference image filename so light and dark references coexist without overwriting each other.

CI Integration

Reference images live in version control alongside the code that produces them. This is not optional. Without version-controlled references, a developer checks out a branch and has nothing to compare against. The references must be committed.

On CI, the test runner needs the same environment variables you configured in your local scheme. Set FB_REFERENCE_IMAGE_DIR and IMAGE_DIFF_DIR as CI environment variables or pass them through your build script:

xcodebuild test \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \
  -testArguments "FB_REFERENCE_IMAGE_DIR=$(pwd)/Tests/ReferenceImages IMAGE_DIFF_DIR=$(pwd)/Tests/FailureDiffs"

When a test fails, archive the diff images as CI artifacts. Most CI platforms support artifact uploads. Storing the diff PNGs lets you inspect failures without reproducing them locally.

One important consideration: snapshot references are device-specific. An iPhone 15 simulator running iOS 17 may render text at a slightly different subpixel position than an iPhone 14 simulator running iOS 16. The standard practice is to pick one canonical simulator configuration for CI—the same model, OS version, and screen scale—and only run snapshot tests on that configuration. Record your references using that same simulator.

When a PR introduces a visual change, the CI snapshot test fails, which surfaces the change for review rather than letting it merge silently.

Comparison with Other iOS Testing Approaches

Manual UI review finds regressions only when a human looks at the right screen at the right time. It scales poorly and misses edge states (empty views, error states, long text). Snapshot tests cover every recorded state automatically on every build.

XCUITest launches the full app in a simulator, taps through real UI flows, and asserts on accessibility identifiers and element states. XCUITest is appropriate for end-to-end flows—login, checkout, navigation paths. It is slow (a full test suite often takes minutes) and expensive to maintain because the tests are sensitive to layout changes in every screen they traverse. XCUITest does not naturally produce a diff image when the UI changes; it fails with an element-not-found error.

Snapshot tests with iOSSnapshotTestCase sit between unit tests and XCUITest. They test a single view or view controller in isolation, execute in milliseconds, and produce clear visual diffs on failure. You do not get any validation of real navigation or server integration, but for catching "something changed in how this component looks," snapshot tests are faster and more precise than XCUITest.

The combination most teams use: unit tests for business logic, snapshot tests for component appearance, and a smaller XCUITest suite for critical end-to-end flows. Each layer catches different categories of regression.

Tolerance and Perceptual Comparison

By default, iOSSnapshotTestCase requires a pixel-perfect match. You can configure a tolerance to allow minor differences:

FBSnapshotVerifyView(view, tolerance: 0.01)

A tolerance of 0.01 means up to 1% of pixels may differ. This is useful when anti-aliasing or subpixel rendering produces minor variation between runs on the same device. Keep the tolerance as low as possible—high tolerance values let real regressions through.

Some teams use the FBSnapshotVerifyViewWithOptions variant for more granular control, including options for overriding the comparison rectangle to focus on a specific region of the view.

Beyond Unit-Level Snapshot Tests

iOSSnapshotTestCase catches regressions in individual components. It does not test how those components behave when assembled into a running app, whether API responses render correctly, or how the UI holds up against real users on real devices. Those gaps require end-to-end testing.

HelpMeTest provides end-to-end monitoring for web and mobile applications written in plain English—no code required. You describe what a test should do ("Open the app, log in, navigate to the profile screen, verify the user's name appears") and HelpMeTest runs it continuously against your live environment. HelpMeTest uses usage-based pricing — $0.003 per test run, no base fee — for unlimited tests with no infrastructure to maintain; it runs entirely in the cloud.

Snapshot tests and end-to-end monitoring address different failure modes. Snapshot tests catch visual regressions in components during development. End-to-end monitoring catches failures in the live product. Both belong in a complete testing strategy.

Start now free