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 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
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.
The actor-isolation trap in "just move it off the main thread"
Synthesized Codable conformances inherit default MainActor isolation too, and async let has stricter demands than the task you already have.
You might also find useful
Proton Drive
Encrypted cloud storage from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreAdGuard for iOS
System-wide ad and tracker blocking on iOS, no separate DNS server required.
As an AdGuard 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 more