wandres.dev
COMPOSICIÓN Y TEST · dependencies, TestStore

TestStore: testing exhaustivo

El TestStore de TCA no es un ayudante opcional sino un juez implacable del comportamiento de una feature: por cada accion que le envias exige declarar exactamente como quedo el estado, campo a campo, y recibir uno a uno todos los efectos que esa accion disparo. Si el estado cambia en algo que no afirmaste, el test falla; si un efecto devuelve una accion que no recibiste, el test falla; si al terminar queda una tarea en vuelo, el test falla. Esta leccion cubre la closure de asercion de `send`, el `receive` de efectos, el control del tiempo con `TestClock` e `ImmediateClock` y el modo no exhaustivo `exhaustivity` off para flujos de integracion, con la tesis de que la severidad del `TestStore` es justo lo que convierte un test que pasa en una especificacion completa y verificada.

⏱ 19 min

Casi cualquier framework te deja escribir tests; TCA te obliga a que sean exhaustivos. Su TestStore no es un ayudante opcional sino un juez implacable: por cada acción que le envías exige que declares exactamente cómo quedó el estado —campo a campo— y que recibas, uno a uno, todos los efectos que esa acción disparó. Si el estado cambia en algo que no afirmaste, el test falla. Si un efecto devuelve una acción que no recibiste, el test falla. Si al terminar queda una tarea en vuelo, el test falla. Esta severidad, que al principio irrita, es el mecanismo: un test que pasa en el TestStore es una descripción completa y verificada de lo que la feature hace, sin un solo hueco donde pueda esconderse un cambio no intencionado.

🎯 Al terminar esta lección sabrás
  • Escribir aserciones de estado exhaustivas con la closure de send, que exige describir cada mutación campo a campo.
  • Afirmar los efectos que vuelven con receive, y entender por qué omitir uno hace fallar el test.
  • Controlar el tiempo con TestClock e ImmediateClock para que los efectos temporales sean deterministas.
  • Reconocer cuándo bajar la exigencia con exhaustivity = .off y qué se gana y qué se pierde al hacerlo.

send: declarar el estado, no observarlo

La primera sorpresa del TestStore es que no le preguntas cómo quedó el estado: se lo dices, y él comprueba si acertaste. Al enviar una acción con send pasas una closure que recibe una copia del estado anterior; tu labor es mutarla hasta dejarla como esperas que quede después. Por dentro, el TestStore corre el reducer real sobre su propia copia y compara ambos resultados campo a campo.

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

La closure no observa el estado, lo predice. Si escribieras $0.cuenta = 2, la comparación fallaría con un diff que enfrenta lo esperado contra lo real. Y aquí aparece la exhaustividad: si el reducer cambiara además otro campo —digamos $0.ultimoToque— y tú no lo declararas en la closure, el test también falla. No basta con acertar lo que miras; hay que dar cuenta de todo lo que se movió. Omitir la closure cuando el estado sí cambió es el error que el TestStore te obliga a no cometer.

La simetría inversa también se vigila: si adjuntas una closure a una acción que no alteró el estado, el TestStore protesta igual, porque una aserción redundante miente sobre lo que ocurre tanto como una omitida. La regla es exacta en ambas direcciones —hay closure si y solo si hubo mutación—, y esa exactitud de doble filo es lo que impide que el test acumule ruido inofensivo en apariencia o esconda un cambio real entre líneas que nadie vuelve a mirar.

receive: no se puede tragar un efecto en silencio

Las acciones que un efecto devuelve al store no desaparecen: el TestStore las encola y exige que las reclames con receive. Reclamar un efecto es afirmar dos cosas a la vez: que llegó esa acción —identificada por su key path de caso— y qué estado dejó al procesarse.

@Test
func carga() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature()
  } withDependencies: {
    $0.clienteAPI.cargar = { [.demo] }
  }
  await store.send(.aparecer) {
    $0.cargando = true
  }
  await store.receive(\.datosRecibidos) {
    $0.cargando = false
    $0.items = [.demo]
  }
}

Tras el send, el efecto corre y emite .datosRecibidos; el TestStore la retiene y espera a que la afirmes. Si terminaras el test sin recibirla, fallaría con un mensaje inequívoco: un efecto emitió una acción que nunca se afirmó. Esa es la exhaustividad de los efectos —hermana de la del estado—: nada de lo que vuelve puede pasar inadvertido. El test deja de ser una foto de un instante y se vuelve la transcripción íntegra de la conversación entre acciones y efectos.

El orden importa tanto como la presencia. Si una acción dispara dos efectos que devuelven .primero y .segundo, debes recibirlos en el orden en que llegan; reclamar .segundo antes que .primero falla igual que no reclamar ninguno. El TestStore no solo exige el qué —qué acciones volvieron— sino el cuándo relativo, porque la secuencia de los efectos es parte del comportamiento y, por tanto, parte de lo que una especificación honesta tiene que fijar.

Controlar el reloj y las dependencias

Un efecto que espera parece imposible de testear sin esperar de verdad. La salida es inyectar el tiempo como una dependencia más y congelarlo. TestClock no avanza solo: solo se mueve cuando tú se lo ordenas, lo que convierte un retardo real en un paso instantáneo y determinista.

@Test
func temporizador() async {
  let clock = TestClock()
  let store = TestStore(initialState: Temporizador.State()) {
    Temporizador()
  } withDependencies: {
    $0.continuousClock = clock
  }
  await store.send(.iniciar)
  await clock.advance(by: .seconds(1))
  await store.receive(\.tick) {
    $0.segundos = 1
  }
  await store.send(.parar)
}

clock.advance(by:) empuja el reloj un segundo y libera exactamente el tick que ese avance produce; lo recibes y afirmas el estado, sin un solo milisegundo de espera real. ImmediateClock va más lejos y colapsa toda espera a cero, útil cuando el cuándo no te importa y solo quieres que el efecto termine. Repara en el último send(.parar): si arrancaras el temporizador y no lo cancelaras, el TestStore fallaría al terminar porque detecta una tarea en vuelo. La exhaustividad también cubre el ciclo de vida: todo efecto que empieza tiene que acabar o cancelarse antes del final del test.

El diff como diálogo: deja que el test te diga qué hace

Hay una consecuencia liberadora de la aserción exhaustiva que casi nadie anticipa: no necesitas saber de antemano el estado exacto resultante. Puedes enviar la acción con una closure vacía o aproximada, ejecutar, y dejar que el TestStore te imprima un diff preciso entre lo que declaraste y lo que de verdad ocurrió.

await store.send(.incrementar) {
  // a propósito vacía: deja que el test te corrija
}
// El TestStore falla y muestra:
//   − $0.cuenta = 0
//   + $0.cuenta = 1

El fallo no es un castigo, es la respuesta a una pregunta. Lees el diff, copias la realidad —$0.cuenta = 1— dentro de la closure y el test pasa. Escribir el test se vuelve un diálogo: el reducer te dice qué hace y tú te limitas a ratificarlo. Este flujo invierte la tarea ingrata de predecir estados a ciegas y la convierte en leer y confirmar, y de paso deja la closure como documentación exacta de la transición: quien lea el test verá el cambio de estado real, no un vago comentario de que debería funcionar.

flowchart TD
T[TestStore] -->|send accion| RD[Reducer real corre y muta]
T -->|closure de asercion| EX[Estado esperado que declaras]
RD --> CMP[Comparacion campo a campo]
EX --> CMP
CMP --> V[Verde si todo coincide, rojo si algo difiere]
RD --> EF[Efecto emite accion de vuelta]
EF -->|receive| CMP
style T fill:#89b4fa,color:#11111b
style CMP fill:#f9e2af,color:#11111b
💡
Cuando la exhaustividad estorba: exhaustivity off

Para tests de integración de un flujo largo, afirmar cada campo de cada paso es ruido. Con store.exhaustivity = .off el TestStore pasa a modo no exhaustivo: afirmas solo lo que te importa y los cambios que ignoras dejan de hacer fallar el test. Y store.exhaustivity = .off(showSkippedAssertions: true) te imprime todo lo que te saltaste, para que puedas volver a exigirlo si descubres que sí importaba. El modo exhaustivo es el valioso y por eso es el de por defecto; el no exhaustivo es una relajación deliberada y local para flujos grandes, no la norma a la que rebajarse por comodidad.

Un test exhaustivo es una especificacion ejecutable, no una comprobacion parcial

La mayoría de las suites de test comparten un vicio silencioso: afirman lo que el autor se acordó de mirar y callan sobre todo lo demás. Ese silencio es donde viven las regresiones —un campo que cambia sin querer, un efecto que se dispara de más—, porque un test que no rompe cuando el comportamiento cambia no protege nada; solo da una falsa sensación de seguridad, que es peor que no tener test. El TestStore invierte el defecto: en vez de comprobar lo que recuerdas, obliga a dar cuenta de todo salvo lo que renuncies explícitamente a mirar. Por eso un test suyo que pasa no es una comprobación, es una especificación: describe, sin lagunas, el estado exacto tras cada acción y la secuencia completa de efectos, y se rompe en el instante en que la realidad se aparta de esa descripción —que es justo lo que quieres de un test—. Y fíjate en la condición que lo hace posible: solo puedes afirmar el estado exacto siguiente si ese estado es una función determinista del estado, la acción y las dependencias inyectadas; solo puedes esperar y afirmar un efecto si el efecto es un valor y no un disparo a ciegas; solo puedes controlar el tiempo si el reloj entró por una dependencia. La severidad del TestStore no es una manía del framework: es el cobro de un cheque que firmaron, niveles atrás, el reducer puro, el efecto como valor y la dependencia controlada. La ceremonia que pagaste para que la lógica no tocara el mundo se te devuelve aquí, convertida en la capacidad de escribir la especificación de tu feature y que la máquina la verifique letra por letra.

⚔️ Escribe un test exhaustivo y hazlo fallar a propósito
  1. Toma una feature con un efecto asíncrono —una carga de red, por ejemplo—. Escribe un TestStore que envíe la acción de disparo y afirme en la closure de send que el indicador de carga se pone en verdadero.
  2. Sobrescribe la dependencia con withDependencies para que devuelva un valor fijo; recibe la acción de respuesta con receive y afirma el estado resultante campo a campo.
  3. Cambia un solo campo en el reducer sin actualizar el test. Ejecútalo y lee el diff de fallo: confirma que el TestStore detectó la deriva que a ti se te habría pasado.
  4. Añade un efecto temporal con debounce o un temporizador; inyecta un TestClock, avánzalo a mano y afirma cada tick. Luego borra la acción que lo cancela y observa el fallo por tarea en vuelo.
  5. Pasa un test de flujo alto a exhaustivity = .off(showSkippedAssertions: true), lee lo que reporta como omitido y decide cuáles de esas omisiones merecían, en realidad, una aserción exhaustiva.