Modern Swift Testing: The Complete Guide - 2
Master Swift Testing: dependency injection patterns, auto-generated mocks with Mockolo, Cuckoo, and Sourcery, snapshot testing for UI and data models, and macro testing.
Introduction
Testing is the part of software development that most developers say they want to do more of, and the part that gets cut the moment a sprint starts slipping. Part of the reason is friction. The tooling is unfamiliar, the setup takes time, and the payoff is not visible until much later. This guide is about removing that friction.
Part 2 picks up where the part 1 leave off. You will learn how to inject dependencies properly so your tests are not at the mercy of live network calls, how to generate mock types automatically instead of writing them by hand, how to get failure messages that actually tell you what went wrong, and how to capture visual and structural snapshots of your code so regressions surface in CI instead of user reports. If all those sound good to you, let’s dive right in!
Dependency Injection for Testing
Why “Hard to Test” Usually Means “Hard to Inject”
Here is a simplified pattern most iOS developers have written at least once in their career
final class UserProfileViewModel: ObservableObject {
@Published var user: User?
@Published var errorMessage: String?
private let networkClient = NetworkClient.shared
func loadUser(id: UserID) async {
do {
self.user = try await networkClient.fetch(
Endpoint.Get(
"https://api.example.com/users/\(id)"
),
as: User.self
)
} catch {
self.errorMessage = error.localizedDescription
}
}
}
This looks completely reasonable at a first glance. But the moment you try to write a test for loadUser, you hit a wall. The view model reaches out and grabs NetworkClient.shared itself. I am not saying singleton is bad, in fact, I also use them a lot. However, there is no seam to slip in a fake network layer, no way to simulate a 401, no way to test the error path without actually hitting the internet. The test either becomes a flaky integration test or it never gets written at all.
The same pattern shows up with CLLocationManager, UserDefaults.standard, Date() called inline, or a singleton analytics client. Any time a type creates or reaches for its own collaborators, you lose control over the test environment.
The fix is always the same.
Don’t let the type decide what it works with. Hand it in from outside.
Protocol-Based Dependency Injection
The classic DI approach is to extract a protocol and inject a conforming type:
protocol NetworkClientType {
func fetchUser(id: UserID) async throws -> User
}
struct LiveNetworkClient: NetworkClientType {
func fetchUser(id: UserID) async throws -> User {
// ... actual implementation
}
}
final class UserProfileViewModel: ObservableObject {
@Published var user: User?
@Published var errorMessage: String?
private let networkClient: NetworkClientType
init(networkClient: NetworkClientType = LiveNetworkClient()) {
self.networkClient = networkClient
}
func loadUser(id: UserID) async {
do {
self.user = try await networkClient.fetchUser(id: id)
} catch {
self.errorMessage = error.localizedDescription
}
}
}
Now your test can inject a mock:
import Testing
struct MockNetworkClient: NetworkClientType {
var stubbedUser: User?
var stubbedError: Error?
func fetchUser(id: UserID) async throws -> User {
if let error = stubbedError { throw error }
return stubbedUser!
}
}
@Test func loadUserSetsUserOnSuccess() async {
let fakeUser = User(id: "1", name: "Test User")
let client = MockNetworkClient(stubbedUser: fakeUser)
let sut = UserProfileViewModel(networkClient: client)
await sut.loadUser(id: "1")
#expect(sut.user == fakeUser)
}
This works, and it is the approach you will see in most codebases. But it has real costs. Every new dependency gets its own protocol. Every mock struct duplicates the protocol’s shape. When you add a method to NetworkClientProtocol, every mock in every test file needs updating. In a large codebase, protocol proliferation becomes its own maintenance problem. Now, you can solve this via promoting the logically related methods into aspects and do the AOP dance but let’s leave this for an another blog post.
Type-alias Based DI
If you look at the shape of fetchUser(id:), you pass in a UserID and get back a User. That is all the call site actually cares about. It does not care how an endpoint is constructed, or that a NetworkClient exists at all. So you can strip the protocol away entirely and inject the function directly:
final class UserProfileViewModel: ObservableObject {
typealias FetchUser = (UserID) async throws -> User
@Published var user: User?
@Published var errorMessage: String?
private let fetchUser: FetchUser
init(fetchUser: @escaping FetchUser = NetworkClient.shared.fetchUser(id:)) {
self.fetchUser = fetchUser
}
func loadUser(id: UserID) async {
do {
self.user = try await fetchUser(id)
} catch {
self.errorMessage = error.localizedDescription
}
}
}
In a test, you pass a closure inline — no mock type needed at all:
import Testing
@Test func loadUserSetsUserOnSuccess() async {
let fakeUser = User(id: "1", name: "Test User")
let sut = UserProfileViewModel(fetchUser: { _ in fakeUser })
await sut.loadUser(id: "1")
#expect(sut.user == fakeUser)
}
@Test func loadUserSetsErrorOnFailure() async {
let sut = UserProfileViewModel(fetchUser: { _ in throw URLError(.badServerResponse) })
await sut.loadUser(id: "1")
#expect(sut.user == nil)
#expect(sut.errorMessage != nil)
}
The entire test doubles story collapses into a one-liner. The tradeoff is that the closure type (UserID) async throws -> User carries no name or documentation — if you have several similar function-shaped dependencies, they start to look alike at the injection site. For a single dependency, this is the lightest approach available.
Struct-Based DI: The Environment Pattern
A leaner approach is to skip the protocol entirely and use a struct of closures, sometimes called a “dependencies struct” or “environment”:
struct AppDependencies {
var fetchUser: (UserID) async throws -> User
var reportAnalyticsEvent: (AnalyticsEvent) -> Void
}
extension AppDependencies {
static let live = AppDependencies(
fetchUser: { id in
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
},
reportAnalyticsEvent: { event in
AnalyticsSDK.shared.track(event)
}
)
static let mock = AppDependencies(
fetchUser: { _ in User(id: "preview", name: "Preview User") },
reportAnalyticsEvent: { _ in }
)
}
final class UserProfileViewModel: ObservableObject {
@Published var user: User?
private let deps: AppDependencies
init(deps: AppDependencies = .live) {
self.deps = deps
}
func loadUser(id: UserID) async {
self.user = try? await deps.fetchUser(id)
}
}
In a test, you override just the function you care about:
var deps = AppDependencies.mock
deps.fetchUser = { _ in throw URLError(.notConnectedToInternet) }
let vm = UserProfileViewModel(deps: deps)
Adding a new dependency means adding one property to the struct. There are no protocols to multiply, and you can override a single closure in a test without reimplementing the whole dependency graph. The tradeoff is discoverability: a struct of function properties is slightly less expressive than a named protocol with documentation attached, and autocomplete is not as helpful when the type is just (UserID) async throws -> User.
The swift-dependencies Library
If you like the struct-of-closures approach but want it to scale across a large codebase without manually threading the struct through every initializer, Point-Free’s swift-dependencies is worth knowing about.
The core idea is a global, type-safe registry of dependencies. Each dependency gets its own key type that conforms to DependencyKey, which is where you declare the live production value and the test value. You then access that dependency anywhere in your code with a @Dependency property wrapper, and the library handles routing the right value to the right context automatically. In tests, you scope overrides with a withDependencies block, which applies only for the duration of that block and does not leak to other tests.
What makes this useful beyond the ad-hoc struct approach is the withDependencies scoping. When you have a deeply nested call chain, you no longer have to pass a dependencies struct through five layers of initializers just to override one thing in a test. The library threads the value through for you via Swift’s task-local storage.
That said, a library like this is a commitment. It shapes how your entire codebase wires together, not just how your tests run. If your codebase is early-stage or your team is small, start with the in-house approaches covered above. They are easier to understand, easier to debug, and carry no external dependency. Reach for swift-dependencies when the manual wiring is genuinely becoming a bottleneck, not as the first tool you try. Their documentation is thorough and the migration path from a simple struct is straightforward when you are ready.
Popular Mock Generation: Mockolo vs Cuckoo
The Boilerplate Maintenance Problem
Writing mocks by hand works fine when your codebase is small. You have five protocols, each with two or three methods, and keeping them in sync is a light chore. Then your codebase grows. Now you have fifty protocols, some with a dozen methods each, and every time a product engineer adds a parameter to a protocol method, someone has to go find the mock, update it, and make sure the call site still compiles. Miss one and you get a cryptic “does not conform to protocol” error. One would argue that the codebase has interface segregation problem, but, that is the reality of most of the large scale codebases.
To make the pain concrete, imagine a NetworkClient protocol that your team has been evolving for a year:
protocol NetworkClient {
func get<T: Decodable>(_ url: URL) async throws -> T
func post<T: Decodable, B: Encodable>(_ url: URL, body: B) async throws -> T
func put<T: Decodable, B: Encodable>(_ url: URL, body: B) async throws -> T
func delete(_ url: URL) async throws
func upload(_ url: URL, data: Data, mimeType: String) async throws -> UploadResult
func download(_ url: URL) async throws -> Data
func cancelRequest(id: UUID)
func pendingRequests() -> [URLRequest]
func setAuthToken(_ token: String)
func clearAuthToken()
func setBaseURL(_ url: URL)
func headers() -> [String: String]
}
The manual mock for this is about 100 lines of repetitive property declarations, closure storage, and stub logic. Every one of those 12 methods needs a callCount property, a received-arguments array, and a return value stub. When upload gains a progressHandler parameter next sprint, you update the protocol in one line and the mock in six. Multiply this by 50 protocols and you have a real maintenance tax.
Mockolo: Fast, Annotation-Driven Generation
Mockolo is a command-line tool from Uber that generates Swift mocks at build time. The core idea is simple: you annotate the protocols you want mocked with /// @mockable, run the tool (either from a build phase script or your CI pipeline), and it writes a generated file full of mock classes.
Setting it up takes about ten minutes. Install the binary via Homebrew or Swift Package Manager, add a build phase script, and point it at your source directories with an output path. For more information about installation, please refer to Mockolo documentation.
Assuming that we are working on a feature package. The following is how you pass the source and destination to generate boilerplate.
mockolo \
--sourcedirs Sources/Networking \
--destination Tests/Mocks/NetworkingMocks.swift \
--testable-imports NetworkingModule
Then annotate your protocol:
/// @mockable
protocol NetworkClient {
func get<T: Decodable>(_ url: URL) async throws -> T
func delete(_ url: URL) async throws
func setAuthToken(_ token: String)
}
Mockolo outputs a class like this:
@testable import NetworkingModule
class NetworkClientMock: NetworkClient {
init() { }
private(set) var getCallCount = 0
var getHandler: ((URL) async throws -> Any)?
func get<T: Decodable>(_ url: URL) async throws -> T {
getCallCount += 1
if let getHandler = getHandler {
return try await getHandler(url) as! T
}
fatalError("getHandler returns can't have a default value thus its handler must be set")
}
private(set) var deleteCallCount = 0
var deleteHandler: ((URL) async throws -> ())?
func delete(_ url: URL) async throws {
deleteCallCount += 1
if let deleteHandler = deleteHandler {
try await deleteHandler(url)
}
}
private(set) var setAuthTokenCallCount = 0
var setAuthTokenHandler: ((String) -> ())?
func setAuthToken(_ token: String) {
setAuthTokenCallCount += 1
if let setAuthTokenHandler = setAuthTokenHandler {
setAuthTokenHandler(token)
}
}
}
For more information about other available args, feel free to type mockolo --help.
Mockolo’s big strength is speed. It handles large codebases without slowing your build noticeably, and the annotation-driven approach means you only generate mocks for the protocols you actually need. The main limitation is that the generated mock style is fairly fixed. You get consistent, readable output, but you can’t heavily customize the template if your team wants a different mock pattern. For instance, the generated code doesn’t strictly follow the test-doubles naming I prefer. I’ve used it once in the past, and I never care to customise the generated name as it is the boilerplate for my UTs after all.
Cuckoo: Expressive Verification DSL
Cuckoo takes a different philosophy. I personally have never used this framework before, though I have heard other folks bringing this up a couple of time during dev conversations. Instead of selective annotation, Cuckoo automatically generates mocks for every protocol (and class) in your target during a build phase script. Your test target imports Cuckoo and gets access to a Hamcrest-inspired stub and verify DSL.
Here is what using MockNetworkClient looks like in a Cuckoo test:
import Cuckoo
import Testing
@Suite struct LoginViewModelTests {
let networkClient: MockNetworkClient
let viewModel: LoginViewModel
init() {
networkClient = MockNetworkClient()
viewModel = LoginViewModel(client: networkClient)
}
@Test func loginCallsSetAuthToken() async throws {
let fakeUser = User(id: "1", name: "Ada")
stub(networkClient) { mock in
when(mock.post(any(), body: any())).thenReturn(fakeUser)
when(mock.setAuthToken(any())).thenDoNothing()
}
try await viewModel.login(email: "ada@example.com", password: "secret")
verify(networkClient).setAuthToken(equal(to: "token-abc-123"))
verifyNoMoreInteractions(networkClient)
}
}
The stub, when, verify, and verifyNoMoreInteractions chain reads almost like a sentence, which makes tests easy to skim during code review. Cuckoo also supports argument matchers like any(), equal(to:), and notNil() out of the box, which cuts down on custom equality boilerplate.
One caveat worth noting
Cuckoo’s verify calls XCTFail internally for failure reporting. This means your test target still needs to link against XCTest even when you have moved to Swift Testing. More importantly, XCTFail does not cause a Swift Testing @Test to fail — a failed verify() call will be silently swallowed and the test will report green. If you rely on Cuckoo verification inside @Test functions, always back it up with a direct #expect(mock.callCount == 1) or similar check on the recorded arguments, so failures surface correctly through Swift Testing’s own reporting.
The tradeoffs are real, though. Because Cuckoo regenerates mocks for everything in a target on every build, large targets take noticeably longer to compile.
Choosing Between Them
| Mockolo | Cuckoo | |
|---|---|---|
| Setup | Install binary, annotate protocols, one-time script | CocoaPods or SPM, add build phase script |
| Generation scope | Opt-in via /// @mockable | Opt-out, everything in target |
| Build speed | Fast, incremental | Slower, full regeneration |
| Verification DSL | Manual callCount + receivedArgs checks | Expressive verify() chain |
| Community | Uber-backed, active | Community-maintained, mature |
Pick Mockolo if you are joining a large, fast-moving codebase where build time matters and your team can agree on which protocols need mocking. The /// @mockable annotation also acts as useful documentation: when you see it on a protocol, you immediately know it is a boundary that tests care about.
I personally wouldn’t recommend Cuckoo, especially when you could easily avoid via asking AI to write mocks. However, it is just a matter of taste, so, you may pick Cuckoo if your team values expressive, readable test assertions and you want the verification DSL to do the heavy lifting. It shines in smaller targets where the build overhead is acceptable and the verify().setAuthToken(equal(to:)) style genuinely makes your tests easier to read and review. If you are into such kind of expressive matching, be sure to checkout Quick and Nimble as well.
Both tools beat hand-written mocks at scale. The best one is whichever your team actually keeps running.
DIY Mock Generator Using Sourcery
Tools like Mockolo and Cuckoo solve the mock generation, but they come with a fixed opinion about what the generated code looks like. If your team has conventions around naming, thread safety annotations, or how captured arguments are stored, you are either bending to the tool’s output or maintaining your own fork of it. Sourcery gives you a third option, generate exactly the mock you would have written by hand, with full control over the template. I have personally and professionally used Sourcery for mock generation and several other user cases.
What Sourcery Is
Sourcery is a Swift metaprogramming tool written by Krzysztof Zabłocki. At build time it parses your Swift source files, builds a typed metadata model of your types, and feeds that model into Stencil templates you write (or borrow). The output is plain Swift code that gets committed or generated on the fly alongside your sources. No runtime magic, no reflection, just generated .swift files that the compiler sees as ordinary code.
The reason to reach for a custom Sourcery template instead of Mockolo or Cuckoo is simple: your output, your rules. Want every mock to expose a callCount per method rather than a boolean? Want async throws mocks to capture the thrown error separately? Write the template once and every mock in the project follows the same pattern forever.
Setting Up Sourcery
Install via Homebrew:
brew install sourcery
Then drop a .sourcery.yml at your project root. This file tells Sourcery where to look for sources, where to find templates, and where to write output.
sources:
- Sources/MyApp
- Sources/MyAppCore
templates:
- Templates/AutoMockable.stencil
output:
Tests/Mocks
That is the whole config for most projects. Run sourcery once from the terminal to verify it picks up your files before wiring it into the build.
Tagging a Protocol for Generation
The default mock template uses a marker approach via source annotation comments, which keeps your protocol free of test-only dependencies:
// sourcery: AutoMockable
protocol AuthService {
var isLoggedIn: Bool { get }
func login(username: String, password: String) throws
func fetchUser(id: String) async throws -> User
func logout()
}
Or, you can also create a marker interface. Something like the following:
protocol AutoMockable {}
and let your type conforms to that marker interface.
protocol AuthService: AutoMockable {
...
}
They both work, I prefer marker interface over inline comment.
Run Sourcery and you get something like this in Tests/Mocks/AutoMockable.generated.swift:
// Generated by Sourcery - do not edit.
class AuthServiceMock: AuthService {
// MARK: - isLoggedIn
var isLoggedIn: Bool = false
// MARK: - login
var loginCalled = false
var loginCallCount = 0
var loginReceivedArguments: (username: String, password: String)?
var loginThrowableError: Error?
func login(username: String, password: String) throws {
loginCalled = true
loginCallCount += 1
loginReceivedArguments = (username, password)
if let error = loginThrowableError { throw error }
}
// MARK: - fetchUser
var fetchUserCalled = false
var fetchUserCallCount = 0
var fetchUserReceivedId: String?
var fetchUserReturnValue: User!
var fetchUserThrowableError: Error?
func fetchUser(id: String) async throws -> User {
fetchUserCalled = true
fetchUserCallCount += 1
fetchUserReceivedId = id
if let error = fetchUserThrowableError { throw error }
return fetchUserReturnValue
}
// MARK: - logout
var logoutCalled = false
var logoutCallCount = 0
func logout() {
logoutCalled = true
logoutCallCount += 1
}
}
The callCount is there alongside the boolean flag, and the async throwing variant captures the error separately. The snippet above comes from the default AutoMockable template, available in the Sourcery GitHub repository under Templates/. Every detail in it is yours to change.
Tweaking the Stencil Template
Sourcery has a --watch mode which allows you to see the the generated code in live. And, you can open your stencil template in VS Code, and change language mode for .stencil file to Django HTML. It will give you intellisense, syntax highlighting and auto completion.
Or, you may buy the Sourcery Pro.
For now, download the default AutoMockable template into your own Templates/ folder and modify it freely. A few common additions you may make:
receivedInvocationsarray to track every call, not just the most recent arguments. Good for asserting call order.@MainActorannotation on the mock class when the protocol is UI-bound.- Closure-based stubs instead of return value properties, so a test can inject dynamic behavior with
loginHandler = { _, _ in throw NetworkError.timeout }.
Integrating Into the Build
You have three realistic options:
Run script build phase runs Sourcery every time Xcode builds. Zero manual steps, but it adds a few seconds to every incremental build and requires Sourcery to be installed on every developer machine.
Pre-build script via a Makefile or Package.swift plugin gives you more control. You can gate the run on whether source files changed using a stamp file, keeping the common-case build fast.
Commit the generated files and run Sourcery manually (or in CI) when protocols change. This is the simplest setup, gives you diffs in pull requests so reviewers see exactly what mock changed, and has zero impact on build times. The downside is that a developer can forget to regenerate after editing a protocol. A CI lint step that runs Sourcery and checks for a dirty diff catches this reliably.
Most teams I have involved land on the committed-files approach with a CI check. It is boring, transparent, and works in every environment without toolchain assumptions.
That’s enough for mock generation, let’s explore other interesting stuff.
Better Failure Messages
Swift Testing’s #expect macro is a genuine step up from XCTAssertEqual. When an assertion fails, it captures the source expression and both operands and prints them clearly, so you immediately see what was compared. For simple scalar values, that is all you need.
The blindspot shows up when your model types grow in complexity. Picture a User type that holds an Address, a Preferences block, and a list of tags:
struct Address: Equatable {
var street: String
var city: String
var postalCode: String
}
struct Preferences: Equatable {
var darkMode: Bool
var notificationsEnabled: Bool
var fontSize: Int
}
struct User: Equatable {
var id: UUID
var name: String
var address: Address
var preferences: Preferences
var tags: [String]
}
When a #expect assertion over two User values fails, the output looks roughly like this:
Expectation failed: user == loaded
user → User(id: 9B3C2A1F-..., name: "Alice", address: Address(street: "1 Main
St", city: "Springfield", postalCode: "12345"), preferences: Preferences(darkMode:
true, notificationsEnabled: false, fontSize: 14), tags: ["swift", "ios"])
loaded → User(id: 9B3C2A1F-..., name: "Alice", address: Address(street: "1 Main
St", city: "Springfield", postalCode: "12346"), preferences: Preferences(darkMode:
true, notificationsEnabled: false, fontSize: 14), tags: ["swift", "ios"])
Better than XCTest? Yes. Both values are named and the expression is shown. But the data is still printed as a flat blob per value, and you still have to scan both blobs and mentally diff them. The difference here is "12345" vs "12346" buried inside a long line. In a type with ten nested fields and arrays, that manual scan becomes genuinely painful.
Swift Testing does not highlight which field changed. It shows you the full before and after, and leaves the diffing to you.
Enter swift-custom-dump
Point-Free’s swift-custom-dump fills exactly that gap. It gives you two things: a structured pretty-printer that formats any Swift value as an indented tree, and a line-by-line diff engine that runs on top of it.
The assertion you reach for is expectNoDifference. Drop it in place of #expect:
import CustomDump
import Testing
@Test func userRoundTrip() {
let saved = save(user)
let loaded = load(saved)
expectNoDifference(user, loaded)
}
When this fails, the output looks like:
expectNoDifference failed: …
User(
id: 9B3C2A1F-…,
name: "Alice",
address: Address(
street: "1 Main St",
city: "Springfield",
- postalCode: "12345"
+ postalCode: "12346"
),
preferences: Preferences(…),
tags: […]
)
(First: −, Second: +)
Lines prefixed with - show the first argument. Lines prefixed with + show the second. Everything that matches collapses to … so your eye lands directly on the delta. One glance, and you know exactly which field is wrong.
This is not a replacement for #expect. Use #expect for simple comparisons, boolean checks, and error assertions. Reach for expectNoDifference specifically when the values you are comparing are large enough that a flat dump would hide the signal in the noise.
Using customDump Standalone
You can also call customDump on its own to print a structured representation of any value to standard output, without asserting anything. This is useful during debugging when you want to inspect a complex object
import CustomDump
customDump(user)
// User(
// id: 9B3C2A1F-…,
// name: "Alice",
// address: Address(
// street: "1 Main St",
// city: "Springfield",
// postalCode: "12345"
// ),
// preferences: Preferences(
// darkMode: true,
// notificationsEnabled: false,
// fontSize: 14
// ),
// tags: [
// [0]: "swift",
// [1]: "ios"
// ]
// )
Notice the indexed array elements and the indented layout. This is far more scannable than Swift.customDump or a custom debugDescription, and it is consistent across your whole codebase because the format is driven by the library, not whatever each author decided to implement.
DebugSnapshots for your ViewModel (Public Beta)
expectNoDifference and customDump work great when you are asserting leaf-level values after a transformation. But if you want to verify exactly how your ViewModel’s state mutates across an entire action, step by step, Point-Free’s DebugSnapshots takes a different approach: instead of asserting at a single point in time, it takes a full snapshot of your class’s stored properties before and after each method call, then diffs them.
The library ships two things. At development time, it prints those diffs to the console automatically so you can watch state evolve without adding a single print statement. In tests, it gives you an expect() function that verifies the exact set of mutations, exhaustively, with a diff when something does not match.
Note: DebugSnapshots is a public beta (requires Swift 6.2+). The API may change before a stable release, so check the repository for the latest before upgrading.
Installation
Add the package via Package.swift or Xcode’s package manager:
.package(
url: "https://github.com/pointfreeco/swift-debug-snapshots",
from: "0.1.0"
)
Then add "DebugSnapshots" as a dependency of your feature target (not just your test target, because the @DebugSnapshot macro lives on the model class itself).
Dev-Time Logging with .logChanges
Annotate your ObservableObject ViewModel with @DebugSnapshot(.logChanges) and every method call logs a minimal diff of what changed to the console:
import DebugSnapshots
@DebugSnapshot(.logChanges)
final class UserProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let fetchUser: (String) async throws -> User
init(fetchUser: @escaping (String) async throws -> User) {
self.fetchUser = fetchUser
}
func loadUser(id: String) async {
isLoading = true
do {
user = try await fetchUser(id)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
Call loadUser(id:) and the console prints a before/after diff of what changed across the whole method call:
loadUser(id:):
#1 UserProfileViewModel.DebugSnapshot(
- user: nil,
+ user: User(id: "1", name: "Alice"),
errorMessage: nil,
)
The - line shows the value before the method ran, and the + line shows the value after it returned. Fields that stayed the same across the entire call appear without a prefix. Because isLoading starts false, is set to true mid-method, and returns to false before the method exits, the net change is zero and it does not appear in the diff at all. You see only what actually changed from the caller’s perspective. On a failure path where the error branch fires, the diff would instead show errorMessage being set.
Logging is stripped in release builds. There is no runtime cost in production. Needless to say, this is going to help us while working/debugging our view model’s state mutations.
Testing with expect() and @Observable
One important constraint:
expect()only works with classes annotated with@Observable(Swift Observation). It does not support@ObservableObject. The reason is that@DebugSnapshotreads properties through the same macro-generated access layer that@Observablesynthesizes, and@ObservableObject’s@Publishedwrapper uses a different mechanism that the snapshot macro cannot hook into the same way. If your ViewModel still uses@ObservableObject, the.logChangeslogging shown above still works for debugging, but you will need to stick with#expectfor test assertions.
Stack @DebugSnapshot alongside @Observable on your class:
import Observation
import DebugSnapshots
@DebugSnapshot
@Observable
final class UserProfileViewModel {
var user: User?
var isLoading = false
var errorMessage: String?
private let fetchUser: (String) async throws -> User
init(fetchUser: @escaping (String) async throws -> User) {
self.fetchUser = fetchUser
}
func loadUser(id: String) async {
isLoading = true
do {
user = try await fetchUser(id)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
In your test, use expect() with an action closure and a changes closure that describes the mutations you expect:
import Testing
import DebugSnapshots
@Test func loadUserSetsUserOnSuccess() async {
let fakeUser = User(id: "1", name: "Alice")
let viewModel = UserProfileViewModel(fetchUser: { _ in fakeUser })
await expect(viewModel) {
await viewModel.loadUser(id: "1")
} changes: {
$0.user = fakeUser
}
}
@Test func loadUserSetsErrorOnFailure() async {
let viewModel = UserProfileViewModel(
fetchUser: { _ in throw URLError(.badServerResponse) }
)
await expect(viewModel) {
await viewModel.loadUser(id: "1")
} changes: {
$0.errorMessage = URLError(.badServerResponse).localizedDescription
}
}
Exhaustiveness Is the Point
If you are following Point Free’s videos for a while now, you will know the exhaustive testing been at its core since the early days of TCA. You shouldn’t be too surprised to find out that the changes closure is exhaustive. If your loadUser accidentally leaves isLoading set to true (a common bug in async error paths), the test fails immediately because that mutation is not described in changes. You are not just checking the happy-path field. You are locking down the full blast radius of the action. Any surprise side effect surfaces as a test failure.
This is a meaningful step beyond #expect(sut.user == fakeUser). That assertion only checks one field. expect() checks the whole state shape at once.
It is important to note that new projected created with latest Xcode targets enable main actor isolation by default, which means your model class may implicitly require the main actor. If your test struct is not annotated with @MainActor and you call expect() on such a class, you will get a concurrency error. Annotate either the test struct or the individual @Test function with @MainActor to fix it or opt-out of main actor isolation by default (not recommended for several reasons 😄).
Snapshot Testing
I know that I am repeating/and going to repeat the word “snapshot” a lot but this is another snapshot, different from the
DebugSnapshotsI’ve covered earlier. The snapshot-testing I am referring to here is from swift-snapshot-testing and they offer both UI snapshot and output/result snapshot.
Instead of writing assertions about individual properties, you let the library capture a reference “snapshot” of your output, commit it alongside your code, and then on every subsequent test run the library compares the new output against that reference. If anything drifts, the test fails. No more “did the redesign accidentally shift that button by 4 points?” slipping through code review.
The manual alternative is tedious: open the simulator, navigate to the screen, squint at it, and hope you notice the regression. Snapshot testing makes that comparison automatic, deterministic, and reviewable in a pull request diff.
Getting Started with assertSnapshot
The core API is a single function:
import SnapshotTesting
import Testing
@Test func profileLayout() {
let vc = ProfileViewController(user: .mock)
assertSnapshot(of: vc, as: .image(on: .iPhone13))
}
The first time this test runs, it writes a PNG to __Snapshots__/ProfileViewControllerTests/testProfileLayout.1.png right next to your test file. Subsequent runs load that file and do a pixel-level comparison. Commit those reference files to your repo. They are part of your test suite.
When you intentionally change a layout, re-record by wrapping your call with withSnapshotTesting:
withSnapshotTesting(record: .all) {
assertSnapshot(of: vc, as: .image(on: .iPhone13))
}
Or set record: .missing to only write snapshots that do not already exist, which is handy after adding new test cases without re-recording everything.
The Non-UI Snapshot
Most people think of snapshot testing as a visual tool, but .customDump is the strategy that earns its keep on non-UI code. It serializes any Swift value into a human-readable text tree using mirror reflection.
import SnapshotTesting
import SnapshotTestingCustomDump
@Test func decodedAPIResponseMatchesSnapshot() throws {
let json = """
{ "id": 42, "username": "alice", "isPremium": true }
""".data(using: .utf8)!
let user = try JSONDecoder().decode(User.self, from: json)
assertSnapshot(of: user, as: .customDump)
}
You run the test, first time, it will fail and record the value in a snapshot file. The snapshot file looks like this:
// Path: __Snapshots__ / MyTests / decodedAPIResponseMatchesSnapshot.1.txt
▿ User
- id: 42
- isPremium: true
- username: "alice"
Run it one more time, this time, since we’ve already recorded the snapshot, the test will pass.
This is far more expressive than a pile of XCTAssertEqual calls, and it catches accidental field removals or type changes in your model layer immediately. New property appears in the model? The snapshot diff makes it obvious.
Inline Snapshot
The above assertSnapshot create a .txt file with snapshot value. If you don’t want to create a separate file, and inline the snapshot, you can do that via assertInlineSnapshot. Let’s take the previous example, replace SnapshotTesting with InlineSnapshotTesting and do something like this:
import InlineSnapshotTesting
import SnapshotTestingCustomDump
@Test func decodedAPIResponseMatchesSnapshot() throws {
let json = """
{ "id": 42, "username": "alice", "isPremium": true }
""".data(using: .utf8)!
let user = try JSONDecoder().decode(User.self, from: json)
assertInlineSnapshot(of: user, as: .customDump)
}
Try to run the test, like usual, it will fail. However, you will see the snapshot captured in the trailing closure as inline template string:
@Test func decodedAPIResponseMatchesSnapshot() throws {
... Other codes
assertInlineSnapshot(of: user, as: .customDump) {
"""
User(
id: 42,
username: "alice",
isPremium: true
)
"""
}
}
Run it again, and it will now pass.
Snapshotting Model Transformations
The real power of .customDump shows up when you are testing data transformations, not just raw decoded values. A common pattern in iOS apps is mapping an API response DTO into a domain model — stripping server-specific naming, parsing raw strings into typed values, applying defaults. Those mappings are easy to get subtly wrong, and a pile of individual #expect calls rarely covers the full shape.
Say you have a DTO that comes in from the server with snake_case keys and raw strings for enums and dates:
struct UserDTO: Decodable {
let user_id: Int
let display_name: String
let subscription_tier: String
let created_at: String
}
struct UserProfile {
let id: UserID
let displayName: String
let tier: SubscriptionTier
let memberSince: Date
}
One snapshot covers the whole mapped shape:
@Test func userDTOToProfileMapping() throws {
let dto = UserDTO(
user_id: 7,
display_name: "Alice Maker",
subscription_tier: "pro",
created_at: "2024-01-15T10:30:00Z"
)
let profile = UserProfile(from: dto)
assertSnapshot(of: profile, as: .customDump)
}
The snapshot file looks like:
▿ UserProfile
▿ id: UserID
- value: 7
- displayName: "Alice Maker"
- tier: SubscriptionTier.pro
▿ memberSince: 2024-01-15 10:30:00 +0000
If the mapper ever starts passing through the raw "pro" string instead of the typed .pro enum case, the diff catches it instantly. If memberSince silently becomes nil because your date parser stopped recognising the format, the snapshot catches that too.
Asserting Enum Shapes
Enums with associated values are awkward to assert with #expect. Even a simple assertion that a result is .success with the right payload requires a switch or a guard case let, which is four to eight lines per test just for the unwrapping. .customDump flattens the whole thing:
enum SearchResult {
case loaded([SearchItem])
case failed(APIError)
case empty
}
@Test func searchResultLoadedShape() {
let result: SearchResult = .loaded([
SearchItem(id: 1, title: "Swift Concurrency"),
SearchItem(id: 2, title: "Swift Testing"),
])
assertSnapshot(of: result, as: .customDump)
}
Snapshot output:
▿ SearchResult
▿ loaded: 2 elements
▿ SearchItem
- id: 1
- title: "Swift Concurrency"
▿ SearchItem
- id: 2
- title: "Swift Testing"
Every associated value is visible without any manual unwrapping. Adding a third SearchItem to the result? The diff shows exactly which element was added and where. Swapping the order? Also caught. This is especially useful for testing reducer state machines where the same action can produce different enum cases depending on prior state.
A Custom Strategy for JSON Encoding
We’ve been using .customDump since the beginning. It shows your Swift value’s in-memory structure. Sometimes what you actually want to pin is the JSON your encoder produces, because that is what your server or a third-party SDK consumes. You can write a custom Snapshotting strategy in about ten lines:
import SnapshotTesting
extension Snapshotting where Value: Encodable, Format == String {
static var encodedJSON: Snapshotting {
var snapshotting = SimplySnapshotting.lines.pullback { (value: Value) -> String in
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try! encoder.encode(value)
return String(data: data, encoding: .utf8)!
}
snapshotting.pathExtension = "json"
return snapshotting
}
}
Use it exactly like any built-in strategy:
@Test func createOrderRequestEncodesCorrectly() {
let request = CreateOrderRequest(
items: [OrderItem(sku: "SWIFT-BOOK", quantity: 2)],
currency: "USD",
discount: nil
)
assertSnapshot(of: request, as: .encodedJSON)
}
The committed snapshot file is a real .json file:
{
"currency" : "USD",
"items" : [
{
"quantity" : 2,
"sku" : "SWIFT-BOOK"
}
]
}
Two things to notice here. The sortedKeys option makes the output deterministic regardless of dictionary iteration order, so you never get a flaky snapshot because Dictionary decided to serialize keys in a different order. The missing discount key confirms your encoder is correctly omitting nil optional fields rather than encoding them as null. Both of those are things that are annoying to verify with #expect and trivial to verify with a snapshot.
Testing Your API Layer with .curl
This is the strategy that quietly saves teams from broken API integrations. Pass it a URLRequest and it serializes the request as a curl command:
@Test func searchRequestConstruction() {
let request = SearchAPIClient().makeRequest(query: "swift", page: 2)
assertSnapshot(of: request, as: .curl)
}
The snapshot file might contain:
curl \
--request GET \
--header "Accept: application/json" \
--header "Authorization: Bearer <token>" \
"https://api.example.com/search?page=2&query=swift"
You can verify headers, query parameter ordering, HTTP method, and base URL at a glance, all without hitting the network. If someone refactors the request builder and accidentally drops the Authorization header, the diff will catch it immediately.
Test Your Macro
Swift Macros are genuinely exciting. They let you eliminate boilerplate, catch errors at compile time, and make your APIs feel like they were built into the language. But the moment you sit down to write tests for one, you run into a wall.
The problem is what you might call the compiler plugin dance. A macro lives in a separate compiler plugin target, expands during compilation, and produces Swift syntax trees. When something goes wrong, you get a compiler diagnostic, not a runtime failure. Standard XCTest has no concept of “what did this macro expand to?” or “did it emit the right error for bad input?” You could write an integration test by just compiling code that uses your macro and checking the build fails, but that is slow, fragile, and tells you almost nothing about why something broke.
That is the gap that Point-Free’s swift-macro-testing fills.
The library gives you a single function, assertMacro, that works a lot like snapshot testing. You pass it the source code string that invokes your macro, and it expands the macro and records the output. On the next run, it compares the new expansion against the recorded one. If they differ, the test fails with a clear diff.
Here is what that looks like for a #URLString macro that validates a string literal is a valid URL at compile time and exposes a URL value:
import MacroTesting
import Testing
@Suite struct URLStringMacroTests {
@Test func validURL() {
withMacroTesting(macros: [URLStringMacro.self]) {
assertMacro {
"""
let endpoint = #URLString("https://api.example.com/v1/users")
"""
} expansion: {
"""
let endpoint = URL(string: "https://api.example.com/v1/users")!
"""
}
}
}
@Test func invalidURLEmitsError() {
withMacroTesting(macros: [URLStringMacro.self]) {
assertMacro {
"""
let bad = #URLString("not a valid url :(")
"""
} diagnostics: {
"""
let bad = #URLString("not a valid url :(")
┬──────────────────
╰─ 🛑 Invalid URL literal: "not a valid url :("
"""
}
}
}
}
The first argument is the input source, and the expansion trailing closure is the expected output. If you delete the expansion closure and run in record mode, the library writes it for you the first time. That is the record mode in action: run once, let the library capture the ground truth, commit it, and from then on your CI will catch any unintended changes to the expansion.
The diagnostics closure (shown in invalidURLEmitsError above) shows the exact error annotation inline on the offending code, in the same way Xcode renders it. If your macro changes which character it underlines, or changes the error message wording, the test will catch it immediately.
This matters more than it sounds. Macro diagnostics are part of your public API. Your users see those error messages when they misuse your macro. Treating them as testable, committed artifacts keeps your error messages intentional and prevents regressions when you refactor the expansion logic. Record mode makes getting started basically zero friction, and the diff output on failure is clear enough that you immediately know what changed.
Test Your UI on CI
Regardless of you like it or not, SwiftUI Previews are one of the most useful tools in your day-to-day workflow, and they are completely invisible to your CI pipeline.
You might have a #Preview for a loading state, an empty state, an error banner, and a fully populated card view. Every time you open that file in Xcode, those previews render correctly and give you confidence. But the moment you push to main, none of that confidence travels with you. A refactor could break the empty state layout, a color token change could make text unreadable in dark mode, and CI will not say a word. It will green-light the build because the code compiles.
That is the problem SnapshotPreviews by Sentry solves. I know, another snapshot again.
The idea is straightforward. Your #Preview macros already describe the exact component states you care about. SnapshotPreviews automatically discovers those declarations and generates snapshot tests from them, with no separate test files to write and no duplication.
Setting it up takes a few minutes. Add the package to your test target:
// In Package.swift or via Xcode package manager
.package(url: "https://github.com/getsentry/SnapshotPreviews-iOS.git", from: "1.0.0")
Then create a single test file that drives the whole thing:
import SnapshotPreviews
class AppPreviewSnapshotTests: SnapshotTest {
// Return nil to snapshot every discovered preview in the bundle.
// Pass an array of name patterns (regex supported) to scope it down.
override class func snapshotPreviews() -> [String]? { nil }
// Exclude previews you do not want snapshotted, such as interactive demos.
override class func excludedSnapshotPreviews() -> [String]? { nil }
}
That is genuinely the whole test file. Note that SnapshotPreviews builds on top of XCTest, so your snapshot test target needs to link against XCTest. Swift Testing is not supported here — this is one place where XCTest still holds the ground. The library scans your app bundle for #Preview macro metadata at runtime, instantiates each discovered preview, renders it into an image, and attaches it to the XCTest result. To write snapshots to disk instead of only attaching them to the result, set the TEST_RUNNER_SNAPSHOTS_EXPORT_DIR environment variable before running the tests. If the rendered output drifts from the reference, the test fails and the diff lands in your test report.
Your component previews probably already cover the states that matter:
#Preview("Loading") {
FeedView(state: .loading)
}
#Preview("Empty") {
FeedView(state: .empty)
}
#Preview("Populated") {
FeedView(state: .loaded(items: .mock))
}
Every one of these becomes a snapshot test automatically. The value is not just coverage, it is that you are reusing work you were already doing. Previews are something you write for yourself, to speed up development. SnapshotPreviews lets them pull a second shift as automated regression guards.
For CI, the workflow follows the same pattern as any snapshot testing library. The first run generates reference images, which you commit to the repository. Subsequent runs on CI render fresh snapshots and diff them against the committed references. A layout change that moves a button by 4 points, or a font weight that shifts in a dependency update, will show up as a test failure with a pixel diff rather than a mystery bug report from a user.
The key insight is that this approach removes the activation energy barrier. Snapshot testing historically required you to write separate test cases that mirrored your component states. That duplication meant the tests often lagged behind the real components. When your previews are the tests, they stay in sync by default.
Disclaimer
I enjoy Point-Free’s work a lot. The swift-dependencies, swift-debug-snapshot, swift-snapshot-testing, and swift-macro-testing libraries all get recommended here because they genuinely solve real problems well, not because of any affiliation or sponsorship. All opinions are my own.
Conclusion
A lot of ground covered here. Dependency injection gives you the seams your tests need, and the right pattern depends on the complexity you are actually dealing with, closures for simple cases, a struct of closures when there are several, and a library like swift-dependencies when the manual threading genuinely becomes a bottleneck. Mock generation tools like Mockolo, Cuckoo, and Sourcery remove the boilerplate tax, each with different tradeoffs around build speed, flexibility, and team convention.
On the assertion side, #expect handles most cases cleanly. When the values are large and nested, expectNoDifference gives you a diff instead of a wall of text. And if you are moving to @Observable, DebugSnapshots takes that further by locking down the exact set of mutations an action produces — no more accidental side effects slipping past.
Snapshot testing rounds it all out. Visual regression for your UI, structural diffs for your data models, macro expansion tests for your metaprogramming, and SnapshotPreviews to turn the previews you were already writing into automated CI guards.
None of this has to land all at once. Pick the one thing causing the most friction right now and start there. The rest will follow naturally as your test suite grows.
Comments are powered by Giscus (GitHub Discussions). Loading them fetches resources from GitHub.