Saltar al contenido
Development

La prueba de SwiftData que falló sin ningún mensaje de error

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

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

Esta semana agregué una pequeña capa de persistencia a mi app de música, un store que registra qué lanzamientos de álbum quedaron marcados como “No me interesa” para que dejen de aparecer. Nada exótico: un modelo de SwiftData, un struct que envuelve un ModelContext, cuatro métodos (dismiss, undo, clear, read). Escribí cinco pruebas unitarias. Las cinco fallaron con un crash.

No fallaron con un error común. Se cayeron. La salida fue tan útil como cabía esperar:

✗ DismissalStoreTests / dismissPersistsKey(): Crash
✗ DismissalStoreTests / dismissIsIdempotent(): Crash
✗ DismissalStoreTests / undoRemovesKey(): Crash
...

Sin mensaje. Sin stack trace que valiera la pena leer. Solo “Crash” al lado de cada prueba de la suite.

La parte que resultaba confusa

Esto fue lo que me desconcertó: tenía otra prueba, en otro archivo, que usaba exactamente el mismo store contra exactamente el mismo contenedor de SwiftData en memoria. Y esa pasaba. Verde. Así que el store funcionaba. El modelo funcionaba. El contenedor en memoria funcionaba. Pero en el momento en que probé el store por su cuenta, todo explotó.

Cuando el mismo código pasa en un lugar y falla en otro, la diferencia no está en el código, está en el contexto que lo rodea. Así que puse los dos casos uno al lado del otro.

La prueba que pasaba construía su contenedor en línea y lo retenía:

let container = try ModelContainer(for: DismissedRelease.self,
                                   configurations: config)
let store = SwiftDataDismissalStore(context: container.mainContext)
// ... container stays in scope for the whole test

Las pruebas que fallaban usaban un helper para reducir el boilerplate:

private func makeStore() throws -> SwiftDataDismissalStore {
    let container = try ModelContainer(for: DismissedRelease.self,
                                       configurations: config)
    return SwiftDataDismissalStore(context: container.mainContext)
}

¿Ya se nota el problema? El helper retorna el store y descarta el contenedor. En cuanto makeStore() retorna, nada mantiene una referencia a container, así que se libera. El store sigue vivo, sigue reteniendo container.mainContext, pero el contexto ahora apunta a un almacén de respaldo que ya no existe. El primer fetch o save se cae al vacío.

Lo que había internalizado mal

Había estado pensando en ModelContext como la cosa que es dueña de los datos. No lo es. El contenedor es dueño del store; el contexto es solo un espacio de trabajo que se comunica con él. Y un contexto no mantiene vivo a su contenedor. Si se le entrega a un servicio container.mainContext y luego se descarta el contenedor, se termina construyendo un use-after-free en cámara lenta.

La razón por la que esto nunca apareció en la app real es que ahí el contenedor vive para siempre, está vinculado a la escena de SwiftUI con .modelContainer(…) y se mantiene vivo mientras la app está corriendo. Esto solo se nota cuando un contenedor es una variable local que sale de scope, que es exactamente lo que fomenta un pequeño helper de prueba prolijo.

La corrección

Mantener el contenedor vivo mientras algo siga usando su contexto. En Swift Testing, la forma prolija es una propiedad almacenada en la prueba, configurada en init (que corre desde cero por cada prueba):

@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 se convirtieron en cinco pruebas exitosas, sin ningún otro cambio.

La regla más general que me llevo: lo que sea dueño de un servicio que envuelve un contexto también tiene que ser dueño del contenedor, o sobrevivirlo. Y si un servicio está pensado para sobrevivir a quien lo creó, no conviene entregarle un contexto pelado, conviene entregarle el contenedor y dejar que él construya su propio contexto. Un contexto es algo prestado. Hay que tratarlo como tal.

Una pequeña nota al pie de Swift 6

Mientras armaba todo esto me topé con un segundo problema menor, sin relación con el anterior. Intenté darle a un helper de prueba un store por defecto:

func loadedVM(store: any DismissalStoring = InMemoryDismissalStore()) async -> ...

y obtuve “call to main actor-isolated initializer in a synchronous nonisolated context.” El init de mi store es @MainActor, y los valores de argumento por defecto se evalúan en un contexto no aislado, así que no se puede llamar ahí a un inicializador aislado al actor principal. La solución es poner nil como valor por defecto del parámetro y construir el objeto real dentro del cuerpo de la función, donde el aislamiento sí aplica. Es menor, pero es el tipo de cosa que suena como un problema profundo de concurrencia y en realidad es solo “mover esta expresión tres líneas más abajo.”

Lecturas relacionadas