Skip to content

Latest commit

 

History

History
204 lines (158 loc) · 7.5 KB

GettingStarted.md

File metadata and controls

204 lines (158 loc) · 7.5 KB

Getting Started

Write your first snapshot suite, then record its reference images.

Overview

VisualTesting is built on UIKit, so its snapshots run on an iOS Simulator through xcodebuild rather than swift test. Add the VisualTesting product to a test target and you are ready to write a suite; installation is covered in the README.

Capturing a view across the default matrix

Mark a struct with @SnapshotSuite, and give it one function per state you want to see.

import Testing
import SwiftUI
import VisualTesting

@SnapshotSuite("ProfileView")
@MainActor
struct ProfileViewSnapshots {

    // Each @Snapshot function returns the view to capture.
    @Snapshot
    func loggedIn() -> some View {
        ProfileView(user: .preview)
    }

    @Snapshot
    @InNavigation
    func loggedInWithNav() -> some View {
        ProfileView(user: .preview)
    }

    @Snapshot
    @WithoutAnimation
    func loading() -> some View {
        ProfileView(user: nil)
    }

    // The runner is hand-written; @SnapshotSuite emits a compile error when it is missing.
    @Test func snapshots() {
        for snapshotCase in Self.__snapshotCases {
            snapshotCase.run()
        }
    }
}

Reference images land in Tests/MyFeatureTests/__Snapshots__/ProfileView/, one subdirectory per device. Later runs compare against them.

The runner cannot be generated for you. Expanding @Test inside a macro-generated declaration makes the compiler lose its lexical context, and swift-testing's test records then fail to compile inside the type. Keep the runner in the suite's own file too: run() defaults file to the caller's #filePath, and that is what puts __Snapshots__ next to the test source.

Capturing a design-system component

Components use the theme axis alone — no device frame, no locale — at a size you specify. Leave the size out and the view's intrinsic size is used instead.

WithoutAnimation() applies here as well. InNavigation() does not, and saying it is a compile error: without a device frame there is no navigation bar for the image to hold.

@SnapshotSuite("PrimaryButton")
@MainActor
struct PrimaryButtonSnapshots {

    @ComponentSnapshot(width: 200, height: 50)
    func idle() -> some View {
        PrimaryButton("Tap me")
    }

    @ComponentSnapshot(width: 200, height: 50)
    func disabled() -> some View {
        PrimaryButton("Tap me").disabled(true)
    }

    @Test func snapshots() {
        for snapshotCase in Self.__snapshotCases {
            snapshotCase.run()
        }
    }
}

Output is __Snapshots__/PrimaryButton/idle.light.png and its dark counterpart.

Changing the matrix

Pass a SnapshotConfiguration to run(configuration:) to narrow or widen a single suite.

@Test func snapshots() {
    let config = SnapshotConfiguration(
        devices: [.iPhone16],
        themes: [.light],
        locales: ["en"]
    )
    for snapshotCase in Self.__snapshotCases {
        snapshotCase.run(configuration: config)
    }
}

Two axes are off by default because turning them on costs images in every suite that adopts them:

  • iPad window widths. SnapshotDevice/iPadPro11Half and SnapshotDevice/iPadPro11Third cover the sizes the HIG asks you to verify now that iPadOS 26 has replaced Split View with freely resizable windows. Both land in the compact horizontal size class, so what they show is an iPad falling into the same layout branch as an iPhone.
  • Accessibility text size. SnapshotDynamicType/accessibility3 is where large-text breakage becomes visible: line heights, vertical centring against icons, where truncation lands. The largest size is not the checkpoint, because it breaks no matter what and so cannot signal a regression. Reference images at the standard size keep their existing names, so adding this axis never invalidates what you already recorded.

Integrating a custom theme system

By default the theme axis is applied through the colorScheme environment. If appearance in your app is driven by something else, implement ThemeApplicable so snapshots go through the same path.

struct AppThemeApplicable: ThemeApplicable {
    @MainActor
    func applyTheme<V: View>(_ view: V, theme: SnapshotTheme) -> AnyView {
        let provider = ThemeProvider()
        provider.themeMode = theme == .light ? .light : .dark
        return AnyView(view.theme(provider))
    }
}

Assign it once — it is process-wide state, so every suite in the test run captures through it.

@MainActor
func setupVisualTesting() {
    VisualTesting.themeApplicable = AppThemeApplicable()
}

Call setupVisualTesting() from each suite's init().

Recording reference images

There is nothing to compare against on the first run, and a deliberate design change needs the images re-recorded. Both are done with swift-snapshot-testing's record mode.

Because the tests run under xcodebuild, the variable needs the TEST_RUNNER_ prefix: that is what makes xcodebuild forward it into the test runner process, where it is read as SNAPSHOT_TESTING_RECORD.

Use failed unless you specifically want a full refresh.

TEST_RUNNER_SNAPSHOT_TESTING_RECORD=failed xcodebuild test \
  -scheme YourScheme \
  -destination 'platform=iOS Simulator,name=iPhone 16'

failed renders every snapshot once, compares it, and writes only the ones that differ. The run passes when it finishes, so there is no second pass, and git status afterwards lists exactly the images that actually changed.

all costs roughly twice the wall clock for the same result. It rewrites every image whether or not it matched, and reports all of them as failures — nothing was compared, so nothing passed — which forces a second full run to find out whether the result is right. On a suite of 136 images that is 272 renders instead of 136, and a 136-file diff in which the handful of real changes are indistinguishable from the churn. Measured on swift-markdown-view, 2026-08-11.

missing records only images that do not exist yet, and leaves every existing one alone. That is the one to use when adding cases to a suite you do not intend to disturb.

Reach for all deliberately, when you want to flush drift that the precision tolerances have been absorbing. perceptualPrecision: 0.98 does not fail on a 2–3 unit shift, so a reference can be stale and still pass; a full refresh is how that gets swept out. In swift-design-system, 46 references had drifted that way and only 2 of them ever failed. But treat that as its own task with its own review — not as the way to re-record a fix.

Generating the gallery

Every assertion writes a manifest.json beside its images. Aggregate those into a catalog, then render the catalog as a single HTML file.

let catalog = try VisualTesting.generateCatalog(
    rootDirectory: "Tests/MyFeatureTests",
    outputPath: "snapshot-catalog.json"
)
try VisualTesting.generateGallery(catalog: catalog, outputPath: "snapshot-gallery.html")

Both throw. A manifest that cannot be read and a page that cannot be written are the two ways to end up with a gallery that is quietly missing views, and a gallery you cannot trust to be complete is worse than no gallery.

Open snapshot-gallery.html in a browser; the CSS, the JavaScript, and the catalog itself are all inlined, so no server is involved. Image paths are relative to the catalog root, so keep the HTML file there. The gallery filters by section, device, theme, and locale, searches by view name, puts light and dark side by side, and opens any image in a lightbox.