How to implement snapshot tests in SwiftUI
Add swift-snapshot-testing from Point-Free to your test target, then call
assertSnapshot(of:as:) with an .image(layout:) strategy
to capture and compare pixel-perfect PNG renders of any SwiftUI view. Run once with record: true
to write baselines; every subsequent run diffs against them and fails on any change.
import XCTest
import SnapshotTesting
import SwiftUI
final class ProfileCardSnapshotTests: XCTestCase {
func testProfileCard_light() {
let sut = ProfileCard(name: "Ada Lovelace", role: "Engineer")
.environment(\.colorScheme, .light)
assertSnapshot(
of: sut,
as: .image(layout: .fixed(width: 393, height: 100))
)
}
}
Full implementation
The library wraps your View in a UIHostingController,
renders it off-screen, and persists the result as a PNG in a __Snapshots__ folder
next to your test file. On subsequent runs it diffs the live render against the stored PNG and fails the test
on any pixel discrepancy, making regressions immediately visible in code review.
The example below covers the four most valuable snapshot scenarios for a production SwiftUI card component: light mode, dark mode, large Dynamic Type, and intrinsic sizing — each producing its own named PNG.
// ── Package.swift additions ────────────────────────────────────────
// dependencies:
// .package(url: "https://github.com/pointfreeco/swift-snapshot-testing",
// from: "1.17.0")
// targets (test target only):
// .product(name: "SnapshotTesting", package: "swift-snapshot-testing")
import XCTest
import SnapshotTesting
import SwiftUI
// ── View under test ────────────────────────────────────────────────
struct ProfileCard: View {
let name: String
let role: String
var body: some View {
HStack(spacing: 16) {
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 56, height: 56)
.foregroundStyle(.accent)
.accessibilityLabel("Profile photo")
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.headline)
Text(role)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
}
}
// ── Snapshot test suite ────────────────────────────────────────────
final class ProfileCardSnapshotTests: XCTestCase {
/// Flip to `true` to write/overwrite reference PNGs, then flip back.
private let isRecording = false
// 1. Light mode — iPhone 16 Pro width
func testProfileCard_light() {
let sut = ProfileCard(name: "Ada Lovelace", role: "Principal Engineer")
.environment(\.colorScheme, .light)
assertSnapshot(
of: sut,
as: .image(layout: .fixed(width: 393, height: 104)),
record: isRecording
)
}
// 2. Dark mode — separate reference PNG
func testProfileCard_dark() {
let sut = ProfileCard(name: "Ada Lovelace", role: "Principal Engineer")
.environment(\.colorScheme, .dark)
assertSnapshot(
of: sut,
as: .image(layout: .fixed(width: 393, height: 104)),
record: isRecording
)
}
// 3. Accessibility Extra Large Dynamic Type
func testProfileCard_accessibilityXL() {
let sut = ProfileCard(name: "Ada Lovelace", role: "Principal Engineer")
.environment(\.sizeCategory, .accessibilityExtraLarge)
assertSnapshot(
of: sut,
as: .image(layout: .fixed(width: 393, height: 180)),
record: isRecording
)
}
// 4. sizeThatFits — let SwiftUI measure its own intrinsic size
func testProfileCard_intrinsicSize() {
let sut = ProfileCard(name: "Jo", role: "Dev")
assertSnapshot(
of: sut,
as: .image(layout: .sizeThatFits),
record: isRecording
)
}
}
// ── SwiftUI Preview ────────────────────────────────────────────────
#Preview("Light") {
ProfileCard(name: "Ada Lovelace", role: "Principal Engineer")
.padding(.vertical)
}
#Preview("Dark") {
ProfileCard(name: "Ada Lovelace", role: "Principal Engineer")
.padding(.vertical)
.preferredColorScheme(.dark)
}
How it works
-
Test-target-only dependency: Swift Package Manager links
SnapshotTestingonly to the test target, so it never enters your app binary or App Store submission. The__Snapshots__PNGs live on disk alongside the.swifttest file and are committed to git. -
assertSnapshot(of:as:record:): Theofparameter accepts anyViewdirectly — the library wraps it in aUIHostingControllerinternally. Theasparameter selects the snapshotting strategy;.imageproduces a PNG diff. -
Layout strategies:
.fixed(width:height:)renders at an explicit pixel size — ideal for cards and list rows..sizeThatFitslets SwiftUI compute its own preferred size, which is better for intrinsically-bounded components like buttons or badges. -
Environment injection: Passing
.environment(\.colorScheme, .dark)before the assertion renders dark mode without changing the simulator's global appearance. This lets a single test run cover both colour schemes — two separate named PNGs — without switching schemes in between. -
Record mode & CI: When
record: true, the assertion always passes and writes a fresh PNG. Set it back tofalsebefore committing. In CI, pin thexcodebuild -destinationto a fixed simulator (e.g., iPhone 16 Pro, iOS 17.5) so pixel output is deterministic across machines.
Variants
Full-screen view with NavigationStack
Use .device(config:) to render at an exact device resolution, including the navigation bar and safe-area insets. The ViewImageConfig catalogue ships with configs for common iPhone and iPad models.
func testSettingsScreen_iPhone16Pro() {
let sut = NavigationStack {
SettingsView()
}
.environment(\.colorScheme, .light)
assertSnapshot(
of: sut,
as: .image(layout: .device(config: .iPhone13Pro)),
record: isRecording
)
}
Parameterised multi-device loop
Iterate over a device list to produce one named PNG per size class. Each file is independently diffed, so a regression on iPad Pro won't mask a clean iPhone run.
func testProfileCard_multiDevice() {
let devices: [(String, ViewImageConfig)] = [
("iPhoneSE3", .iPhoneSe),
("iPhone16Pro", .iPhone13Pro),
("iPadPro11", .iPadPro11)
]
for (name, config) in devices {
assertSnapshot(
of: ProfileCard(name: "Ada Lovelace", role: "Engineer"),
as: .image(layout: .device(config: config)),
named: name,
record: isRecording
)
}
}
Common pitfalls
-
⚠️ Simulator mismatch breaks every PNG. Snapshot images are pixel-tied to the simulator model and OS version they were recorded on. Always record on one canonical simulator (e.g., iPhone 16 Pro / iOS 17.5) and enforce the same
-destinationstring in CI. Upgrading Xcode or switching the sim invalidates all existing baselines. -
⚠️ Committing
record: truesilently overwrites history. Record mode never fails — it always passes and replaces the reference PNG. Add a pre-commit hook or CI lint step that greps forisRecording = trueand blocks the merge. -
⚠️ Dynamic content makes snapshots flaky. Dates, random UUIDs, animated placeholders, and network images all produce non-deterministic renders. Inject fixed values through view-model mocks or environment overrides before asserting, and suppress animations with
.transaction { $0.animation = nil }on the root view.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement snapshot tests in SwiftUI for iOS 17+. Use SnapshotTesting (pointfreeco/swift-snapshot-testing ≥1.17.0). Cover light mode, dark mode, and accessibilityExtraLarge Dynamic Type. Inject fixed data so all snapshots are fully deterministic. Make all interactive elements accessible (VoiceOver labels). Add a #Preview with realistic sample data.
Drop this prompt into Soarias during the Build phase right after scaffolding your feature screens — Claude Code will wire up the SPM dependency, generate the full test suite, and write the initial reference PNGs in a single pass so visual regression coverage is ready before your first TestFlight build.
Related
FAQ
Does this work on iOS 16?
The SnapshotTesting library itself supports iOS 13+, so asserting against an iOS 16 deployment target is fine. However, this guide uses the #Preview macro (iOS 17+) and newer SwiftUI material modifiers. If you target iOS 16, replace #Preview with PreviewProvider and swap any iOS 17-only APIs with their iOS 16 equivalents — the snapshot assertions themselves are unchanged.
How do I update a snapshot after an intentional UI change?
Temporarily set isRecording = true in your test class, run only the affected tests, and the library overwrites the old PNGs with fresh baselines. Flip isRecording back to false before committing. Alternatively, delete the specific PNG files from Finder — a missing reference triggers record mode automatically for that test only, leaving all other baselines intact.
What's the UIKit equivalent?
In UIKit you pass a UIViewController or UIView directly: assertSnapshot(of: myViewController, as: .image(on: .iPhone13Pro)). The API is identical — SnapshotTesting ships built-in strategies for both UIKit and SwiftUI types, so you can snapshot UIKit and SwiftUI views within the same test suite and even the same test file.
Last reviewed: 2026-05-12 by the Soarias team.