My mocks couldn't fail: error injection and a Swift 6 isolation crash
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
A code review of my iOS side project surfaced an uncomfortable finding: every error-handling branch in the app had zero test coverage. Not “thin” coverage. Zero.
The cause was simple. My mock service - the thing all the view model tests run against - could only succeed. Every method returned canned sample data. So every catch { state = .failed(...) } branch in every view model was dead code in tests. A regression that mishandled errors would sail through CI green.
The seam
I didn’t want one global “fail everything” switch. Real failure tests need precision: the first page loads fine, then the second page fails. Fetch succeeds, the write fails. So the seam is a set of method identifiers:
enum MockMethod: Hashable {
case fetchLibraryArtists, fetchPlaylistContents, fetchRecentlyPlayed,
createPlaylist, addTracksToLibrary, removeTracksFromPlaylist // ...
}
var failingMethods: Set<MockMethod> = []
var injectedError: any Error = SimulatedError()
private func throwIfFailing(_ method: MockMethod) throws {
if failingMethods.contains(method) { throw injectedError }
}
Every protocol method gets one line at the top: try throwIfFailing(.fetchRecentlyPlayed). A test that wants pagination to break halfway does this:
await vm.load() // first page: fine
service.failingMethods = [.fetchRecentlyPlayed] // now the network "dies"
await vm.loadMore()
#expect(vm.state == .loaded) // the visible list survives
#expect(!vm.hasMore) // pagination ends quietly
The injectedError property earns its keep too: swap in CancellationError() and you can assert that a cancelled load neither flips the UI to a failure state nor caches a half-finished feed. That path came out of the previous issue’s cancellation work and was untestable until now.
Twenty-six new tests later, the failed states, failure toasts, and keep-cached-content-on-background-failure behaviors are all pinned down.
Then the test runner exploded
One of the new test files covered a tiny Array.chunked(into:) helper. Pure function, no state, no actors. So unlike every other suite in the project, I didn’t mark it @MainActor. Why would I?
First full run: 20 test failures, all at exactly 0.000 seconds, spread across files I hadn’t touched. That pattern smelled less like 20 bugs and more like one crash taking down the runner, so I went looking in ~/Library/Logs/DiagnosticReports/. The crash report was blunt:
EXC_BREAKPOINT (SIGTRAP)
_dispatch_assert_queue_fail
closure #1 in Array.chunked(into:)
The project builds with Swift 6’s default actor isolation set to MainActor. Under that setting, my “pure” Array extension is implicitly @MainActor - the isolation comes from a build setting, not from anything visible in the code. Swift Testing runs non-MainActor suites on background executors, so my one unannotated suite called a MainActor function from the wrong executor and the runtime trapped. The whole process died, and every in-flight test got reported as a failure.
The fix was one annotation: mark the suite @MainActor like all the others. It turns out that project convention was load-bearing, not stylistic.
Lessons
- If your mocks can’t fail, your error handling is untested by construction. Build the failure seam early - it’s 15 lines.
- A method-keyed set beats a global failure flag. Most interesting failure tests need some calls to succeed first.
- Mass test failures at 0.000 seconds mean a crashed runner, not mass regressions. Go read the crash report before “fixing” twenty tests.
- In a default-MainActor project, “this code looks pure” tells you nothing about its isolation. Check the build setting before skipping the annotation.
Related reading
Seven small Deep Cut Atlas fixes, and a test I almost deleted
A backlog sweep smallest-first: ScaledMetric artwork, a wrong thumbs icon, dead chevrons - and a broken test whose comment described a race my fix had narrowed but not closed.
The suggestions bug that survived because my own investigation lied to me twice
A frozen snapshot, a plausible 'History is safe' claim that fell apart under a skeptical second pass, and the coalescing layer I almost built that already existed one layer down.
The bug I filed was wrong, and the fixture that fixed it broke four other tests
A write-time guard for a bug that couldn't happen, the read-time exclusion that was actually wanted, and four tests quietly load-bearing on a fixture count.