Pular para o conteúdo
Development

O teste do SwiftData que falhou sem nenhuma mensagem de erro

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

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

Você também pode achar útil

NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
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
Airalo

eSIM 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