La prueba de SwiftData que falló sin ningún mensaje de error
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
Cuando un conjunto de filtro se convierte en silencio en una función
El almacén de No me interesa solo guardaba claves. Después tuvo que convertirse en una pantalla, y el modelo necesitó cargar su propia descripción.
Carreras en el main actor: cuando await es la condición de carrera
@MainActor elimina las condiciones de carrera de datos, no las de intercalado. Un token de generación, una protección contra fallos obsoletos, y una prueba de carrera determinista sin sleeps.
Mis mocks no podían fallar: inyección de errores y un crash de aislamiento en Swift 6
Cada rama catch era código muerto en las pruebas. Una costura de fallas de 15 líneas resolvió eso, y luego una sola suite de pruebas sin anotar tumbó todo el runner.