Pular para o conteúdo
Development

Construindo um app com MusicKit quando o MusicKit não roda no simulador

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

Esse app foi renomeado depois para Deep Cut Atlas. Aqui embaixo ele é chamado de “Discoverer” o tempo todo, porque era esse o nome dele no dia em que isso aconteceu.

Estou construindo um pequeno app iOS que vasculha a sua biblioteca da Apple Music para te mostrar coisas que você deveria conferir (o post sobre a configuração inicial tem o contexto). No primeiro dia, esbarrei no muro que todo desenvolvedor de MusicKit esbarra: o MusicKit não faz nada no simulador. Sem prompt de autenticação, sem biblioteca, sem busca no catálogo. Não dá erro. Só devolve resultados vazios, para sempre. Qualquer chamada real precisa de um iPhone físico logado numa conta com uma assinatura ativa da Apple Music.

Isso quebra a coisa que eu mais gosto no SwiftUI: o ciclo rápido de ajustar uma view, dar run e ver o resultado no simulador um segundo depois. Se toda tela que mexe com música precisa de um build em aparelho, esse ciclo desaparece antes mesmo de o app existir.

Então, antes de escrever uma única feature, gastei um issue inteiro para tornar o simulador útil de novo.

O formato: um protocolo, duas implementações

A ideia é antiga, meio óbvia e funciona: colocar um protocolo entre o app e o framework, escrever uma implementação real e uma falsa, e escolher qual usar no momento do build.

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

É @MainActor porque a implementação real mexe em MusicLibrary.shared, que exige o main actor. Marcando o protocolo, o isolamento flui de graça para as duas implementações.

Pegadinha 1: você não pode devolver os próprios tipos do MusicKit

Meu primeiro instinto foi devolver [Artist], [Album], [Track] direto do MusicKit. Dois problemas mataram essa ideia.

Primeiro, Artist, Album e afins do MusicKit não têm inicializadores públicos. Só dá pra conseguir um fazendo uma requisição de verdade. Então um mock fisicamente não consegue construir um pra devolver. Só isso já encerra o debate.

Segundo, o arquivo da implementação real importa o MusicKit, então um Artist puro ficaria ambíguo com qualquer outra coisa chamada Artist. Então criei structs simples, de propriedade do próprio app:

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

O id é a string bruta do MusicItemID. O mock fabrica ids como "art.1"; o serviço real converte de volta para objetos do MusicKit quando precisa escrever. Esses DTOs acabaram sendo a decisão certa independente do mocking - o resto do app nunca importa o MusicKit, então o framework fica selado atrás de uma única parede.

O mock que registra o que você pediu pra ele fazer

As leituras devolvem dados de exemplo fixos. A parte interessante são as escritas: o mock não finge fazer nada, ele registra a requisição para que um teste (ou uma tela de feature) possa verificar depois.

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

É esse registro que me permite construir o botão “adicionar à playlist” no simulador e provar que ele chamou o serviço corretamente, sem nenhum aparelho físico no meio do caminho.

A troca na porta de entrada

Um pequeno container escolhe a implementação, e o modelo de autenticação força um estado autorizado no simulador para que a trava não me prenda numa tela que nunca vai se resolver:

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

No aparelho, o serviço real e o MusicAuthorization.request() de verdade assumem. O código de feature acima dessa linha não faz ideia de com qual dos dois está falando.

Pegadinha 2: o worktree que engoliu meu test target

Essa aqui a culpa é minha, não da Apple. Eu vinha rodando cada issue num git worktree, um diretório de trabalho separado vinculado a uma branch. Ótimo quando várias coisas rodam ao mesmo tempo. Péssimo nesse caso, porque o Xcode se ancora a um diretório só, e eu estava com o checkout principal aberto enquanto minha branch vivia no worktree.

Então, quando adicionei um target de teste unitário pelo assistente do Xcode, ele escreveu o target na árvore errada. A mudança no projeto foi parar numa branch completamente diferente. Passei um bom tempo movendo uma mudança em project.pbxproj e uma pasta entre as árvores e revertendo o outro lado.

A correção não foi um git mais esperto. Foi abandonar worktrees nesse projeto e usar uma feature branch simples num único checkout. Agora git e Xcode apontam para a mesma pasta, e uma ação de GUI não consegue ir parar num lugar que eu não estou olhando. Combine seu modelo de branching com suas ferramentas - uma GUI que só enxerga um diretório quer um diretório só.

Pegadinha 3: targets novos nascem novos demais

O target de teste não rodava. O Xcode 26.5 dá a um target recém-criado o deployment target do próprio toolchain (26.5), não o do app (17.6). Então:

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.

Você consegue provar que seus testes estão bons sem mexer no projeto sobrescrevendo pela linha de comando - xcodebuild test ... IPHONEOS_DEPLOYMENT_TARGET=17.6 - e depois persistir o valor real em Build Settings. Com isso corrigido, doze casos de Swift Testing (é o padrão para novos test targets agora - import Testing, @Test, #expect) rodam verdinhos contra o mock, no simulador, em cerca de um centésimo de segundo.

O que eu diria pro eu do passado

O mock não é um retoque de testes que você encaixa depois. Para um app de MusicKit, ele é a superfície principal de desenvolvimento, a coisa que você realmente olha noventa por cento do tempo. Construa ele primeiro, faça ele devolver dados críveis, e deixe as escritas dele inspecionáveis. Aí o build em aparelho vira o que deveria ser: a checagem final, não a rotina diária.

E mantenha seu controle de versão honesto com seu editor. A configuração de isolamento mais sofisticada não vale nada se sua IDE está silenciosamente editando uma cópia diferente do projeto daquela que você acha que está.

Leitura relacionada

Development

A playlist que já tinha o nome certo

Um artefato de renomeação, uma API sem campo de autor pra atualizar, e um teste em dispositivo real provando que o bug já tinha se corrigido sozinho, fechado como aceitar como está.

Ler

Você também pode achar útil

Proton

Proton Drive

Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais