Closing the test gaps: pure logic, an unreachable mock branch, and a Swift 6 flip
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.
This was a cleanup issue from an earlier repo-review spike, and it turned out to be more interesting than “add some tests” usually is. Three unrelated fixes, but two of them found real bugs I wasn’t looking for.
Problem
Three gaps had built up in Discoverer’s test suite. The playlist album-grouping algorithm and an EP-detection heuristic were both buried inside MusicKit-calling service methods, so they could only be exercised through the full async fetch path. The mock service had a parameter, libraryAlbumKeys, that it silently ignored - that parameter is what lets the app tell you “you already own this album” instead of “it’s already in your playlist,” two very different messages, and no test could ever prove that branch worked. And the test target still compiled under Swift 5, while the app target had been Swift 6 with default Main Actor isolation since day one. Nobody had ever checked whether the tests would even compile under the same rules the app follows.
Why this approach
For the first gap, the fix was extraction: pull the pure part of the algorithm (group tracks by album, preserve first-appearance order, fall back to track title when the album title is missing) out into its own function that takes plain values, not MusicKit types. Same move for the EP heuristic - once it’s just (title, isCompilation, isSingle) -> RecordingType, you don’t need a network call to test four branches of an if/else chain.
For the mock, I didn’t want to bolt on a separate “test knob” that could drift from the real check. Instead I made the mock’s addAlbumToPlaylist check the same libraryAlbumKeys parameter the real service checks, in the same order. Mirroring the real logic instead of faking a shortcut is what let the next part happen.
Implementation
The extraction was mechanical. The interesting part was the mock: the moment I wired up the ignored parameter, two previously-green tests broke.
Turned out two of the mock’s sample albums - “Mordechai” and “Oncle Jazz” - were doing double duty. They were both in the fake “library” (so fetchLibraryAlbumKeys() would return their keys) AND registered as “already in the user’s playlist” for a completely different test scenario. That overlap was invisible for as long as the mock never actually checked library ownership. The instant it did, both albums became genuinely ambiguous: are they “already in your library” or “already in your list”?
I checked what the real service does in that exact situation, and it always checks library ownership first, unconditionally. So the fix wasn’t to patch around the ambiguity - the tests were relying on a behavior the mock never should have had. I swapped the “already in list” fixtures to two different albums that aren’t in the fake library, and both tests now assert something that’s actually true of production code.
The Swift 6 flip was almost anticlimactic: switched the test target to Swift 6 language mode plus Main Actor default isolation, matching the app target, and reran the full suite. Zero new errors. The 163 tests all still passed. Sometimes “it just works” is the actual result, and you only find that out by actually flipping the switch instead of assuming.
Gotchas
Extracting the grouping function into its own file hit a Swift 6 isolation error I’d already seen once before, in a different shape: a plain struct with no actor annotation inherits the module’s default Main Actor isolation, which means its compiler-synthesized Equatable and initializer also become Main-Actor-isolated. A nonisolated free function can’t call a Main-Actor-isolated initializer, even for a harmless value type with no actual shared mutable state. The fix is one word - mark the type nonisolated - but you only find out you need it by trying to compile, and the error message doesn’t obviously point at “the struct needs an annotation,” it points at “a conformance is main-actor-isolated,” which reads oddly the first time you see it.
The second gotcha was self-inflicted: after renaming the “already in list” fixture from “Mordechai” (which happens to be track #1 in the fixture list) to a different album that’s track #7, one test crashed with a force-unwrap failure. The test’s default page size only loads the first 5 tracks. Swapping which album a test cares about isn’t just a string replace if the fixture has positional assumptions baked in - I had to bump the page size for both affected tests.
Results and lessons
163/163 tests green, before and after the Swift 6 flip. The extraction gave the album-grouping algorithm and the EP heuristic their own focused unit tests, independent of MusicKit and the simulator entirely. And wiring up that one ignored mock parameter didn’t just add coverage - it exposed that two existing tests had been passing for the wrong reason.
That’s the actual value of “close the test gaps” work. Hitting more lines is the surface metric; the real gain is making the fake version of your service behave enough like the real one that a genuine regression can’t hide behind a convenient fixture coincidence.
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.