iOS Snapshot Testing with swift-snapshot-testing: A Complete Guide

iOS Snapshot Testing with swift-snapshot-testing: A Complete Guide

UI bugs are the ones users actually notice. A misaligned button, a truncated label, a broken dark mode layout — none of these are caught by a unit test that only verifies a view model property. Snapshot testing exists to close this gap. It captures the visual output of your UI and compares it against a known-good reference, failing your test suite the moment anything changes unexpectedly.

Point-Free's swift-snapshot-testing library is the most capable and flexible snapshot testing tool available for Swift. It goes well beyond pixel comparison — it can snapshot any Swift value, not just views. This guide walks through everything you need to know to add snapshot testing to your iOS project.

What Snapshot Testing Is and Why It Matters

A snapshot test works in three phases. On the first run, or whenever you set a recording flag, the test renders the subject — a view, a view controller, a data model — and saves that output to disk as a reference artifact. On subsequent runs, the test renders the subject again and compares the result against the saved reference. If anything differs, the test fails.

This approach catches regressions that unit tests miss entirely. If a designer adjusts a font size, a constraint breaks under a longer localized string, or a refactor changes how cells are laid out, a snapshot test will surface it immediately. The comparison is objective: either the output matches the reference or it does not.

swift-snapshot-testing extends this concept in a powerful direction. It introduces the idea of "snapshot strategies," which are type-safe descriptions of how to convert any value into a comparable artifact. The .image strategy renders a view as a PNG. The .recursiveDescription strategy captures the full UIKit or SwiftUI view hierarchy as text. The .json strategy serializes an Encodable value. You can even define your own strategies for custom types.

Installation via Swift Package Manager

Adding swift-snapshot-testing to your project takes about a minute. Open your project in Xcode, go to File > Add Packages, and enter the repository URL:

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

Select the version you want (the library follows semantic versioning) and add it to your test target only — there is no reason to include it in your app binary.

Alternatively, add it to your Package.swift dependencies:

dependencies: [
    .package(
        url: "https://github.com/pointfreeco/swift-snapshot-testing",
        from: "1.15.0"
    )
],
targets: [
    .testTarget(
        name: "MyAppTests",
        dependencies: [
            "MyApp",
            .product(name: "SnapshotTesting", package: "swift-snapshot-testing")
        ]
    )
]

Once added, import the module in your test files:

import SnapshotTesting
import XCTest

Basic Usage: assertSnapshot and Snapshot Strategies

The core function is assertSnapshot(matching:as:). It takes the value being tested and the strategy to apply:

func testProfileViewController() {
    let vc = ProfileViewController(user: .mockUser)
    assertSnapshot(matching: vc, as: .image)
}

The first time this test runs, swift-snapshot-testing creates a __Snapshots__ directory next to your test file and saves a PNG named after the test function. The test passes. On every subsequent run, it renders the view controller again and compares pixels. Any difference causes an XCTest failure with a diff image showing exactly what changed.

The library ships with several built-in strategies:

.image renders UIView, UIViewController, CALayer, SwiftUI View, and NSView (macOS) as PNG images. This is the most common strategy for UI testing.

.recursiveDescription captures the full view hierarchy as a text tree — the same output as debugDescription on a UIView, but formatted for diffing. Useful when you want to verify the structure of a layout without depending on pixel-perfect rendering.

.json serializes any Encodable type to a pretty-printed JSON string. This makes it easy to snapshot API responses, view model state, or any structured data.

.dump uses Swift's dump() function to capture a structured text representation of any value. This is the most general-purpose strategy.

Image Snapshots for UIViewController and SwiftUI Views

For UIViewController, the .image strategy renders the view at a default device size. You can control the size and configuration:

func testLoginScreen() {
    let vc = LoginViewController()
    assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
}

For SwiftUI views, wrap them in a hosting controller or use the view directly:

import SwiftUI

func testCardView() {
    let view = CardView(title: "Hello", subtitle: "World")
    assertSnapshot(matching: view, as: .image)
}

The library handles the hosting controller internally when you pass a SwiftUI View directly. This means you do not need to write boilerplate for every test.

Recording Snapshots: isRecording Flag and File Location

When you want to capture a new reference, or update an existing one, set the isRecording flag:

func testLoginScreen() {
    isRecording = true  // Remove this after recording
    let vc = LoginViewController()
    assertSnapshot(matching: vc, as: .image(on: .iPhone13Pro))
}

With isRecording = true, the test always saves a new reference and always passes. Once you have confirmed the reference looks correct in Xcode's file navigator, remove the flag and the test will start comparing against it.

Snapshot files are saved to __Snapshots__/<TestClassName>/ relative to the test source file. The filenames follow the pattern <testFunctionName>.<strategy>.<index>, for example testLoginScreen.image.png. These files should be committed to your repository — they are the source of truth for your UI.

You can also set isRecording globally for a test class to re-record all snapshots at once, which is useful after a major UI redesign.

Updating Snapshots After Intentional UI Changes

When your UI changes intentionally — a new design, a rebranded color palette, a layout adjustment — you need to update the reference snapshots. The workflow is straightforward:

  1. Set isRecording = true globally in the relevant test class or test file
  2. Run the tests to regenerate all references
  3. Review the new snapshots in Finder or Xcode's file navigator
  4. Remove the isRecording flag
  5. Commit the updated snapshot files along with your UI changes

This workflow makes the change deliberate and reviewable. A code reviewer can see both the code change and the resulting UI difference in the same pull request, because the snapshot files are committed to source control.

Testing Dark Mode and Different Device Sizes

swift-snapshot-testing makes it easy to test your UI across multiple configurations. The viewImageConfig strategy accepts a ViewImageConfig value that specifies traits:

func testHomeScreen_darkMode() {
    let vc = HomeViewController()
    assertSnapshot(
        matching: vc,
        as: .image(on: .iPhone13Pro, traits: .init(userInterfaceStyle: .dark))
    )
}

You can run multiple snapshot assertions in a single test function to cover several configurations at once:

func testCardView_configurations() {
    let view = CardView(title: "Test")

    assertSnapshot(matching: view, as: .image(on: .iPhone13), named: "light_small")
    assertSnapshot(matching: view, as: .image(on: .iPhone13ProMax), named: "light_large")
    assertSnapshot(
        matching: view,
        as: .image(on: .iPhone13, traits: .init(userInterfaceStyle: .dark)),
        named: "dark_small"
    )
}

The named parameter gives each snapshot a unique suffix so all three references are stored as separate files. This is the idiomatic way to cover a matrix of configurations without writing a separate test function for each.

Predefined configurations include .iPhone8, .iPhone13, .iPhone13Pro, .iPhone13ProMax, .iPad10_2, .iPadPro12_9, and several others. You can also define custom configurations with explicit sizes and trait collections.

Snapshot Testing for Non-UI Types

One of swift-snapshot-testing's most powerful capabilities is that it works with any Swift type, not just views. This makes it useful for testing API responses, view model state, and custom data structures.

Testing a JSON response from an API client:

struct UserResponse: Encodable {
    let id: Int
    let name: String
    let email: String
}

func testUserAPIResponse() {
    let response = UserResponse(id: 42, name: "Alice", email: "alice@example.com")
    assertSnapshot(matching: response, as: .json)
}

The .json strategy serializes the value to a pretty-printed JSON string and saves it as a .txt file. Any change to the response shape — a new field, a renamed key, a changed value type — will cause the test to fail.

You can also write custom strategies for domain-specific needs. For example, a strategy that serializes your app's navigation state, or one that extracts accessibility labels from a view hierarchy for localization testing.

CI Integration: Managing Snapshot Artifacts and Platform Differences

Snapshot files must be committed to your repository. This is non-negotiable — without committed references, the tests cannot compare anything on CI. Configure your .gitignore to ensure snapshot directories are not excluded.

The most significant challenge with snapshot testing on CI is rendering consistency. Font rendering, anti-aliasing, and some UIKit behaviors differ between macOS versions and iOS simulator versions. A snapshot recorded on a developer's MacBook Pro may not match pixel-for-pixel when rendered on a CI machine running a different macOS version.

The practical solution is to record your canonical snapshots on CI itself. Run your tests once on the CI environment with isRecording = true, commit the resulting artifacts, then disable recording. From that point forward, the CI environment is the reference environment.

For GitHub Actions, this means using a consistent macos runner image version and pinning it:

runs-on: macos-14

If you must support comparison across environments, use the .recursiveDescription strategy instead of .image for tests that are likely to have rendering differences. View hierarchy text is far more stable across environments than pixel data.

Some teams configure snapshot test failures as warnings rather than errors in CI, particularly during active development, promoting them to hard failures only before merging to main. This is a policy decision that depends on how stable your UI is.

Common Pitfalls

Font rendering differences between CI and local machines. As described above, this is the most frequent source of spurious failures. Solve it by canonicalizing your reference environment.

Forgetting to commit snapshot files. New snapshots only exist on the developer's machine until committed. Set up a CI check that fails if there are uncommitted snapshot changes after running tests.

Setting isRecording = true without reviewing the output. Always open the generated snapshot and verify it looks correct before removing the flag. The test passes while recording regardless of what is rendered.

Testing too many configurations. Snapshot tests are fast but not free. Testing every combination of device size, trait, and localization for every screen will create thousands of files and slow down your suite. Be strategic — test the configurations that represent real user scenarios.

Not resetting state between tests. Snapshots depend on the state of the view when rendered. If tests share mutable state, snapshot results may depend on test execution order. Keep tests isolated.


Snapshot testing with swift-snapshot-testing is one of the highest-leverage additions you can make to an iOS test suite. It catches the category of bug that unit tests cannot reach — visual regressions, layout breaks, dark mode failures — and it documents your UI's intended appearance in a form that non-engineers can review.

HelpMeTest extends your testing suite with 24/7 visual monitoring and AI-powered test generation — start free at helpmetest.com.

Read more