Testing y migración: la persistencia bajo prueba y el formato versionado
Un estado que sobrevive al proceso plantea dos preguntas que ninguna de las lecciones anteriores podía responder: cómo se prueba algo que por definición escapa del test, y qué ocurre con lo ya guardado el día que el modelo cambia. Esta lección resuelve la primera mostrando que bajo pruebas los sustratos son efímeros y que `TestStore` asevera también lo compartido, y la segunda tratando el formato guardado como lo que realmente es —una API pública con los usuarios como contraparte— con envoltura versionada, migración en la carga y ficheros de referencia históricos en la suite.
La persistencia introduce en el programa el único tipo de dato que no puedes desplegar: el que ya está en los dispositivos de tus usuarios. Puedes corregir un reducer, reescribir una vista, cambiar de sustrato y publicar la versión nueva mañana, pero los ficheros escritos por la versión anterior seguirán exactamente como estaban, esperando a que alguien intente leerlos con un modelo que ya no coincide. Esta lección cierra el nivel atacando los dos frentes que ese hecho abre. El primero es metodológico: cómo se prueba de forma determinista un estado cuya razón de ser es sobrevivir al test. El segundo es de diseño: cómo se cambia el modelo sin borrarle nada a nadie, sabiendo que el formato guardado es un contrato que no se puede renegociar.
- Explicar por qué bajo pruebas los sustratos son efímeros y qué garantía de aislamiento se sigue de ello.
- Aseverar mutaciones de estado compartido en
TestStore, incluidas las que ocurren fuera del envío de una acción. - Reconocer que el formato guardado es una API pública y enumerar qué cambios lo rompen.
- Diseñar una envoltura versionada con migración en la carga y protegerla con ficheros de referencia históricos.
Bajo pruebas, los sustratos son efímeros
La primera sorpresa agradable es que no hay que hacer nada. Cuando el código corre en un contexto de test, las claves de fábrica no usan sus almacenes reales: appStorage se apoya en un almacén de preferencias efímero y exclusivo de ese test, fileStorage en un sistema de ficheros virtual que vive en memoria, e inMemory en un almacén recién creado. Ninguna prueba escribe en el disco del desarrollador, ninguna deja rastro para la siguiente, y la clase entera de fallos donde un caso pasa en solitario y falla dentro de la suite queda descartada por construcción.
De ahí se sigue una consecuencia práctica importante: para preparar el escenario no hay que interceptar nada, basta con escribir el valor antes de construir el store, porque la clave devuelve la misma referencia a quien la nombre.
@Test
func laBandejaCargaLosBorradoresGuardados() async {
@Shared(.borradores) var borradores = []
$borradores.withLock { $0 = [Borrador(id: UUID(0), texto: "sin terminar")] }
let store = TestStore(initialState: Bandeja.State()) { Bandeja() }
await store.send(.borradorDescartado(UUID(0))) {
$0.$borradores.withLock { $0 = [] }
}
}
El State no recibe los borradores por parámetro: los encuentra, porque la clave del test y la del estado son la misma clave y por tanto el mismo valor. Y si en algún caso concreto interesa ejercitar el sustrato real —comprobar que un JSON escrito por tu encode se lee de verdad— se sustituye el sistema de ficheros por el auténtico dentro del ámbito de esa prueba, que es la excepción deliberada y no la norma.
Aislamiento por defecto
Cada test recibe almacenes vacíos. No hay limpieza que recordar ni orden de ejecución que respetar.
Preparar es escribir
Declarar la clave en el test y mutarla con withLock basta para montar el escenario, sin inyecciones ni dobles.
Aserción exhaustiva
Toda mutación de un valor compartido debe declararse, igual que cualquier otro cambio del estado.
Aserción a distancia
Si otra feature muta la clave, el cambio aparece aquí. La compartición deja de ser invisible en cuanto hay pruebas.
Lo compartido también se asevera, y desde lejos
La segunda propiedad es más profunda que la primera y es la que convierte el estado compartido en algo defendible. Como el valor es una referencia y no una copia, TestStore puede detectar sus cambios ocurran donde ocurran; y como la exhaustividad es la norma de la casa, exige declararlos. Si una feature hija que ni siquiera aparece en el test muta la sesión, el test que observa esa clave falla hasta que alguien escriba la aserción correspondiente. La crítica habitual al estado compartido —que crea vínculos que nadie ve— queda así respondida en el único plano donde se puede responder: los vínculos se ven porque las pruebas obligan a escribirlos.
Queda el caso de las mutaciones que no siguen a ninguna acción: un valor que cambia porque respondió un observador externo, porque una tarea de fondo terminó o porque una feature ajena escribió mientras tanto. Para eso existe una aserción sin envío asociado.
// El valor compartido cambió sin que este store enviara nada
store.assert {
$0.$sesion.withLock { $0 = nil }
}
La lógica de tus features debe probarse contra almacenes efímeros, siempre. Pero conviene tener además una única prueba, aparte, que ejercite el sustrato de verdad: escribir, releer y comparar. No prueba tu dominio, prueba tu clave; y es la que se rompe el día que alguien cambia el codificador sin pensar en lo que ya está en disco.
El formato guardado es una API pública
Aquí termina la parte cómoda. Todo lo anterior protege el código; nada de ello protege los datos que ya existen. En el momento en que la primera versión escribió un fichero, la forma de ese fichero pasó a ser un contrato con una contraparte que no puede actualizarse y con la que no se puede negociar. La tabla enumera los cambios habituales y su efecto sobre lo ya guardado.
| Cambio en el modelo | Efecto sobre lo escrito por la versión anterior |
|---|---|
| Añadir una propiedad opcional | compatible: la clave ausente decodifica a nulo |
| Añadir una propiedad no opcional con valor por defecto | rompe: la síntesis de Codable lanza si la clave falta |
| Renombrar una propiedad | rompe: la clave vieja no se busca y la nueva no está |
| Eliminar una propiedad | compatible al leer: el decodificador ignora lo que sobra |
| Cambiar el tipo de una propiedad | rompe, salvo conversión numérica trivial |
| Añadir un caso a una enumeración | rompe al leer con la versión vieja, no con la nueva |
| Cambiar el codificador o la estrategia de fechas | rompe todo el fichero a la vez |
La segunda fila es la que sorprende a casi todo el mundo, y merece dejarla probada en código porque contradice la intuición.
struct Borrador: Codable, Equatable, Identifiable, Sendable {
let id: UUID
var texto: String
var fijado = false // el valor por defecto NO se usa al decodificar
}
La conformidad sintetizada no consulta el valor por defecto de la propiedad: si la clave fijado no está en el JSON, lanza un error de clave no encontrada. Y ese error, en una clave de fichero, no llega a ninguna parte visible: cae al valor por defecto de la declaración, la colección aparece vacía y la primera mutación del usuario sobrescribe el fichero bueno. Añadir un campo con valor por defecto es, literalmente, un borrado de datos diferido. Las dos salidas correctas son declararlo opcional o escribir la decodificación a mano.
init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(UUID.self, forKey: .id)
self.texto = try c.decode(String.self, forKey: .texto)
self.fijado = try c.decodeIfPresent(Bool.self, forKey: .fijado) ?? false
}
Versionar, migrar y probar contra el pasado
Parchear campo a campo funciona dos o tres veces y después se vuelve inmanejable. La solución estable es dejar de guardar el modelo desnudo y guardarlo dentro de una envoltura que declare su versión, de modo que la lectura sepa siempre a qué se enfrenta antes de intentar interpretarlo.
struct Sobre: Codable { var version: Int; var contenido: Data }
enum FormatoBorradores {
static let versionActual = 3
static func decodificar(_ datos: Data) throws -> [Borrador] {
let sobre = try JSONDecoder().decode(Sobre.self, from: datos)
switch sobre.version {
case 1: return try migrarDeV1(sobre.contenido)
case 2: return try migrarDeV2(sobre.contenido)
case 3: return try JSONDecoder().decode([Borrador].self, from: sobre.contenido)
default: throw ErrorDeFormato.versionDelFuturo(sobre.version)
}
}
static func codificar(_ valor: [Borrador]) throws -> Data {
let contenido = try JSONEncoder().encode(valor)
return try JSONEncoder().encode(Sobre(version: versionActual, contenido: contenido))
}
}
El caso final no es un adorno defensivo: un usuario puede volver a una versión anterior de la app o recibir por sincronización un fichero escrito por un dispositivo más moderno, y entonces la app vieja se encuentra con una versión que no conoce. Rendirse al valor por defecto ahí equivale a destruir datos perfectamente válidos; lanzar los preserva y permite mostrar un mensaje honesto.
flowchart TD F[Fichero en disco] --> S[Leer version del sobre] S --> V1[Version 1] --> M1[Migrar a 3] S --> V2[Version 2] --> M2[Migrar a 3] S --> V3[Version 3] --> OK[Decodificar directo] S --> VF[Version desconocida] --> E[Lanzar y conservar el fichero] M1 --> OK M2 --> OK OK --> ST[Valor compartido] style E fill:#f38ba8,color:#11111b style ST fill:#a6e3a1,color:#11111b
La migración solo es de fiar si está probada, y no puede probarse con datos que genere el propio código actual: hay que guardar ficheros de referencia reales de cada formato histórico en los recursos de la suite y verificar que todos siguen leyéndose. Es la única prueba de tu proyecto que jamás debe modificarse; si alguien la cambia para que pase, acaba de romperle la app a los usuarios más antiguos.
@Test(arguments: ["borradores-v1", "borradores-v2", "borradores-v3"])
func todoFormatoHistoricoSigueLeyendose(recurso: String) throws {
let url = Bundle.module.url(forResource: recurso, withExtension: "json")!
let valor = try FormatoBorradores.decodificar(try Data(contentsOf: url))
#expect(valor == esperadoTrasMigrar)
}
El código tiene una propiedad que resulta fácil olvidar por lo cómoda que es: se puede sustituir entero. Un despliegue reemplaza cada línea, y la versión anterior desaparece sin dejar más huella que un registro en el control de versiones. Los datos guardados no funcionan así, y esa diferencia no es de grado sino de naturaleza. En el instante en que tu app escribe el primer fichero, deja de ser un programa que se ejecuta y se convierte en un programa que tiene historia: hay algo ahí fuera, en dispositivos que no controlas, que fue producido por una versión tuya que ya no existe y que ninguna corrección futura podrá alcanzar. Toda la disciplina de esta lección se deduce de ese hecho. Versionar es admitir que habrá un lector que no eres tú; migrar es aceptar la responsabilidad de interpretar lo que escribió alguien que ya no está; y conservar ficheros de referencia intactos es lo más parecido que tiene la ingeniería a una obligación con el pasado. Hay algo más que higiene técnica en todo esto. Un fichero de notas es el trabajo de alguien, y la diferencia entre una app que lo respeta y una que lo pierde en silencio no se decide el día del incidente sino mucho antes, en la decisión aparentemente inocua de añadir un campo con valor por defecto un martes cualquiera. Persistir no es una función del programa: es una promesa, y la única forma de cumplir una promesa hecha a alguien con quien ya no puedes hablar es haberla escrito de manera que se cumpla sola.
- Escribe un test que prepare una clave compartida antes de crear el store y verifica que el estado la encuentra sin recibirla por parámetro.
- Haz que una feature hija mute una clave que otra observa, y comprueba que el test de la segunda falla hasta que escribas la aserción. Ese fallo es la respuesta a quien dice que lo compartido es invisible.
- Añade a un modelo persistido una propiedad no opcional con valor por defecto, escribe un fichero con la versión anterior y observa la secuencia completa: error de decodificación, colección vacía y sobrescritura del original.
- Introduce la envoltura versionada con migración en la carga y el caso de versión desconocida que lanza en vez de rendirse.
- Congela un fichero de referencia por cada formato que tu app haya escrito alguna vez, añade el test parametrizado que los lee todos, y deja escrito junto a él que ese test no se toca.