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
The bug I filed was wrong, and the fixture that fixed it broke four other tests
The bug I filed was a write-time guard for something that couldn't happen. The real fix was a read-time exclusion, and its fixture broke four other tests.
A segfault that wasn't a bug, and the API I finally deleted
A MusicKit service-layer cleanup where deleting dead methods crashed every test at once - and the fix was a clean build, not a debugger.
Closing the test gaps: pure logic, an unreachable mock branch, and a Swift 6 flip
Extracting algorithms out of MusicKit's reach, a mock parameter that was silently ignored, and two tests that had been passing for the wrong reason all along.
You might also find useful
NordPass
Password manager from the team behind NordVPN, with a free tier.
As a NordPass affiliate, I earn from qualifying purchases.
Learn moreProton Mail
End-to-end encrypted email with zero-access architecture.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreAiralo eSIM
Local data eSIM for travel - no physical SIM swap needed.
This is my Airalo referral link. You get a discount on your first eSIM, and I earn Airalo credit toward mine.
Learn more