Back to blog

Snapshot Testing SwiftUI: Multi-Device, Multi-Theme

·4 min read

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

  1. Catches unintended changes: A single property update can break multiple screens
  2. Documents expected appearance: Reference images serve as visual documentation
  3. Tests multiple configurations automatically: Device sizes, dark mode, accessibility sizes
  4. 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 = true

Debugging 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

  1. Simulator version mismatch: Different iOS versions render fonts differently
  2. Animation timing: Async animations can capture mid-transition states
  3. 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.xcresult

Simulator 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

ScenarioMethodSize
Full screenassertSnapshotsDevice-based
ComponentassertComponentSnapshotsFixed
Single configassertSnapshotManual

Snapshot testing catches regressions before users do.

Share:

Related Posts

How to use the Page Object pattern to write UI tests that don't break with every UI change.

A comprehensive guide to parsing journaling suggestions, handling 11+ content types, and navigating Swift 6 concurrency challenges.

A practical guide to implementing Live Activities with ActivityKit, including navigation display and Dynamic Island integration.