8.6 — Snapshot Testing

Opening scenario

A designer messages you on Slack: “the cart button is 2 points too low on iPhone SE.” You scroll through the PR diff — nothing changed in CartView. Two days of git-blaming later, you discover someone modified a shared ButtonStyle extension that subtly bumped padding on small screens. There’s no unit test that could have caught this. There is, however, a snapshot test.

Snapshot tests record what a view looks like, then fail when the rendered image differs by even one pixel.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
Reference snapshotPNG committed to repoThe “expected” outputRe-recording every failure → loses the signal
Snapshot diffPer-pixel comparisonCatches visual regressionsAnti-aliasing/font rendering can produce false positives
record: true modeRecords new snapshots instead of assertingUse to capture initial baselinesForgetting to flip back to assert mode
Device/scale/locale matrixSnap on multiple configurationsCatches platform-specific issuesExploding test count — pick critical configs only
swift-snapshot-testingPoint-Free’s libraryDe facto standard for iOSBuilding it yourself — don’t

Concept → Why → How → Code

Concept: render a view to an image, compare against a previously-recorded reference. Fail with a diff image showing what changed.

Why: visual regressions are invisible to logic tests but immediately obvious to users. Snapshot tests run in seconds, dozens at a time, with no real device required. They catch the entire class of “I changed a shared style and broke five unrelated screens” bug.

How: install swift-snapshot-testing, write one assertion per view × configuration, commit the generated PNGs alongside your code.

Setup

Package.swift:

.testTarget(
    name: "AppTests",
    dependencies: [
        "App",
        .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
    ]
)

First test

import SnapshotTesting
import SwiftUI
import XCTest
@testable import App

final class CartViewSnapshotTests: XCTestCase {
    func test_emptyCart() {
        let view = CartView(items: [])
        assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
    }

    func test_threeItems() {
        let view = CartView(items: [
            .stub(name: "Coffee", price: 4),
            .stub(name: "Bagel", price: 3),
            .stub(name: "OJ", price: 5),
        ])
        assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13)))
    }
}

First run — record mode

isRecording = true       // global; or pass `record: true` per-assertion

Run tests. They all “fail” but actually write the reference PNGs to a __Snapshots__/ directory next to your test file. Commit those PNGs. Flip back:

isRecording = false

Now subsequent runs compare against the committed images.

Device/configuration matrix

func test_cart_iPhoneSE() {
    assertSnapshot(of: CartView(items: stubItems),
                   as: .image(layout: .device(config: .iPhoneSe)))
}
func test_cart_iPhone13() {
    assertSnapshot(of: CartView(items: stubItems),
                   as: .image(layout: .device(config: .iPhone13)))
}
func test_cart_iPadPro() {
    assertSnapshot(of: CartView(items: stubItems),
                   as: .image(layout: .device(config: .iPadPro12_9)))
}

Or parameterize:

for config in [ViewImageConfig.iPhoneSe, .iPhone13, .iPadPro12_9] {
    assertSnapshot(of: view, as: .image(layout: .device(config: config)),
                   named: "\(config)")
}

Dynamic Type and Dark Mode

assertSnapshot(
    of: view.environment(\.sizeCategory, .accessibilityExtraExtraLarge),
    as: .image(layout: .device(config: .iPhone13))
)

assertSnapshot(
    of: view.preferredColorScheme(.dark),
    as: .image(layout: .device(config: .iPhone13)),
    named: "dark"
)

CI gotchas

Snapshot tests are environment-sensitive:

  • Xcode version — font rendering changes between Xcode versions. CI must use the same Xcode version as developers.
  • Simulator runtime — iOS 17 vs iOS 18 simulator can render the same view differently.
  • Apple Silicon vs Intel — different float math in some Metal paths. Pin runners to one arch.

Solutions:

  • Pin Xcode version in CI (/Applications/Xcode_16.0.app)
  • Pin simulator destination (-destination "platform=iOS Simulator,name=iPhone 15,OS=18.0")
  • Use the same arch as developers’ machines

When snapshots differ harmlessly (one anti-aliased pixel), set a precision tolerance:

assertSnapshot(of: view, as: .image(precision: 0.99))  // accept 99% match

What NOT to snapshot

  • Time-based content — anything with Date() or relativeFormatter. Either inject a fixed clock or hide the timestamp from the snapshot.
  • Animations mid-flight — snapshots capture a moment. If your view is animating at capture time, it’ll flake.
  • Random IDs — UUID-driven content needs deterministic seeding.
  • System UI — keyboard, status bar, share sheet are not snapshotted reliably.

Storage size

Snapshot PNGs live in Git. A medium app accumulates 200–500 MB of PNGs over a year. Mitigations:

  • Use Git LFS for __Snapshots__/ directories
  • Snapshot at smaller scale (scale: 2 instead of 3) for compact files
  • Prune unused snapshots quarterly (the library can detect orphans)

In the wild

  • swift-snapshot-testing (Point-Free) — the canonical library. 5k+ stars.
  • iOSSnapshotTestCase (Facebook, formerly FBSnapshotTestCase) — older, predates Swift Package Manager era; still in maintenance.
  • iosched (Google) — uses snapshot tests for their conference app’s collection of views.
  • Square’s Workflow — relies heavily on snapshots to test their architecture’s screen state.

Common misconceptions

  1. “Snapshots replace UI tests.” They verify appearance, not interaction. You still need XCUITest for taps.
  2. “Pixel-perfect comparisons are always best.” False positives waste hours. Use precision thresholds for non-critical regions.
  3. “Re-record when it fails.” Stop. A failing snapshot is either a real regression or a flaky environment. Investigate before re-recording.
  4. “Snapshots make tests faster.” A snapshot test running through SwiftUI’s render pipeline is slower than an XCTest assertion. Fast vs slow is relative to UI tests, not unit tests.
  5. “Once recorded, snapshots are stable forever.” False — Xcode/iOS updates can break them. Plan for snapshot churn every major Xcode release.

Seasoned engineer’s take

Snapshot tests pay off most for design systems (button styles, card components, list cells) where many screens depend on a shared piece of UI. They pay off least for screens that change weekly — you’ll spend more time re-recording than debugging. Pick the stable, reusable parts of your UI and snapshot those. Skip ephemeral product screens.

[!TIP] Use as: .recursiveDescription (not just .image) to also snapshot the SwiftUI view hierarchy as text. Text diffs are easier to read in PR reviews than image diffs.

[!WARNING] Never auto-update snapshots in CI. If a snapshot test fails, a human must inspect the diff and decide whether it’s a regression or an intended change.

Interview corner

Junior — “What is snapshot testing?” Render a view to an image, save it, then assert later renders match. If the appearance changes, the test fails. Used to catch visual regressions you can’t catch with logic tests.

Mid — “Your snapshot tests flake in CI but pass locally. Why?” Almost always environment. Xcode version, simulator runtime, or simulator architecture differs between dev machines and CI. Pin all three. If they still flake, check for non-deterministic content like timestamps, random IDs, or unfinished animations.

Senior — “When would you NOT add snapshot tests?” Three cases. One: rapidly evolving product screens where re-recording costs exceed catch-rate. Two: views with heavy dynamic content (charts, maps, video) where pixel comparison is meaningless. Three: views with intentional randomness or system UI. I’d also consider that snapshot tests work best at the component level — shared design system pieces, cells, modals — and worse at screen level, because screens combine state in too many ways to snapshot every combination cleanly.

Red flag — “I snapshot every screen on every device size.” That’s 500+ snapshots churning weekly, with everyone re-recording on every PR.

Lab preview

Lab 8.3 has a snapshot testing portion: you’ll add swift-snapshot-testing, capture baselines for three components, and tune a precision threshold.


Next: Performance Testing