Skip to content
Development

My mocks couldn't fail: error injection and a Swift 6 isolation crash

By Victor Da Luz
iosswiftswift-testingtestingdev-logdeep-cut-atlas

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

You might also find useful

NordPass

NordPass

Password manager from the team behind NordVPN, with a free tier.

As a NordPass affiliate, I earn from qualifying purchases.

Learn more
Proton

Proton 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 more
Airalo

Airalo 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