O teste do SwiftData que falhou sem nenhuma mensagem de erro
Este app foi renomeado depois para Deep Cut Atlas. Abaixo ele é chamado de “Discoverer” o tempo todo, porque foi assim que se chamava no dia em que isso aconteceu.
Esta semana adicionei uma pequena camada de persistência ao meu app de música: um store que registra quais lançamentos de álbum você marcou como “Not Interested” para que parem de aparecer. Nada exótico: um model do SwiftData, uma struct que envolve um ModelContext, quatro métodos (dismiss, undo, clear, read). Escrevi cinco testes unitários. Todos os cinco travaram.
Não falharam. Travaram. A saída era praticamente inútil:
✗ DismissalStoreTests / dismissPersistsKey(): Crash
✗ DismissalStoreTests / dismissIsIdempotent(): Crash
✗ DismissalStoreTests / undoRemovesKey(): Crash
...
Nenhuma mensagem. Nenhum stack trace que valesse a pena ler. Só “Crash” ao lado de cada teste da suíte.
A parte que gerou confusão
Aqui está o que me confundiu: eu tinha um teste diferente, em um arquivo diferente, que usava exatamente o mesmo store contra exatamente o mesmo container do SwiftData em memória. E ele passava. Verde. Então o store funcionava. O model funcionava. O container em memória funcionava. Mas no momento em que testei o store sozinho, tudo explodiu.
Quando o mesmo código passa em um lugar e trava em outro, a diferença não está no código, está no contexto ao redor dele. Então coloquei os dois lado a lado.
O teste que passava construía seu container inline e mantinha uma referência a ele:
let container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
let store = SwiftDataDismissalStore(context: container.mainContext)
// ... container stays in scope for the whole test
Os testes que travavam usavam um helper para cortar o boilerplate:
private func makeStore() throws -> SwiftDataDismissalStore {
let container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
return SwiftDataDismissalStore(context: container.mainContext)
}
Percebeu? O helper retorna o store e descarta o container. Assim que makeStore() retorna, nada mantém uma referência a container, então ele é desalocado. O store continua vivo, ele está segurando o container.mainContext, mas o context agora aponta para um backing store que não existe mais. O primeiro fetch ou save cai no vazio.
O que eu tinha internalizado errado
Eu vinha pensando no ModelContext como a coisa que possui os dados. Não é. O container possui o store; o context é só um espaço de trabalho que conversa com ele. E um context não mantém seu container vivo. Passe container.mainContext para um serviço e depois descarte o container, e você construiu um use-after-free em câmera lenta.
O motivo de isso nunca ter aparecido no app de verdade é que lá o container vive para sempre, ele está anexado à scene do SwiftUI com .modelContainer(...) e permanece vivo enquanto o app estiver rodando. Você só vê esse problema quando um container é uma variável local que sai de escopo, que é exatamente o que um helper de teste bem arrumadinho incentiva.
A correção
Manter o container vivo enquanto qualquer coisa usar seu context. No Swift Testing, o jeito limpo é uma stored property no teste, configurada no init (que roda do zero a cada teste):
@MainActor
struct DismissalStoreTests {
let container: ModelContainer // retained for the test's lifetime
let store: SwiftDataDismissalStore
init() throws {
container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
store = SwiftDataDismissalStore(context: container.mainContext)
}
}
Cinco crashes viraram cinco sucessos, sem nenhuma outra mudança.
A regra mais geral que tirei disso: o que quer que possua um serviço que envolve um context precisa possuir, ou sobreviver a, o container também. E se um serviço deve sobreviver a quem o criou, não entregue a ele um context solto, entregue o container e deixe que ele crie seu próprio context. Um context é algo emprestado. Trate-o como tal.
Uma pequena nota de rodapé sobre Swift 6
Enquanto configurava isso, esbarrei em um segundo problema, sem relação com o primeiro. Tentei dar a um helper de teste um store padrão:
func loadedVM(store: any DismissalStoring = InMemoryDismissalStore()) async -> ...
e recebi “call to main actor-isolated initializer in a synchronous nonisolated context”. O init do meu store é @MainActor, e valores de argumento padrão são avaliados em um contexto nonisolated, então não dá para chamar ali um initializer main-actor. Defina o parâmetro como nil por padrão e construa o objeto de verdade dentro do corpo da função, onde o isolamento realmente se aplica. Pequeno, mas é o tipo de coisa que parece um problema profundo de concorrência e na real é só “mover essa expressão três linhas para baixo”.
Leitura relacionada
Quando um conjunto de filtros silenciosamente vira uma funcionalidade
O armazenamento de 'Não Tenho Interesse' só guardava chaves. Depois ele precisou virar uma tela, e o modelo precisou carregar sua própria descrição.
Correndo contra a main actor: quando o await é a condição de corrida
@MainActor elimina data races, não races de interleaving. Um token de geração, uma guarda contra falha desatualizada, e um teste de race determinístico sem sleeps.
Meus mocks não conseguiam falhar: injeção de erro e um crash de isolamento no Swift 6
Todo branch de catch era código morto nos testes. Uma costura de falha de 15 linhas resolveu isso, e depois uma suíte de testes sem anotação derrubou o runner inteiro.
Você também pode achar útil
NordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba maisProton 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 maiseSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba mais