Testing SwiftUI Views: View Models, Snapshots, and Integration Tests


“SwiftUI views can’t be tested” is one of those half-truths that becomes an excuse for untested production code. You can’t instantiate a View and call .films on it directly — but that’s never been where the interesting bugs live anyway. The logic that fetches data, transforms it, handles errors, and drives state transitions is fully testable. The rendered output can be snapshot-compared. End-to-end user flows can be exercised with XCUITest.

This post covers all three testing layers for SwiftUI: view model isolation tests with Swift Testing, snapshot tests with swift-snapshot-testing, and a pragmatic look at ViewInspector and XCUITest for the cases where they earn their keep. We won’t cover general Swift Testing syntax — that’s in Unit Testing in Swift: Getting Started with the Swift Testing Framework.

Contents

The Problem: Logic Baked Into the View

Here is a FilmListView that fetches Pixar films and displays them. It works — but it cannot be tested at all:

struct FilmListView: View {
    @State private var films: [PixarFilm] = []
    @State private var isLoading = false
    @State private var errorMessage: String?

    // Repository is instantiated directly — impossible to substitute a mock
    private let repository = FilmRepository()

    var body: some View {
        Group {
            if isLoading {
                ProgressView("Fetching films…")
            } else if let error = errorMessage {
                Text(error).foregroundStyle(.red)
            } else {
                List(films) { film in
                    // Sorting and filtering logic inside the View body
                    Text(film.title)
                }
            }
        }
        .task {
            isLoading = true
            do {
                films = try await repository.fetchAll()
                    .filter { $0.year >= 1995 }
                    .sorted { $0.title < $1.title }
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
}

The problems are concrete: the repository is instantiated inside the view, so you can’t inject a mock. The filtering and sorting logic lives in .task, mixed with loading state mutations. There’s no way to write a test that confirms the view shows an error message when the repository throws, because the dependency is hardcoded.

The solution is MVVM: move the logic into a view model that can be initialized with injected dependencies, then test the view model in complete isolation from SwiftUI’s rendering machinery.

Layer 1: Testing View Models in Isolation

This is the most valuable testing layer for SwiftUI apps. A well-designed view model encapsulates all testable behavior, leaving the view as a pure projection of that state.

Defining the Protocol and Mock

Start with a protocol so you can inject a mock during tests:

protocol FilmRepositoryProtocol: Sendable {
    func fetchAll() async throws -> [PixarFilm]
    func fetchByID(_ id: String) async throws -> PixarFilm?
}

// Production implementation
final class FilmRepository: FilmRepositoryProtocol {
    func fetchAll() async throws -> [PixarFilm] {
        // Real network call
    }
    func fetchByID(_ id: String) async throws -> PixarFilm? {
        // Real network call
    }
}

// Test double
final class MockFilmRepository: FilmRepositoryProtocol, @unchecked Sendable {
    var stubbedFilms: [PixarFilm]
    var shouldThrow: Bool
    private(set) var fetchAllCallCount = 0

    init(films: [PixarFilm] = [], shouldThrow: Bool = false) {
        self.stubbedFilms = films
        self.shouldThrow = shouldThrow
    }

    func fetchAll() async throws -> [PixarFilm] {
        fetchAllCallCount += 1
        if shouldThrow { throw FilmError.networkUnavailable }
        return stubbedFilms
    }

    func fetchByID(_ id: String) async throws -> PixarFilm? {
        stubbedFilms.first { $0.id == id }
    }
}

The Testable View Model

@Observable
@MainActor
final class FilmListViewModel {
    private(set) var films: [PixarFilm] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    private let repository: any FilmRepositoryProtocol

    init(repository: any FilmRepositoryProtocol) {
        self.repository = repository
    }

    func loadFilms() async {
        isLoading = true
        errorMessage = nil
        do {
            let fetched = try await repository.fetchAll()
            films = fetched
                .filter { $0.year >= 1995 }
                .sorted { $0.title < $1.title }
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

Writing the Tests

With the view model accepting a protocol, testing all state transitions becomes straightforward:

import Testing

@Suite("Film List View Model")
@MainActor
struct FilmListViewModelTests {

    @Test("Loading films populates the films array in sorted order")
    func loadingFilmsPopulatesArray() async throws {
        let mockRepo = MockFilmRepository(films: [
            PixarFilm(id: "wall-e",    title: "WALL·E",    year: 2008),
            PixarFilm(id: "toy-story", title: "Toy Story", year: 1995),
            PixarFilm(id: "coco",      title: "Coco",      year: 2017)
        ])
        let viewModel = FilmListViewModel(repository: mockRepo)

        await viewModel.loadFilms()

        #expect(viewModel.films.count == 3)
        #expect(viewModel.films.first?.title == "Coco")   // sorted alphabetically
        #expect(viewModel.films.last?.title == "Toy Story")
        #expect(!viewModel.isLoading)
        #expect(viewModel.errorMessage == nil)
    }

    @Test("Loading films sets isLoading to false after completion")
    func isLoadingIsFalseAfterLoad() async throws {
        let viewModel = FilmListViewModel(repository: MockFilmRepository())

        await viewModel.loadFilms()

        #expect(!viewModel.isLoading)
    }

    @Test("Repository failure sets errorMessage")
    func repositoryFailureSetsError() async throws {
        let mockRepo = MockFilmRepository(shouldThrow: true)
        let viewModel = FilmListViewModel(repository: mockRepo)

        await viewModel.loadFilms()

        #expect(viewModel.films.isEmpty)
        #expect(viewModel.errorMessage != nil)
        #expect(!viewModel.isLoading)
    }

    @Test("Films released before 1995 are filtered out")
    func prePixarFilmsAreFiltered() async throws {
        let mockRepo = MockFilmRepository(films: [
            PixarFilm(id: "toy-story",     title: "Toy Story",    year: 1995),
            // Pre-Pixar era — should be filtered
            PixarFilm(id: "fantasia",      title: "Fantasia",     year: 1940),
            PixarFilm(id: "finding-nemo",  title: "Finding Nemo", year: 2003)
        ])
        let viewModel = FilmListViewModel(repository: mockRepo)

        await viewModel.loadFilms()

        #expect(viewModel.films.count == 2)
        #expect(!viewModel.films.contains { $0.title == "Fantasia" })
    }
}

Notice that @MainActor is applied to the entire suite, not just individual tests. Because FilmListViewModel is @MainActor-isolated (a requirement for @Observable view models that drive UI), all accesses to it must happen on the main actor. Annotating the suite once is cleaner than annotating every @Test.

Test Data Builders

For test suites that create many PixarFilm instances, a builder with defaults keeps tests concise:

extension PixarFilm {
    static func fixture(
        id: String = "toy-story",
        title: String = "Toy Story",
        year: Int = 1995,
        director: String = "John Lasseter"
    ) -> PixarFilm {
        PixarFilm(id: id, title: title, year: year, director: director)
    }
}

Tests can then override only the fields they care about:

@Test("Filtering keeps only films after 2000", arguments: [2001, 2010, 2017, 2023])
func filterKeepsFilmsAfterYear(year: Int) async throws {
    let mockRepo = MockFilmRepository(films: [
        .fixture(title: "The Oldest", year: 1999),
        .fixture(title: "The Included", year: year)
    ])
    let viewModel = FilmListViewModel(repository: mockRepo, minimumYear: 2000)

    await viewModel.loadFilms()

    #expect(viewModel.films.count == 1)
    #expect(viewModel.films.first?.year == year)
}

Layer 2: Snapshot Testing with swift-snapshot-testing

View model tests verify logic. They tell you nothing about whether a button’s label renders at the right size, whether dark mode breaks a card’s contrast, or whether a new iOS version changed how List renders separators. Snapshot tests fill this gap.

swift-snapshot-testing by Point-Free renders a SwiftUI view to an image and compares it byte-for-byte against a reference image committed to your repository. If they differ, the test fails and saves a diff image showing exactly what changed.

Note: swift-snapshot-testing is an XCTest-based library as of version 1.x. The tests in this section use XCTestCase. A Swift Testing–native API is in development.

Adding the Package

In Package.swift (or via Xcode’s package manager UI):

.package(
    url: "https://github.com/pointfreeco/swift-snapshot-testing",
    from: "1.17.0"
)

Add it as a test-target dependency only — it doesn’t need to be linked into your app target.

Recording Reference Snapshots

The first time you run a snapshot test, set record: true to write the reference image:

import XCTest
import SnapshotTesting
import SwiftUI

final class FilmCardSnapshotTests: XCTestCase {

    func testFilmCardDefaultState() {
        let card = FilmCard(film: .fixture(title: "Up", year: 2009))
            .frame(width: 320)

        assertSnapshot(
            of: card,
            as: .image(layout: .fixed(width: 320)),
            record: true  // ← Remove after recording
        )
    }
}

After the first run, a __Snapshots__ directory appears next to the test file containing testFilmCardDefaultState.1.png. Commit this file. On subsequent runs with record: false (the default), the test renders the view again and compares it to the stored reference.

Testing Multiple Configurations

The real value of snapshot testing comes from systematically verifying all the configurations your users encounter:

final class FilmCardSnapshotTests: XCTestCase {

    func testFilmCardLightMode() {
        let card = FilmCard(film: .fixture(title: "Ratatouille", year: 2007))
            .frame(width: 320)
            .environment(\.colorScheme, .light)

        assertSnapshot(of: card, as: .image(layout: .fixed(width: 320)))
    }

    func testFilmCardDarkMode() {
        let card = FilmCard(film: .fixture(title: "Ratatouille", year: 2007))
            .frame(width: 320)
            .environment(\.colorScheme, .dark)

        assertSnapshot(of: card, as: .image(layout: .fixed(width: 320)))
    }

    func testFilmCardAccessibilityExtraExtraExtraLarge() {
        let card = FilmCard(film: .fixture(title: "Ratatouille", year: 2007))
            .frame(width: 320)
            .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)

        assertSnapshot(of: card, as: .image(layout: .fixed(width: 320)))
    }

    func testFilmCardOnIPhoneSE() {
        let card = FilmCard(film: .fixture(title: "Ratatouille", year: 2007))

        assertSnapshot(of: card, as: .image(on: .iPhone8))
    }

    func testFilmCardOnIPadPro() {
        let card = FilmCard(film: .fixture(title: "Ratatouille", year: 2007))

        assertSnapshot(of: card, as: .image(on: .iPadPro11))
    }
}

Warning: Snapshot tests are device- and OS-specific. A snapshot recorded on a Mac with Xcode 16 / iOS 18 simulator will fail on a machine running iOS 17. Pin your CI simulator configuration and commit snapshots only from that environment.

When Snapshots Fail

When assertSnapshot fails, it writes three images to disk: the reference, the recorded failure, and a visual diff. The diff highlights the changed pixels in red, making it immediately obvious whether the failure is a legitimate regression or an expected design change. If it’s intentional, re-run with record: true to update the reference.

Layer 3: View Hierarchy Inspection with ViewInspector

ViewInspector takes a different approach: rather than rendering a view to pixels, it traverses the SwiftUI view hierarchy and lets you make assertions about its structure and content.

import XCTest
import ViewInspector

final class FilmCardInspectorTests: XCTestCase {

    func testFilmCardDisplaysCorrectTitle() throws {
        let film = PixarFilm.fixture(title: "Soul", year: 2020)
        let card = FilmCard(film: film)

        let titleText = try card.inspect().find(viewWithId: "filmTitle")
        XCTAssertEqual(try titleText.text().string(), "Soul")
    }

    func testEmptyStateMessageIsShown() throws {
        let view = FilmListView(viewModel: FilmListViewModel(
            repository: MockFilmRepository(films: [])
        ))
        // After loading, the empty state view should be in the hierarchy
        let emptyLabel = try view.inspect().find(text: "No films found")
        XCTAssertNotNil(emptyLabel)
    }
}

ViewInspector is most useful when you need to assert on specific view hierarchy details — accessibility labels, button text, binding values — without the overhead of a full snapshot or a UI test launch.

Note: ViewInspector requires view conformance to Inspectable for views that use @State or @Binding. The library’s README has clear setup instructions per SwiftUI version.

Layer 4: Integration Tests with XCUITest

XCUITest launches your actual app binary in a simulator, drives it through XCUIApplication, and makes assertions against what’s visible on screen. It’s the closest thing to a user actually using your app — and the slowest, most fragile testing layer as a result.

Use UI tests sparingly: focus them on critical user flows (login, checkout, onboarding) rather than exhaustively testing every screen.

Accessibility Identifiers as Test Hooks

Never test against display strings like "Toy Story" in UI tests — a localization change will break every test. Instead, use accessibilityIdentifier as a stable test hook:

// In your view
List(films) { film in
    FilmCard(film: film)
        .accessibilityIdentifier("filmCard-\(film.id)")
}

// Search bar
TextField("Search films…", text: $searchText)
    .accessibilityIdentifier("filmSearchBar")
// In your UI test
final class FilmListUITests: XCTestCase {

    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        // Inject launch arguments to tell the app to use mock data
        app.launchArguments = ["--use-mock-data"]
        app.launch()
    }

    func testSearchFiltersFilmList() {
        let searchBar = app.textFields["filmSearchBar"]
        searchBar.tap()
        searchBar.typeText("Coco")

        // After typing, only the "Coco" card should be visible
        XCTAssertTrue(app.otherElements["filmCard-coco"].waitForExistence(timeout: 2))
        XCTAssertFalse(app.otherElements["filmCard-toy-story"].exists)
    }

    func testTappingFilmCardNavigatesToDetail() {
        let toyStoryCard = app.otherElements["filmCard-toy-story"]
        XCTAssertTrue(toyStoryCard.waitForExistence(timeout: 2))
        toyStoryCard.tap()

        // Detail view should be on screen
        XCTAssertTrue(app.staticTexts["filmDetailTitle"].waitForExistence(timeout: 2))
    }
}

The --use-mock-data launch argument is checked in AppDelegate or @main to configure the app with a mock repository. This prevents UI tests from making real network calls, which makes them faster and reliable in CI.

Tip: Keep your UI test suite small and focused. A suite of 10–15 high-value flow tests is far more sustainable than 200 fragile element-by-element assertions.

Advanced Usage

Testing Navigation

To verify that tapping a FilmCard navigates to a FilmDetailViewModel containing the correct film, inject the navigation path through the view model:

@Observable
@MainActor
final class FilmListViewModel {
    var navigationPath = NavigationPath()
    // ...

    func selectFilm(_ film: PixarFilm) {
        navigationPath.append(film)
    }
}
@Test("Selecting a film appends it to the navigation path")
@MainActor
func selectingFilmUpdatesNavigationPath() async throws {
    let viewModel = FilmListViewModel(repository: MockFilmRepository(
        films: [.fixture(id: "up", title: "Up", year: 2009)]
    ))
    await viewModel.loadFilms()

    let film = try #require(viewModel.films.first)
    viewModel.selectFilm(film)

    #expect(viewModel.navigationPath.count == 1)
}

Testing Async UI State with Continuations

When view model state updates happen across multiple async steps and you need fine-grained control, withCheckedContinuation lets you observe state mid-flight:

@Test("isLoading is true during a slow fetch")
@MainActor
func isLoadingIsTrueDuringFetch() async throws {
    // A mock that suspends before returning
    let slowRepo = SlowMockFilmRepository(delay: .seconds(1))
    let viewModel = FilmListViewModel(repository: slowRepo)

    let task = Task { await viewModel.loadFilms() }

    // Give the task a moment to start
    try await Task.sleep(for: .milliseconds(100))

    #expect(viewModel.isLoading)

    // Wait for it to finish
    await task.value
    #expect(!viewModel.isLoading)
}

Preview-Based Living Documentation

#Preview macros serve as lightweight living documentation for your views. They’re not automated tests — Xcode won’t fail a build if a preview crashes — but they’re invaluable for catching visual regressions during development and reviewing component states in isolation:

#Preview("Loading state") {
    FilmListView(viewModel: {
        let vm = FilmListViewModel(repository: MockFilmRepository())
        vm.isLoading = true // set loading state directly for the preview
        return vm
    }())
}

#Preview("Error state") {
    FilmListView(viewModel: {
        let vm = FilmListViewModel(repository: MockFilmRepository())
        vm.errorMessage = "Unable to connect to Pixar servers."
        return vm
    }())
}

#Preview("Populated state") {
    FilmListView(viewModel: {
        let vm = FilmListViewModel(repository: MockFilmRepository(
            films: [
                .fixture(title: "Toy Story"),
                .fixture(id: "soul", title: "Soul", year: 2020)
            ]
        ))
        return vm
    }())
}

Consider your previews a form of test specification: if you have a preview for every meaningful view state, you have a visual checklist to verify after each UI change.

When to Use (and When Not To)

ScenarioRecommendation
Business logic (fetching, filtering, transforming data)View model unit tests — highest ROI, fastest feedback
Visual regressions after refactor or design system updateSnapshot tests — catch unintended rendering changes
Verifying specific view hierarchy details, accessibility labelsViewInspector — lighter than a full snapshot
Critical user flows end-to-end (login, onboarding, checkout)XCUITest — slow but provides the highest confidence
Exhaustively testing every screen with XCUITestAvoid — fragile, slow, diminishing returns
A view with no view model and no injectable dependenciesRefactor first, then test — logic in View bodies is untestable
Accessibility correctness (VoiceOver, Dynamic Type)Combination of ViewInspector assertions + manual audit

Summary

  • The most valuable testing layer for SwiftUI is the view model: inject dependencies through protocols, test every state transition in isolation with Swift Testing.
  • Use test data builders (PixarFilm.fixture(...)) to keep test setup concise and focused on the property under test.
  • Snapshot tests with swift-snapshot-testing catch visual regressions across dark mode, Dynamic Type sizes, and device form factors — commit reference images to your repository.
  • ViewInspector allows structural hierarchy assertions without rendering; use it for targeted checks on accessibility identifiers, text content, and binding values.
  • UI tests with XCUITest provide end-to-end confidence but are slow and fragile — reserve them for critical flows and keep the suite small.
  • Anchor UI tests with accessibilityIdentifier rather than display strings to survive localization changes.

With solid view model and snapshot coverage in place, the next step is ensuring your dependency graph supports testability at scale. Dependency Injection in Swift covers constructor injection, environment-based injection, and the patterns that make large SwiftUI apps straightforward to test.