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
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
| Reference snapshot | PNG committed to repo | The “expected” output | Re-recording every failure → loses the signal |
| Snapshot diff | Per-pixel comparison | Catches visual regressions | Anti-aliasing/font rendering can produce false positives |
record: true mode | Records new snapshots instead of asserting | Use to capture initial baselines | Forgetting to flip back to assert mode |
| Device/scale/locale matrix | Snap on multiple configurations | Catches platform-specific issues | Exploding test count — pick critical configs only |
swift-snapshot-testing | Point-Free’s library | De facto standard for iOS | Building 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()orrelativeFormatter. 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: 2instead of3) 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
- “Snapshots replace UI tests.” They verify appearance, not interaction. You still need XCUITest for taps.
- “Pixel-perfect comparisons are always best.” False positives waste hours. Use
precisionthresholds for non-critical regions. - “Re-record when it fails.” Stop. A failing snapshot is either a real regression or a flaky environment. Investigate before re-recording.
- “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.
- “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