Building a MusicKit app when MusicKit won't run in the simulator
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.
I’m building a small iOS app that pokes at your Apple Music library to surface stuff you should check out (the setup post has the background). Day one, I hit the wall every MusicKit developer hits: MusicKit does nothing in the simulator. No auth prompt, no library, no catalog search. It doesn’t error. It just hands back empty results, forever. Any real call needs a physical iPhone signed into an account with an active Apple Music subscription.
That breaks the thing I like most about SwiftUI: the fast loop where you tweak a view, hit run, and see it in the simulator a second later. If every screen that touches music needs a device build, that loop is gone before the app even exists.
So before writing a single feature, I spent an issue making the simulator useful again.
The shape: one protocol, two implementations
The idea is old and boring and it works: put a protocol between the app and the framework, write a real implementation and a fake one, and pick which to use at build time.
@MainActor
protocol MusicLibraryServiceProtocol: AnyObject {
func fetchLibraryArtists() async throws -> [LibraryArtist]
func fetchLibraryAlbums() async throws -> [LibraryAlbum]
func fetchPlaylist(named name: String) async throws -> LibraryPlaylist?
func fetchRecentlyPlayed() async throws -> [LibraryTrack]
func fetchCatalogReleases(for artist: LibraryArtist) async throws -> [LibraryAlbum]
func addTracksToLibrary(_ tracks: [LibraryTrack]) async throws
func addTracksToPlaylist(_ tracks: [LibraryTrack], playlist: LibraryPlaylist) async throws
func removeTracksFromPlaylist(_ tracks: [LibraryTrack], playlist: LibraryPlaylist) async throws
}
It’s @MainActor because the real one touches MusicLibrary.shared, which wants the main actor. Mark the protocol and the isolation flows to both implementations for free.
Gotcha 1: you can’t return MusicKit’s own types
My first instinct was to return [Artist], [Album], [Track] straight from MusicKit. Two problems killed that.
First, MusicKit’s Artist, Album, and friends have no public initializers. You can only get them by making a real request. So a mock physically cannot construct one to hand back. That alone ends the debate.
Second, the real implementation file imports MusicKit, so bare Artist would be ambiguous with anything else named Artist. So I made plain, app-owned structs:
struct LibraryArtist: Identifiable, Hashable, Sendable {
let id: String // MusicItemID raw value
var name: String
var artworkURL: URL?
}
The id is the MusicItemID raw string. The mock fabricates ids like "art.1"; the real service round-trips them back to MusicKit objects when it needs to write. These DTOs turned out to be the right call regardless of mocking - the rest of the app never imports MusicKit, so the framework stays sealed behind one wall.
The mock that records what you asked it to do
Reads return fixed sample data. The interesting part is the writes: the mock doesn’t pretend to do anything, it records the request so a test (or a feature screen) can assert on it later.
@MainActor @Observable
final class MockMusicLibraryService: MusicLibraryServiceProtocol {
private(set) var tracksAddedToPlaylist: [(tracks: [LibraryTrack], playlist: LibraryPlaylist)] = []
func fetchLibraryArtists() async throws -> [LibraryArtist] { Self.sampleArtists }
func addTracksToPlaylist(_ tracks: [LibraryTrack], playlist: LibraryPlaylist) async throws {
tracksAddedToPlaylist.append((tracks, playlist))
}
}
That recording is what lets me build the “add to playlist” button in the simulator and prove it called the service correctly, without a device anywhere in the loop.
Swapping at the door
A small container picks the implementation, and the auth model forces an authorized state on the simulator so the gate doesn’t trap me on a screen that can never resolve:
#if targetEnvironment(simulator)
musicLibrary = MockMusicLibraryService()
#else
musicLibrary = MusicLibraryService()
#endif
On device, the real service and the real MusicAuthorization.request() take over. The feature code above this line has no idea which one it’s talking to.
Gotcha 2: the worktree that swallowed my test target
This one’s on me, not on Apple. I’d been running each issue in a git worktree - a separate working directory bound to a branch. Great when several things run at once. Terrible here, because Xcode anchors to one directory, and I had the main checkout open while my branch lived in the worktree.
So when I added a unit-test target through the Xcode wizard, it wrote the target into the wrong tree. The project change landed on the wrong branch entirely. I spent a chunk of time moving a project.pbxproj change and a folder between trees and reverting the other side.
The fix wasn’t cleverer git. It was dropping worktrees for this project and using a plain feature branch on the single checkout. Now git and Xcode point at the same folder, and a GUI action can’t land somewhere I’m not looking. Match your branching model to your tools - a GUI that only sees one directory wants one directory.
Gotcha 3: new targets are born too new
The test target wouldn’t run. Xcode 26.5 gives a brand-new target the toolchain’s deployment target (26.5), not the app’s (17.6). So:
Cannot test target "DiscovererTests" on "iPhone 16 Pro": iPhone 16 Pro's iOS
Simulator 18.5 doesn't match DiscovererTests's iOS Simulator 26.5 deployment target.
You can prove your tests are fine without touching the project by overriding on the command line - xcodebuild test ... IPHONEOS_DEPLOYMENT_TARGET=17.6 - then persist the real value in Build Settings. With that fixed, twelve Swift Testing cases (it’s the default for new test targets now - import Testing, @Test, #expect) run green against the mock, on the simulator, in about a hundredth of a second.
What I’d tell past me
The mock isn’t a testing afterthought you bolt on later. For a MusicKit app it’s the primary development surface - the thing you actually look at ninety percent of the time. Build it first, make it return believable data, and make its writes inspectable. Then the device build becomes what it should be: the final check, not the daily grind.
And keep your version control honest with your editor. The fanciest isolation setup is worthless if your IDE is quietly editing a different copy of the project than the one you think you’re on.
Related reading
The mirrored window lied about the phone being unlocked
A one-line grouping fix that was already written, and three walls between it and proof: actor isolation, log stream's Mac-only scope, and a mirrored session that looks unlocked when the device isn't.
Pre-release albums, and the fix I couldn't fully verify
Apple's 'Track N' placeholders rendered as real data. The one detection signal I couldn't confirm became the one signal I stopped depending on.
A subscribe button, a MusicKit API I'd never used, and a sheet that dismisses into silence
musicSubscriptionOffer presents Apple's native sheet - and nothing in the app would ever notice it closing. The correctly-coded action that was still a functional dead end.