`exhaustivity = .off`: afirmar solo lo que importa
Una sola línea convierte al juez implacable en un testigo colaborador: con `exhaustivity` en `.off` el `TestStore` comprueba lo que afirmas y guarda silencio sobre lo demás, salta las acciones intermedias hasta encontrar la que pediste y deja de exigir que el árbol entero quede apagado al terminar. Esta lección precisa qué se relaja exactamente y qué no —las dependencias sin implementar siguen fallando—, enseña a leer los avisos informativos de `showSkippedAssertions`, presenta `assert`, `skipReceivedActions`, `skipInFlightEffects` y `finish`, y defiende la relajación por tramos frente a la relajación global.
El modo no exhaustivo del TestStore cabe en una línea, y esa brevedad esconde una decisión de diseño notable: en lugar de ofrecerte un tipo distinto de store para los tests laxos, TCA convierte la exigencia en una propiedad mutable del mismo objeto, que puedes cambiar tantas veces como quieras dentro de un mismo test. La consecuencia práctica es que la exhaustividad deja de ser un régimen que se elige al principio y pasa a ser un dial que se gira según el tramo del flujo que estés cruzando. Pero para girarlo bien hace falta saber con precisión quirúrgica qué comprobaciones se apagan, cuáles siguen encendidas pase lo que pase, y cómo leer los avisos que el TestStore sigue emitiendo cuando le pides que no falle.
- Enunciar exactamente qué tres comprobaciones se relajan con
exhaustivityen.offy cuáles siguen activas. - Leer los avisos informativos de
.off(showSkippedAssertions: true)y decidir cuáles merecen ascender a aserción real. - Usar
assert,skipReceivedActions,skipInFlightEffectsyfinishcon criterio y no como analgésicos. - Practicar la relajación por tramos: exhaustivo en el nudo del comportamiento, laxo en el atrezo que lo rodea.
Qué se apaga exactamente
exhaustivity es una propiedad del TestStore, no un parámetro del inicializador, y admite tres valores: .on, que es el de por defecto; .off, y .off(showSkippedAssertions: true). Cambiarla no altera en nada la ejecución: el reducer real sigue corriendo entero, los efectos siguen disparándose y el estado sigue evolucionando igual. Lo único que cambia es la severidad con la que el store juzga tus afirmaciones.
| Regla en modo exhaustivo | Comportamiento con .off |
|---|---|
| Toda mutación de estado debe declararse | Se comprueba lo que declaras | el resto se aplica en silencio |
| Toda acción emitida debe recibirse en orden | receive salta las intermedias hasta encontrar la pedida |
| No puede quedar ningún efecto en vuelo | El test termina sin protestar por tareas vivas |
| Una closure sin mutación real es un error | Una closure vacía o parcial es legítima |
@Test
func compraCompleta() async {
let store = TestStore(initialState: Tienda.State()) {
Tienda()
} withDependencies: {
$0.catalogo.cargar = { [.camiseta] }
$0.pagos.cobrar = { _ in .exito }
}
store.exhaustivity = .off
await store.send(\.view.aparecio)
await store.send(\.view.anadirPulsado, Producto.camiseta)
await store.send(\.view.pagarPulsado)
await store.receive(\.pagoCompletado) {
$0.pedidoConfirmado = true
}
}
Tres detalles cargan el peso de este ejemplo. El primero: los tres send no llevan closure aunque cada uno mueva media docena de campos, y eso ya no es un error. El segundo: entre el pagarPulsado y el pagoCompletado el reducer emitió probablemente cuatro o cinco acciones —validación del carrito, escritura de telemetría, respuesta del catálogo—; el receive las atraviesa todas, aplicando sus mutaciones en silencio, hasta dar con la que nombraste. El tercero, el más importante: la única línea de aserción del test es exactamente la garantía que el test existe para proteger.
Y una advertencia que no conviene diluir: lo que afirmas sigue siendo obligatorio y sigue siendo exacto. .off no significa aserciones aproximadas; significa aserciones parciales. Si escribes que $0.pedidoConfirmado = true y el reducer lo dejó en false, el test falla igual de rojo que antes. La relajación afecta a la cobertura de la afirmación, nunca a su veracidad.
Los avisos que el TestStore sigue emitiendo
Apagar la exhaustividad no hace que el TestStore deje de calcular la diferencia entre lo que ocurrió y lo que afirmaste: solo hace que deje de suspenderte por ella. Con .off(showSkippedAssertions: true) esa información vuelve al informe del test como fallo esperado —XCTExpectFailure en XCTest, withKnownIssue en Swift Testing—, de modo que aparece en gris, no rompe la ejecución y sigue siendo legible.
store.exhaustivity = .off(showSkippedAssertions: true)
await store.send(\.view.pagarPulsado)
// Aviso informativo, no fallo:
// Un cambio de estado no fue afirmado
// − $0.procesando = false
// + $0.procesando = true
// − $0.telemetria.eventosEnCola = 0
// + $0.telemetria.eventosEnCola = 1
//
// Se saltaron acciones recibidas:
// • .carritoValidado
// • .telemetria(.enviado)
Ese informe es la pieza pedagógica más valiosa del modo no exhaustivo, y casi todo el mundo lo desactiva antes de leerlo una sola vez. Sirve para tres cosas distintas. Como inventario, te enseña todo lo que un flujo hace de verdad, incluidas responsabilidades que ni sabías que estaban ahí. Como radar de regresiones sordas, te avisa de que aparecieron acciones nuevas en un camino que creías estable. Y como cantera de aserciones, te ofrece candidatos concretos a ascender: si en la lista aparece algo que sí importa —una sesión que se invalida, una caché que se purga—, ese es el momento de escribir la línea que lo fija.
La disciplina que recomiendo es asimétrica: .off(showSkippedAssertions: true) mientras escribes o revisas el test, .off a secas cuando lo dejas en la suite, para no llenar el informe de ruido gris permanente. Y una relectura periódica en modo verboso de los tests de integración críticos, que suele descubrir más de lo que uno espera.
Lo que no se relaja nunca
Hay una asimetría deliberada en el diseño de Exhaustivity que conviene tener muy presente, porque desmonta la lectura de que .off es un modo permisivo. La exhaustividad gobierna la relación entre tus aserciones y el estado, y nada más. Todo lo que constituye la frontera del sistema sigue vigilado con la misma dureza.
Una dependencia sin sobrescribir cuyo testValue no está implementado sigue produciendo un fallo cuando el reducer la usa, y ese fallo no es de aserción sino de diseño del test: el TestStore te está diciendo que la feature habló con el mundo por un canal que no controlaste. Del mismo modo, un receive de una acción que nunca llega sigue fallando, porque no es una omisión tuya sino una afirmación falsa; en modo no exhaustivo el store consume acciones hasta encontrar la que pediste, y si se le acaban antes, has afirmado algo que no ocurrió. También sigue vigente la exigencia de que el reducer se comporte de forma determinista y de que la acción que envías exista.
// Estas herramientas son explícitas: dicen en el propio test que renuncias a algo
await store.send(\.view.cerrarSesionPulsado)
await store.skipReceivedActions() // vacía la cola sin afirmar nada
await store.skipInFlightEffects() // da por buenas las tareas que siguen vivas
await store.finish() // o al contrario: espera a que terminen de verdad
store.assert { // afirma el estado acumulado sin enviar nada
$0.sesion = nil
}
Merece la pena entender por qué estas cuatro existen incluso teniendo .off. skipReceivedActions y skipInFlightEffects funcionan también en modo exhaustivo y dejan constancia escrita de la renuncia en el punto exacto donde ocurre, que es infinitamente más honesto que una relajación global al principio del archivo. finish es su opuesto: en vez de perdonar los efectos vivos, espera a que acaben, y es lo que quieres cuando el final del flujo forma parte de la garantía. Y assert resuelve el problema que aparece en cuanto dejas de afirmar por pasos: cómo comprobar el estado acumulado tras una ráfaga de acciones sin colgar la aserción de ninguna de ellas en particular.
En modo exhaustivo era imposible escribir un test vacío que pasara: el store te obligaba a hablar. Con .off, un test compuesto solo de send sin closures es verde por construcción y no protege absolutamente nada, aunque en el informe de cobertura luzca igual que uno bueno. La regla mínima que impongo en revisión es simple: todo test no exhaustivo debe contener al menos una aserción que un fallo real pudiera romper, y esa aserción debe corresponder a la frase que justifica la existencia del test.
Relajación por tramos
Como exhaustivity es una propiedad mutable, la unidad de relajación no tiene por qué ser el test: puede ser el tramo. Esto permite un patrón que combina lo mejor de los dos regímenes y que, en mi experiencia, es la forma correcta de escribir casi todos los tests de integración: laxo para llegar al sitio, estricto para examinar lo que allí ocurre.
store.exhaustivity = .off
await store.send(\.view.aparecio)
await store.send(\.credenciales.correoEscrito, "a@b.c")
await store.send(\.credenciales.claveEscrita, "secreto")
store.exhaustivity = .on
await store.send(\.credenciales.entrarPulsado) {
$0.credenciales.entrando = true
$0.credenciales.error = nil
}
await store.receive(\.entradaFallida) {
$0.credenciales.entrando = false
$0.credenciales.error = .credencialesInvalidas
}
La preparación del escenario —abrir la pantalla, rellenar dos campos— no es lo que este test verifica y no merece una sola línea de aserción. El manejo del fallo de autenticación sí lo es, y ahí quieres de vuelta al juez implacable, con su diff campo a campo y su prohibición de tragarse un efecto en silencio. El resultado es un test que se lee como una frase con énfasis: todo el ruido en voz baja y la garantía en negrita.
flowchart TD S[send en modo no exhaustivo] --> R[El reducer corre entero igual que siempre] R --> C[Compara estado real contra lo que afirmaste] C --> OK[Coincide en lo afirmado, sigue adelante] C --> NO[Difiere en lo afirmado, falla el test] C --> SK[Lo no afirmado se aplica en silencio] SK --> V[Con showSkippedAssertions se informa en gris] R --> Q[Cola de acciones recibidas] Q --> RC[receive salta hasta encontrar la pedida] RC --> AG[Si se agota la cola sin encontrarla, falla] style NO fill:#f38ba8,color:#11111b style AG fill:#f38ba8,color:#11111b style V fill:#f9e2af,color:#11111b
La lectura ingenua de exhaustivity es la de un mando de volumen del rigor, donde .on es riguroso y .off es descuidado; la lectura correcta es que ese mando no cambia cuánto compruebas sino sobre qué dominio tu test se declara competente, y esa diferencia tiene consecuencias muy concretas en el diseño de una suite. Un test exhaustivo reclama autoridad total sobre el sujeto: dice que sabe todo lo que ocurre y se compromete a fallar ante cualquier desviación, incluidas las que no le importan. Un test no exhaustivo reclama autoridad parcial y la declara explícitamente en cada línea que escribe: dice que sobre estos campos y sobre esta secuencia se pronuncia, y sobre el resto se abstiene. Ninguna de las dos posturas es más honesta que la otra en abstracto; lo deshonesto es reclamar autoridad total sobre un dominio que no puedes conocer, porque entonces la única manera de mantener el compromiso es copiar la salida de la máquina, y ahí el test deja de ser un juez para volverse notario. Fíjate además en la elegancia de que el dial sea una propiedad mutable y no un tipo distinto: TCA está afirmando que el ámbito de autoridad puede variar dentro de un mismo recorrido, porque efectivamente varía —hay tramos de un flujo sobre los que tienes un modelo completo y tramos que solo atraviesas—, y forzar una única severidad para todo el test sería tan burdo como forzar un único nivel de detalle para toda una demostración matemática. De ahí sale la regla que gobierna esta lección entera y la siguiente: afirma exhaustivamente allí donde puedas predecir, abstente explícitamente allí donde solo puedas observar, y no dejes nunca que la abstención sea implícita, porque una abstención silenciosa y una garantía se parecen demasiado en el informe verde de una suite que nadie vuelve a leer.
- Toma el test de integración más largo de tu suite, ponle
exhaustivity = .offy borra todas las closures. Comprueba que sigue en verde y cuenta cuánto encogió. - Vuelve a añadir exactamente una aserción: la que corresponde a la frase que justifica el test. Rompe el comportamiento a propósito y confirma que el test detecta la rotura.
- Activa
.off(showSkippedAssertions: true)y lee el informe gris completo. Anota tres cosas que el flujo hacía y tú no sabías. - De esa lista, asciende a aserción real las que un usuario notaría si dejaran de ocurrir, y deja el resto como omisión consciente.
- Reescribe el test con relajación por tramos:
.offpara preparar el escenario y.onpara el nudo. Compara la legibilidad con la versión enteramente laxa y quédate con la que un compañero entendería sin preguntar.