Skip to main content

Command Palette

Search for a command to run...

Snapshot Testing in SwiftUI: Catching Visual Regressions Automatically

Updated
7 min readView as Markdown
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 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:

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

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

// 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:

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:

__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:

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:

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.

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:

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:

// 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:

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:

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

// 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.