wandres.dev
TESTSTORE · testing exhaustivo

send y la mutación esperada

Enviar una acción a un `TestStore` no es ejecutarla y mirar el resultado: es acompañarla de una closure que describe, mutación a mutación, cómo tiene que quedar el estado. Esta lección disecciona esa closure —de dónde sale el valor que recibe, por qué se escribe como una mutación y no como una comparación, qué significa `$0` y por qué la firma es `inout`—, fija la regla del si y sólo si que exige closure exactamente cuando hubo cambio, y muestra el flujo de trabajo que convierte el diff de fallo en un diálogo donde el reducer dicta y tú ratificas.

⏱ 18 min

La primera vez que uno escribe await store.send(.incrementar) seguido de una llave abierta, el instinto interpreta mal la escena: parece que dentro de esa closure vamos a inspeccionar el estado resultante, como quien abre la caja después del truco. No es eso lo que pasa. Lo que el TestStore te entrega ahí dentro es el estado anterior, tal como estaba justo antes de enviar la acción, y lo que espera de ti no es una comprobación sino una escritura: mutar esa copia hasta dejarla exactamente igual a como crees que quedará el mundo cuando el reducer termine. Después ejecuta el reducer de verdad, y compara. La closure de send es, por tanto, una predicción escrita en el lenguaje de las mutaciones, y esa elección de forma —mutar en vez de comparar— tiene consecuencias precisas sobre lo que puedes decir, lo que estás obligado a decir y lo que el test acaba documentando.

🎯 Al terminar esta lección sabrás
  • Entender qué valor recibe la closure de send, por qué es el estado anterior y por qué se muta con inout en lugar de compararse.
  • Escribir aserciones exhaustivas campo a campo, incluidas las de estado anidado, colecciones y enumeraciones.
  • Aplicar la regla del si y sólo si: hay closure exactamente cuando hubo mutación, ni más ni menos.
  • Usar el diff de fallo como herramienta de descubrimiento en vez de predecir el estado a ciegas.

La closure predice, no observa

La firma de send recibe la acción y una closure de aserción que toma el estado por referencia mutable. Dentro de ella, $0 es una copia del estado previo al envío, y tu trabajo consiste en llevarla a mano hasta el estado que esperas.

@Test
func incremento() async {
  let store = TestStore(initialState: Contador.State(cuenta: 0)) {
    Contador()
  }

  await store.send(.incrementar) {
    $0.cuenta = 1        // predicción: partiendo de 0, debe quedar en 1
  }
  await store.send(.incrementar) {
    $0.cuenta = 2        // el punto de partida es ahora el estado ya avanzado
  }
}

Que la closure sea una mutación y no un predicado booleano cambia la naturaleza del enunciado. Un predicado permite ser vago —afirmar que la cuenta es positiva, que la lista no está vacía— y la vaguedad es exactamente el hueco por donde se cuelan las regresiones. Una mutación no admite grados: o dejas la copia idéntica al resultado real o no la dejas, y la igualdad estructural que el TestStore calcula después no tiene zonas grises. Escribir la aserción como mutación es, en el fondo, obligarse a decir el estado completo sin poder esconderse detrás de una descripción parcial.

Que el valor entregado sea el estado anterior también es deliberado y economiza esfuerzo: sólo escribes lo que cambia. Los cuarenta campos que siguen igual no aparecen en la closure porque ya están en la copia, intactos, y el silencio sobre ellos es una afirmación fuerte —«esto no se movió»— y no una omisión. Ahí está la clave de que un test exhaustivo no sea insoportablemente largo: la exhaustividad se paga en cambios declarados, no en campos enumerados.

Rasgo Aserción que observa Closure que declara
Qué se escribe comprobaciones sobre lo que recuerdas mirar la mutación exacta que esperas
Alcance del enunciado los campos citados; el resto queda libre el estado entero, por omisión incluida
Admite vaguedad sí: mayor que cero, no vacío, contiene algo no: o el valor coincide o no coincide
Puede aprobar un bug sí, si el bug cae fuera de lo citado sólo si lo declaras tú a propósito
Qué cuesta mantener crece con el número de comprobaciones crece con el número de cambios reales

Anatomía de una aserción campo a campo

Cuando el estado deja de ser un entero y se convierte en una estructura con hijos, colecciones y enumeraciones, la closure sigue siendo lo mismo —mutaciones sobre una copia— pero conviene conocer sus gestos idiomáticos.

await store.send(.filtroCambio(.pendientes)) {
  $0.filtro = .pendientes                      // enum simple
  $0.seleccion = nil                           // opcional que se limpia
  $0.tareas[id: idDeLaPrimera]?.destacada = true   // dentro de una colección identificada
  $0.detalle?.borrador.titulo = "Nuevo"        // estado hijo opcional
  $0.orden.append(.porFecha)                   // colección que crece
}

Repara en dos detalles que suelen desconcertar. El primero es el encadenamiento opcional: si el estado hijo es nil en el momento de la aserción, la mutación no se aplica y el TestStore te lo señalará como una diferencia, porque tu predicción presuponía una presencia que no existía. El segundo es que puedes escribir cualquier código Swift dentro de la closure —calcular un valor, iterar, construir un modelo— siempre que el resultado sea determinista; la closure no es un mini lenguaje de aserciones, es Swift corriente mutando un valor.

El caso incómodo es el del valor que la propia feature fabrica: un identificador, una fecha, un número aleatorio. No puedes predecir lo impredecible, pero tampoco hace falta rendirse a una aserción vaga, porque la salida ya la conoces desde el Nivel 13: si el valor entra por una dependencia, en la prueba entra por una dependencia controlada y vuelve a ser predecible.

let store = TestStore(initialState: Lista.State()) {
  Lista()
} withDependencies: {
  $0.uuid = .incrementing        // 0, 1, 2... en vez de identidades al azar
  $0.date = .constant(Date(timeIntervalSince1970: 0))
}

await store.send(.anadirPulsado) {
  $0.filas.append(Fila.State(id: UUID(0)))   // literal exacto, no aproximación
  $0.creadaEn = Date(timeIntervalSince1970: 0)
}
✍️

Mutar, no comparar

La closure escribe el estado esperado. No hay predicados, no hay comparaciones parciales, no hay lugar para la vaguedad.

🕰️

Parte del estado anterior

Recibes el mundo tal como estaba antes de la acción; sólo declaras la diferencia. Lo no escrito significa que no cambió.

⚖️

Regla del si y sólo si

Closure cuando hay mutación; sin closure cuando no la hay. Una aserción de más falla igual que una de menos.

🔍

El diff enseña

Cuando fallas, el mensaje no es un veredicto sino un dictado: te dice exactamente qué línea sobra y cuál falta.

La regla del si y sólo si merece un párrafo propio porque es la que más incomoda al principio. Si envías una acción que no toca el estado —una que sólo dispara un efecto, por ejemplo— debes escribir await store.send(.aparecer) sin closure, y añadirle una vacía no es inocuo: el TestStore protesta porque una closure vacía afirma que había algo que declarar. Y a la inversa, omitir la closure de una acción que sí mutó produce el fallo clásico. La bidireccionalidad no es celo excesivo, es lo que impide que los tests acumulen ruido decorativo con el tiempo, esas líneas que nadie relee y que dejan de significar nada.

El diff como dictado: deja que el reducer te lo diga

De todo lo anterior se sigue un flujo de trabajo que invierte el orden natural de la tarea. No hace falta que sepas de antemano el estado exacto resultante: envía la acción con la closure vacía o con lo poco que tengas claro, ejecuta y lee lo que el TestStore te devuelve.

await store.send(.cargarPulsado) {
  // a propósito incompleta
  $0.cargando = true
}

// El TestStore falla e imprime algo así:
//   − $0.intentos = 0
//   + $0.intentos = 1
//   − $0.mensajeError = "algo"
//   + $0.mensajeError = nil

Copias esas dos líneas dentro de la closure y el test pasa. Escribir la prueba se convierte en un diálogo: el reducer declara lo que hace y tú te limitas a ratificarlo. Pero conviene subrayar el matiz que separa este flujo de una trampa: ratificar no es rendirse. Cada línea que el diff te ofrece hay que leerla y decidir si describe un comportamiento que quieres o un descuido que acabas de descubrir. La mitad de los bugs que el TestStore encuentra aparecen justo en ese instante, cuando alguien mira un + inesperado y se pregunta por qué demonios ese campo se está moviendo aquí.

flowchart TD
A[send de una accion] --> B[Copia del estado anterior]
B --> C[Tu closure la muta: estado esperado]
A --> D[Reducer real muta el estado real]
C --> E{Son iguales}
D --> E
E -->|si| V[Verde: la prediccion se cumplio]
E -->|no| X[Rojo con diff de lo que sobra y lo que falta]
X --> F[Lees, decides si es comportamiento o bug, y ratificas]
style V fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b
⚠️
Cuidado con las aserciones que se calculan solas

Escribir $0.total = $0.items.reduce(0, +) dentro de la closure parece elegante y es peligroso: si el reducer calcula el total con la misma fórmula equivocada, el test pasa porque estás reproduciendo el bug en el enunciado en vez de fijar el valor. La aserción debe ser un literal siempre que sea posible —$0.total = 42—, porque su función es anclar el comportamiento a una expectativa independiente y no volver a derivarlo desde la misma lógica que pretende verificar.

Predecir obliga a comprender; observar sólo obliga a mirar

Detrás de la incomodidad inicial de escribir el estado esperado a mano hay una diferencia epistemológica que vale la pena nombrar. Observar un resultado y aceptarlo es una operación pasiva: el sistema actúa, tú registras, y el registro es verdadero por construcción porque copia lo que ocurrió. Un test así jamás puede contradecir al código, sólo puede reflejarlo, y por eso los tests que se escriben mirando la salida tienden a fosilizar los bugs junto con las funciones. Predecir es otra cosa. Cuando escribes $0.cargando = true antes de ejecutar nada estás emitiendo un enunciado que puede ser falso, y esa posibilidad de ser falso —que es la definición misma de contenido empírico— es lo único que hace que el test informe. La forma de la closure está diseñada para forzar esa postura: no te deja preguntar, te obliga a comprometerte. Y el compromiso tiene un efecto secundario que ningún framework puede fabricar por ti: para predecir el estado siguiente hay que haber entendido la feature, y quien no la entiende descubre que no la entiende justo aquí, en la línea en blanco donde debería estar la predicción. De ahí que el flujo del diff no contradiga esta tesis sino que la complete. El TestStore te deja equivocarte y te muestra la verdad, sí, pero la muestra en forma de diferencia contra lo que afirmaste, no en forma de resultado desnudo. Lo que ves no es el estado del sistema: es la distancia entre tu modelo mental y el sistema. Ese es exactamente el objeto que una prueba debería medir, y casi ninguna mide.

⚔️ Convierte una predicción en una conversación
  1. Elige una acción de tu feature que mute al menos tres campos, incluido uno anidado dentro de un estado hijo o de una colección identificada. Escribe la closure entera de memoria, sin mirar el reducer.
  2. Ejecuta el test y compara tu predicción con el diff. Cada diferencia es un hueco en tu modelo mental de la feature; anótala antes de corregirla.
  3. Sustituye una aserción literal por una calculada a partir del propio estado y vuelve a ejecutar. Comprueba que el test sigue verde e introduce entonces un bug en la fórmula del reducer para ver cómo el test lo aprueba.
  4. Envía una acción que no cambie el estado con una closure vacía y observa el fallo. Explica en una frase por qué la aserción redundante es tan grave como la omitida.
  5. Recorre un test antiguo tuyo y busca aserciones vagas del estilo «no está vacío» o «es mayor que cero». Reescríbelas como mutaciones exactas y cuenta cuántos comportamientos no documentados aparecen.