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
- Layer 1: Testing View Models in Isolation
- Layer 2: Snapshot Testing with
swift-snapshot-testing - Layer 3: View Hierarchy Inspection with ViewInspector
- Layer 4: Integration Tests with XCUITest
- Advanced Usage
- When to Use (and When Not To)
- Summary
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-testingis an XCTest-based library as of version 1.x. The tests in this section useXCTestCase. 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
Inspectablefor views that use@Stateor@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)
| Scenario | Recommendation |
|---|---|
| Business logic (fetching, filtering, transforming data) | View model unit tests — highest ROI, fastest feedback |
| Visual regressions after refactor or design system update | Snapshot tests — catch unintended rendering changes |
| Verifying specific view hierarchy details, accessibility labels | ViewInspector — 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 XCUITest | Avoid — fragile, slow, diminishing returns |
| A view with no view model and no injectable dependencies | Refactor 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-testingcatch 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
XCUITestprovide end-to-end confidence but are slow and fragile — reserve them for critical flows and keep the suite small. - Anchor UI tests with
accessibilityIdentifierrather 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.