Snapshot Testing SwiftUI: Multi-Device, Multi-Theme
Snapshot testing catches visual regressions automatically. Here's how to test SwiftUI views across multiple devices and themes efficiently.
Why Snapshot Testing?
Snapshot testing solves a specific problem: visual regression detection. When code changes, you want to know if the UI changed unexpectedly.
Benefits
- Catches unintended changes: A single property update can break multiple screens
- Documents expected appearance: Reference images serve as visual documentation
- Tests multiple configurations automatically: Device sizes, dark mode, accessibility sizes
- Fast feedback: Run hundreds of visual checks in seconds
When NOT to Use Snapshots
- Frequently changing designs: You'll spend more time updating snapshots than catching bugs
- User interaction testing: Use XCUITest for flows and gestures
- Business logic: Unit tests are faster and more precise
Snapshot testing complements—not replaces—unit and UI testing.
Setup
Install swift-snapshot-testing:
// Package.swift
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0")Device Configurations
Define reusable device configs:
// SnapshotConfiguration.swift
import SnapshotTesting
enum DeviceConfig {
static let iPhone16 = ViewImageConfig.iPhone13Pro
static let iPadAir = ViewImageConfig.iPadPro11
}
enum ScreenConfiguration: CaseIterable {
case iPhoneLight, iPhoneDark, iPadLight, iPadDark
var config: ViewImageConfig {
switch self {
case .iPhoneLight, .iPhoneDark: return DeviceConfig.iPhone16
case .iPadLight, .iPadDark: return DeviceConfig.iPadAir
}
}
var traits: UITraitCollection {
switch self {
case .iPhoneLight, .iPadLight:
return UITraitCollection(userInterfaceStyle: .light)
case .iPhoneDark, .iPadDark:
return UITraitCollection(userInterfaceStyle: .dark)
}
}
}Base Test Case
Disable animations for deterministic snapshots:
// SnapshotTestCase.swift
class SnapshotTestCase: XCTestCase {
override func setUp() {
super.setUp()
UIView.setAnimationsEnabled(false)
}
override func tearDown() {
UIView.setAnimationsEnabled(true)
super.tearDown()
}
}Helper Methods
Test all configurations at once:
extension SnapshotTestCase {
func assertSnapshots<V: View>(
of view: V,
named name: String,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
for config in ScreenConfiguration.allCases {
assertSnapshot(
of: view,
as: .image(
layout: .device(config: config.config),
traits: config.traits
),
named: "\(name)_\(config)",
file: file,
testName: testName,
line: line
)
}
}
func assertComponentSnapshots<V: View>(
of view: V,
named name: String,
size: CGSize,
file: StaticString = #file,
testName: String = #function,
line: UInt = #line
) {
// Components: just light/dark, fixed size
for style in [UIUserInterfaceStyle.light, .dark] {
assertSnapshot(
of: view,
as: .image(
layout: .fixed(width: size.width, height: size.height),
traits: UITraitCollection(userInterfaceStyle: style)
),
named: "\(name)_\(style == .light ? "Light" : "Dark")",
file: file,
testName: testName,
line: line
)
}
}
}Use assertSnapshots for full screens, assertComponentSnapshots for isolated components.
Writing Tests
class CircleButtonSnapshotTests: SnapshotTestCase {
func testPlayButton() {
let view = CircleButton(size: 60, icon: "play.fill") {}
assertComponentSnapshots(
of: view,
named: "play",
size: CGSize(width: 80, height: 80)
)
}
func testPauseButtonLarge() {
let view = CircleButton(size: 120, icon: "pause.fill") {}
assertComponentSnapshots(
of: view,
named: "pause_large",
size: CGSize(width: 140, height: 140)
)
}
}Recording Baselines
First run creates reference images:
RECORD_SNAPSHOTS=1 xcodebuild test ...Or in code:
// Temporarily set to record
isRecording = trueDebugging Failed Snapshots
When a snapshot fails, swift-snapshot-testing creates a diff showing exactly what changed:
__Snapshots__/
├── testLoginButton.login_Light.png # Reference
├── testLoginButton.login_Light.1.png # Actual (new)
└── testLoginButton.login_Light.diff.png # Visual diff
Common Causes
- Simulator version mismatch: Different iOS versions render fonts differently
- Animation timing: Async animations can capture mid-transition states
- Dynamic content: Dates, random IDs, or live data appearing in snapshots
Handling Dynamic Content
Inject fixed values for non-deterministic content:
func testTimestamp() {
// Inject fixed date instead of Date()
let fixedDate = Date(timeIntervalSince1970: 1704067200)
let view = TimestampView(date: fixedDate)
assertSnapshot(of: view, as: .image)
}For animations, disable them in your test base class (already shown above).
Directory Structure
Snapshots organize automatically:
MyAppTests/
└── Snapshots/
└── Components/
└── CircleButtonSnapshotTests/
└── __Snapshots__/
├── testPlayButton.play_Light.png
├── testPlayButton.play_Dark.png
├── testPauseButtonLarge.pause_large_Light.png
└── testPauseButtonLarge.pause_large_Dark.png
CI Integration
Fail on differences:
- name: Run snapshot tests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro' \
-resultBundlePath TestResults.xcresultSimulator differences can cause false failures. Pin simulator versions in CI.
Common Pitfalls
1. Adding Package to Wrong Target
swift-snapshot-testing must be added to your test target, not your app target.
2. Global Configuration Bleeding
With Swift Testing (@Test), global state can leak between tests. Use scoped configuration:
withSnapshotTesting(record: .missing) {
assertSnapshot(of: view, as: .image)
}3. Simulator Inconsistency
Pin your CI simulator to match local development:
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0'4. Large Snapshot Files
Consider Git LFS for projects with many snapshots. Shopify runs ~2,300 snapshot tests; Spotify runs ~1,600.
Quick Reference
| Scenario | Method | Size |
|---|---|---|
| Full screen | assertSnapshots | Device-based |
| Component | assertComponentSnapshots | Fixed |
| Single config | assertSnapshot | Manual |
Snapshot testing catches regressions before users do.