How do you test SwiftUI: view models, hosting a view, ImageRenderer snapshots and ViewInspector?
Predict first
An @Observable cart model is tested without any view. What are the totals, and would a view reading the total be invalidated by adding an item?
import Foundation
import Observation
@Observable final class CartModel {
var items: [(name: String, price: Double)] = []
var discountPercent = 0.0
var total: Double { items.reduce(0) { $0 + $1.price } * (1 - discountPercent / 100) }
func add(_ name: String, _ price: Double) { items.append((name, price)) }
}
let model = CartModel()
model.add("Tea", 4); model.add("Cake", 6)
print(model.total)
model.discountPercent = 10
print(model.total)
var changed = 0
withObservationTracking { _ = model.total } onChange: { changed += 1 }
model.add("Tip", 1)
print("view would be invalidated:", changed)
- 10.0, then 9.0, then invalidated 1
- 10.0 both times, invalidated 0
- 9.0, then 10.0, invalidated 1
Predict first
A 100×50 SwiftUI view is rendered with ImageRenderer at scale 1. What size is the image?
import SwiftUI
@MainActor func run() {
let renderer = ImageRenderer(content: Text("Hi").frame(width: 100, height: 50).background(Color.red))
renderer.scale = 1
if let image = renderer.cgImage { print("\(image.width)x\(image.height)") } else { print("no image") }
}
MainActor.assumeIsolated { run() }
- 100x50
- 300x150
- no image
Mental model. You test a recipe by tasting, not by photographing the plate. Put the cooking in a model you can taste directly, and glance at the plated dish with a snapshot.
A SwiftUI View is a value that describes UI; there is no object to poke, so the first and best strategy is to test the logic, not the view. Move decisions (validation, formatting, sorting, state machines, what to show for each state) into an @Observable model or a plain type, and test that with ordinary unit tests: call methods, assert on properties, and even assert which reads the view would depend on, as the check below does with withObservationTracking. This is also what makes the view thin enough that there is little left to test. Test how the model reacts to async events (a stubbed repository, a controlled clock) rather than rendering it.
For the view layer you have three options with different costs. Hosting: put the view in a UIHostingController inside a window, run layout, and inspect the resulting UIKit hierarchy or accessibility elements (also how you can count how often bodies run, Q433). Snapshots: render with ImageRenderer (the check below renders a 100×50 view at scale 1 and gets exactly that size) or a snapshot library, and compare to references (see the snapshot question). ViewInspector, an open-source library, walks a view’s structure by reflection so unit tests can find a Text and tap a Button without a device; it is convenient, but it depends on SwiftUI’s internal layout, which can change between OS releases, and it cannot see everything (some modifiers and system views are opaque). For end-to-end behaviour use a few UI tests with accessibility identifiers (see the UI-test question). Do not treat Xcode Previews as tests, although a preview per state is a good manual and snapshot fixture.
The total is plain, testable logic; and reading it registers a dependency, so adding an item notifies the observer once.
At scale 1 each point is one pixel, so the image matches the view’s size; a different scale multiplies it.
Common traps
- Putting logic inside
bodyor in view modifiers. The only way to test it is through the screen. - Building a large suite on ViewInspector internals. An OS update can change the view tree and break many tests at once.
They might follow up with
- Where do you test navigation? Model the route as state (a path or enum) and assert on the state; use a UI test only for the wiring.
- Can you unit-test
@Environmentand@AppStoragereads? Inject the values through the model or through the environment of a hosted view, not through globals.
Related questions
- 22.5.1 How do snapshot tests work, and what are their pitfalls: rendering differences, dynamic content, size, and…
- 22.5.2 How do you write UI tests that stay stable: accessibility identifiers, page objects, launch arguments…
- 8.3.3 How does the Observation framework differ from ObservableObject, and how does tracking work?
- 21.4.1 What makes a SwiftUI body run again, how do you see it, and how do you reduce unnecessary invalidation?
How do you test tvOS focus and remote input, and watchOS connectivity… →