# Snapshot Testing in SwiftUI: Catching Visual Regressions Automatically

Unit tests verify logic. UI tests verify user flows. But neither catches the moment a refactor silently shifts a layout, changes a font, or breaks a dark mode style.

**Snapshot testing** fills that gap. On the first run, it captures a reference image of your view. On every subsequent run, it renders the view again and compares it pixel-by-pixel to the reference. If anything changed — a padding, a colour, a missing element — the test fails and shows you exactly what shifted.

This article shows how to integrate snapshot testing into a SwiftUI project using [swift-snapshot-testing](https://github.com/pointfreeco/swift-snapshot-testing) by Point-Free, and how to apply it in a way that complements rather than duplicates your existing unit tests.

* * *

## Setup

Add the package to your project via Swift Package Manager:

```plaintext
https://github.com/pointfreeco/swift-snapshot-testing
```

Add it to your **test target only** — it's a development tool, not a production dependency.

```swift
// In your test file
import SnapshotTesting
import SwiftUI
import XCTest
```

* * *

## Your First Snapshot Test

Take the `FeedView` from the Clean Architecture article. Here is a minimal snapshot test:

```swift
final class FeedViewSnapshotTests: XCTestCase {

    func test_feedView_loadingState() {
        let sut = FeedView(viewModel: FeedViewModel(loadFeed: LoadingStub()))
        assertSnapshot(of: UIHostingController(rootView: sut), as: .image(on: .iPhone13))
    }
}

private class LoadingStub: LoadFeedUseCase {
    func execute() async throws -> [FeedItem] {
        // Never resolves — keeps the view in loading state
        try await Task.sleep(nanoseconds: .max)
        return []
    }
}
```

On the first run, the test **records** a reference image and passes unconditionally. The image is saved alongside your test file:

```plaintext
__Snapshots__/FeedViewSnapshotTests/test_feedView_loadingState.1.png
```

Commit this file to version control. From this point on, any change to the view that alters its appearance will fail the test and produce a diff image showing exactly what changed.

* * *

## Testing Multiple States

A well-designed snapshot test suite covers every meaningful visual state of a view. For a feed screen, that means at least three:

```swift
final class FeedViewSnapshotTests: XCTestCase {

    func test_loadingState() {
        let sut = makeSUT(state: .loading)
        assertSnapshot(of: sut, as: .image(on: .iPhone13))
    }

    func test_loadedState() {
        let sut = makeSUT(state: .loaded)
        assertSnapshot(of: sut, as: .image(on: .iPhone13))
    }

    func test_errorState() {
        let sut = makeSUT(state: .error)
        assertSnapshot(of: sut, as: .image(on: .iPhone13))
    }

    // MARK: - Helpers

    private func makeSUT(state: FeedState) -> UIViewController {
        let viewModel = FeedViewModelStub(state: state)
        let view = FeedView(viewModel: viewModel)
        return UIHostingController(rootView: view)
    }
}
```

The `FeedViewModelStub` drives the view into a specific state synchronously — no async, no timers, no network:

```swift
private enum FeedState { case loading, loaded, error }

@MainActor
private final class FeedViewModelStub: ObservableObject {
    @Published var items: [FeedItem] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    init(state: FeedState) {
        switch state {
        case .loading:
            isLoading = true
        case .loaded:
            items = [
                FeedItem(id: UUID(), title: "Test-Driven Development", imageURL: anyURL()),
                FeedItem(id: UUID(), title: "Clean Architecture", imageURL: anyURL()),
                FeedItem(id: UUID(), title: "SOLID Principles", imageURL: anyURL())
            ]
        case .error:
            errorMessage = "Could not load feed. Please try again."
        }
    }
}
```

Three tests, three reference images, every visual state covered.

* * *

## Testing Dark Mode and Accessibility

One of the most valuable uses of snapshot testing is catching appearance regressions across colour schemes and Dynamic Type sizes — issues that are easy to miss in manual testing.

```swift
func test_loadedState_darkMode() {
    let sut = makeSUT(state: .loaded)
    sut.overrideUserInterfaceStyle = .dark
    assertSnapshot(of: sut, as: .image(on: .iPhone13))
}

func test_loadedState_largeTextSize() {
    let sut = makeSUT(state: .loaded)
    assertSnapshot(
        of: sut,
        as: .image(on: .iPhone13, traits: UITraitCollection(preferredContentSizeCategory: .accessibilityLarge))
    )
}
```

These tests run in under a second and catch broken contrast ratios, truncated labels, and overflowing layouts before they reach a user.

* * *

## Snapshot Testing Respects Your Architecture

Notice what we are *not* doing: we're not testing the ViewModel logic in snapshot tests. We already did that in unit tests. The `FeedViewModelStub` drives the view into a known state — it exists purely to support rendering.

This maps directly to the layered architecture from previous articles:

```plaintext
Unit tests      → Domain (use cases, formatters, decorators)
Snapshot tests  → Presentation (views in each visual state)
Integration tests → Full stack (real use case, stubbed network)
```

Each layer of tests has a focused responsibility. Snapshot tests answer one question: **does the view look right?** They do not answer whether the data loading works, whether the cache strategy is correct, or whether navigation is wired up — those are answered elsewhere.

* * *

## Updating Snapshots Intentionally

When you make an intentional UI change — a new design, a revised layout — the snapshot test will fail because the reference no longer matches. This is correct behaviour. Update the reference by setting the `record` flag:

```swift
// Temporarily set to record new snapshots
func test_loadedState() {
    let sut = makeSUT(state: .loaded)
    assertSnapshot(of: sut, as: .image(on: .iPhone13), record: true)
}
```

Run the test once, then remove the `record: true` flag and commit the new reference image. The test now guards the new appearance.

A common workflow: use a CI environment variable to control recording, so snapshots are never accidentally updated in a pipeline:

```swift
let isRecording = ProcessInfo.processInfo.environment["RECORD_SNAPSHOTS"] == "true"
assertSnapshot(of: sut, as: .image(on: .iPhone13), record: isRecording)
```

* * *

## Naming and Organising Snapshot Tests

Reference images are stored next to the test file by default:

```plaintext
FeedViewSnapshotTests.swift
__Snapshots__/
  FeedViewSnapshotTests/
    test_loadedState.1.png
    test_loadedState_darkMode.1.png
    test_loadedState_largeTextSize.1.png
    test_loadingState.1.png
    test_errorState.1.png
```

Keep test function names descriptive and consistent. They become the filenames of your reference images, which you review in code review like any other asset.

* * *

## What Snapshot Testing Catches That Unit Tests Miss

```swift
// This unit test passes ✅
func test_errorState_setsErrorMessage() async {
    let (sut, _) = makeSUT(loadResult: .failure(anyError()))
    await sut.onAppear()
    XCTAssertNotNil(sut.errorMessage)
}

// But the snapshot test catches this ❌
// The error message label is white text on a white background —
// invisible to the user, but correct according to the unit test.
```

Unit tests verify **values**. Snapshot tests verify **appearance**. You need both.

* * *

## What You Gain

| Without Snapshot Tests | With Snapshot Tests |
| --- | --- |
| Visual regressions discovered by QA or users | Caught on every CI run |
| Dark mode broken silently | Test fails with a diff image |
| Large text truncates labels unnoticed | Accessibility trait test catches it |
| Reviewing UI changes requires running the app | Review a PNG in the pull request |
| Refactors break layouts unexpectedly | Intentional changes require explicit snapshot update |

* * *

## Summary

Snapshot testing is the missing layer between unit tests and full UI automation. It answers the question unit tests cannot: **does the view actually look right?**

In SwiftUI:

1.  Drive views into **each meaningful visual state** using a stub ViewModel — synchronously, with no infrastructure.
    
2.  Capture a reference image for each state and commit it to version control.
    
3.  Test **across dimensions**: light/dark mode, Dynamic Type sizes, different device sizes.
    
4.  Update references **intentionally** — a failing snapshot is a signal that something visual changed, expected or not.
    
5.  Keep snapshot tests focused on appearance. Let unit tests handle logic.
    

Together, unit tests and snapshot tests form a safety net where logic regressions and visual regressions are each caught by the tool best suited to detect them.
