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