Saltar al contenido
Development

Construir una app con MusicKit cuando MusicKit no corre en el simulador

Por Victor Da Luz
iosswiftmusickitswift-testingdev-logdeep-cut-atlas

Esta app se renombró después a Deep Cut Atlas. Abajo se la llama “Discoverer” en todo momento, porque así se llamaba el día que pasó esto.

Estoy construyendo una pequeña app de iOS que explora la biblioteca de Apple Music del usuario para sacar a la luz cosas que vale la pena revisar (el post de configuración tiene el contexto). El primer día choqué con el muro que choca todo desarrollador de MusicKit: MusicKit no hace nada en el simulador. Sin prompt de autenticación, sin biblioteca, sin búsqueda en el catálogo. No da error. Simplemente devuelve resultados vacíos, para siempre. Cualquier llamada real necesita un iPhone físico con la sesión iniciada en una cuenta con una suscripción activa a Apple Music.

Eso rompe lo que más me gusta de SwiftUI: el ciclo rápido en el que se ajusta una vista, se corre la app, y se la ve en el simulador un segundo después. Si cada pantalla que toca música necesita un build en dispositivo, ese ciclo desaparece antes de que la app siquiera exista.

Así que, antes de escribir una sola feature, dediqué un issue a que el simulador volviera a ser útil.

La forma: un protocolo, dos implementaciones

La idea es vieja, aburrida, y funciona: poner un protocolo entre la app y el framework, escribir una implementación real y una falsa, y elegir cuál usar en tiempo de compilación.

@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
}

Es @MainActor porque la implementación real toca MusicLibrary.shared, que exige el actor principal. Al marcar el protocolo, el aislamiento fluye gratis a ambas implementaciones.

Trampa 1: no se pueden devolver los tipos propios de MusicKit

Mi primer instinto fue devolver [Artist], [Album], [Track] directamente desde MusicKit. Dos problemas mataron esa idea.

Primero, Artist, Album y compañía de MusicKit no tienen inicializadores públicos. Solo se los puede obtener haciendo una solicitud real. Así que un mock no puede construir uno para devolverlo, físicamente imposible. Eso solo ya termina el debate.

Segundo, el archivo de la implementación real importa MusicKit, así que un Artist a secas sería ambiguo con cualquier otra cosa llamada Artist. Así que hice structs simples, propios de la app:

struct LibraryArtist: Identifiable, Hashable, Sendable {
    let id: String          // MusicItemID raw value
    var name: String
    var artworkURL: URL?
}

El id es el string crudo de MusicItemID. El mock fabrica ids como "art.1"; el servicio real los convierte de vuelta a objetos de MusicKit cuando necesita escribir. Estos DTOs resultaron ser la decisión correcta más allá del mockeo, el resto de la app nunca importa MusicKit, así que el framework queda sellado detrás de un solo muro.

El mock que registra lo que se le pidió hacer

Las lecturas devuelven datos de muestra fijos. Lo interesante son las escrituras: el mock no finge hacer nada, registra la solicitud para que una prueba (o una pantalla de feature) pueda verificarla después.

@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))
    }
}

Ese registro es lo que me permite construir el botón “agregar a la playlist” en el simulador y demostrar que llamó al servicio correctamente, sin ningún dispositivo en el ciclo.

El intercambio en la puerta

Un pequeño contenedor elige la implementación, y el modelo de autenticación fuerza un estado autorizado en el simulador para que la compuerta no me atrape en una pantalla que nunca puede resolverse:

#if targetEnvironment(simulator)
    musicLibrary = MockMusicLibraryService()
#else
    musicLibrary = MusicLibraryService()
#endif

En el dispositivo, el servicio real y el MusicAuthorization.request() real toman el control. El código de la feature por encima de esta línea no tiene idea con cuál de los dos está hablando.

Trampa 2: el worktree que se tragó mi target de pruebas

Esta es culpa mía, no de Apple. Había estado corriendo cada issue en un git worktree, un directorio de trabajo separado ligado a una rama. Genial cuando varias cosas corren a la vez. Terrible acá, porque Xcode se ancla a un solo directorio, y yo tenía abierto el checkout principal mientras mi rama vivía en el worktree.

Entonces, cuando agregué un target de pruebas unitarias con el asistente de Xcode, escribió el target en el árbol equivocado. El cambio de proyecto terminó en la rama incorrecta por completo. Pasé un buen rato moviendo un cambio de project.pbxproj y una carpeta entre árboles, y revirtiendo el otro lado.

El arreglo no fue un git más ingenioso. Fue abandonar los worktrees para este proyecto y usar una simple rama de feature sobre el checkout único. Ahora git y Xcode apuntan a la misma carpeta, y una acción de la interfaz gráfica no puede aterrizar en un lugar que no se está mirando. El modelo de ramas debe coincidir con las herramientas usadas, una interfaz gráfica que solo ve un directorio quiere un directorio.

Trampa 3: los targets nuevos nacen demasiado nuevos

El target de pruebas no corría. Xcode 26.5 le asigna a un target recién creado el deployment target del toolchain (26.5), no el de la app (17.6). Así:

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.

Se puede probar que las pruebas están bien sin tocar el proyecto haciendo un override por línea de comandos, xcodebuild test ... IPHONEOS_DEPLOYMENT_TARGET=17.6, y después persistir el valor real en Build Settings. Con eso resuelto, doce casos de Swift Testing (ahora es el default para targets de pruebas nuevos, import Testing, @Test, #expect) corren en verde contra el mock, en el simulador, en cerca de una centésima de segundo.

Lo que le diría a mi yo del pasado

El mock no es una ocurrencia tardía de testing que se atornilla después. Para una app de MusicKit es la superficie principal de desarrollo, lo que realmente se mira el noventa por ciento del tiempo. Conviene construirlo primero, hacer que devuelva datos creíbles, y que sus escrituras sean inspeccionables. Entonces el build en dispositivo se convierte en lo que debería ser: el chequeo final, no el trabajo diario.

Y hay que mantener el control de versiones alineado con el editor. La configuración de aislamiento más sofisticada no vale nada si el IDE está editando en silencio una copia del proyecto distinta de la que se cree que se está usando.

Lecturas relacionadas