receive: reclamar lo que vuelve del efecto
Un efecto que emite una acción no la aplica a escondidas: el `TestStore` la retiene en una cola y exige que la reclames con `receive`, afirmando a la vez qué acción llegó y qué estado dejó al procesarse. Esta lección explica las formas de `receive` —por caso, por valor, por predicado—, el papel de los key paths de caso, por qué el orden de llegada forma parte de la especificación, qué significa el timeout y por qué es casi siempre un síntoma de tiempo no controlado, y cierra con la razón de fondo por la que terminar una prueba con un efecto vivo o una acción sin reclamar es un fallo y no una tolerancia.
Con send cierras la mitad fácil del problema: qué pasa cuando el usuario actúa. La otra mitad es la que hunde a la mayoría de las suites de test, porque en ella el que habla no es el usuario sino el mundo —una respuesta de red que llega tarde, un tic de reloj, una notificación del sistema— y esa voz no está bajo el control de quien escribe la prueba. TCA resuelve el problema en dos tiempos: primero convierte el efecto en un valor devuelto, de modo que hablar del mundo es hablar de datos; después, en el TestStore, intercepta todas las acciones que esos efectos emiten y en vez de aplicarlas en silencio las encola y espera. receive es el gesto con el que reclamas una de esas acciones y, al reclamarla, afirmas dos cosas a la vez: que llegó exactamente esa y qué estado dejó al procesarse. Lo que esta lección defiende es que la parte más valiosa de ese mecanismo no es lo que te deja afirmar, sino lo que te prohíbe callar.
- Reclamar acciones emitidas por efectos con
receivey afirmar el estado resultante en su closure. - Distinguir las formas de
receive—por caso, por caso y valor, por predicado— y saber cuándo conviene cada una. - Entender por qué el orden de recepción forma parte de la especificación y no es un detalle de implementación.
- Reconocer los dos fallos de cierre —acción sin reclamar y efecto en vuelo— y qué defecto de diseño delata cada uno.
Un efecto que vuelve es una acción que hay que reclamar
Cuando el reducer devuelve un efecto y ese efecto emite una acción, el TestStore no la procesa: la guarda. Tu prueba tiene que ir a buscarla.
@Test
func carga() async {
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.clienteAPI.cargar = { [.demo] }
}
await store.send(.aparecer) {
$0.cargando = true // lo que hace la accion del usuario
}
await store.receive(\.datosRecibidos) {
$0.cargando = false // lo que hace la respuesta del mundo
$0.items = [.demo]
}
}
La closure de receive funciona igual que la de send —parte del estado anterior, se muta hasta el esperado, rige la regla del si y sólo si— pero el enunciado que produce es más rico, porque además del estado afirma la identidad de la acción entrante. El key path de caso \.datosRecibidos no compara payloads: comprueba que la acción que estaba en la cabeza de la cola es de ese caso, lo cual basta cuando el contenido ya queda fijado por las mutaciones de estado que declaras justo debajo. Si quieres además clavar el valor, la variante con dos argumentos lo exige; y si la acción llega envuelta en capas de features anidadas, el key path las atraviesa igual: \.detalle.presented.guardado es una dirección perfectamente legítima.
// Afirmar tambien el payload
await store.receive(\.datosRecibidos, [.demo]) { $0.items = [.demo] }
// Cuando el valor no es Equatable o solo importa una propiedad
await store.receive { accion in
if case let .datosRecibidos(items) = accion { return items.count == 1 }
return false
}
El orden importa tanto como la presencia. Si una acción dispara dos efectos que devuelven .perfilCargado y .avisosCargados, hay que reclamarlos en el orden en que la cola los recibió; pedir el segundo antes que el primero falla igual que no pedir ninguno. Esto irrita hasta que se entiende lo que protege: la secuencia de efectos es comportamiento observable —determina qué ve el usuario primero, qué animación se solapa con cuál, qué escritura gana si dos tocan el mismo campo— y una especificación que no fija el orden deja libre precisamente la dimensión donde viven las condiciones de carrera.
Hay un tercer caso que conviene anticipar porque desconcierta la primera vez: la acción que vuelve y el reducer no usa para nada. Un receive sin closure es perfectamente válido y afirma algo preciso —llegó esta acción y no movió el estado—, de modo que también las respuestas que sólo se registran o se descartan quedan escritas en la prueba. Nada de lo que el mundo devuelve puede atravesar el TestStore sin dejar rastro, ni siquiera lo que no tiene consecuencias, y esa es justamente la razón por la que un efecto disparado dos veces se hace visible aunque su resultado sea idéntico.
Las dos deudas de cierre
Acción sin reclamar
Un efecto emitió algo que la prueba nunca afirmó. El fallo llega al destruirse el store y nombra la acción huérfana.
Efecto en vuelo
Una tarea de larga vida sigue corriendo al terminar. La feature deja trabajo colgando y el test lo denuncia.
Timeout agotado
La acción esperada no llegó dentro del margen. Casi siempre significa tiempo real sin controlar, no lentitud.
Acción inesperada
Llegó una acción distinta de la reclamada. El diff enfrenta la que pediste con la que estaba en la cola.
| Situación al terminar la prueba | Veredicto | Qué suele delatar |
|---|---|---|
| Cola vacía y sin tareas vivas | verde | el flujo se cerró sobre sí mismo |
| Queda una acción sin reclamar | rojo | un efecto que dispara de más o una rama olvidada |
| Queda un efecto corriendo | rojo | falta cancelación o falta el paso final del flujo |
| Se recibió en orden distinto | rojo | dependencia oculta entre efectos concurrentes |
La fila del orden distinto merece una lectura menos evidente que las otras. Cuando una prueba falla porque dos respuestas llegaron al revés de como las reclamaste, la conclusión perezosa es cambiar el orden de los receive hasta que pase. La conclusión útil es preguntarse si tu feature depende de ese orden, porque si depende y el orden lo decide la latencia de dos peticiones concurrentes, entonces lo que la prueba acaba de fijar no es un comportamiento sino una casualidad. Ahí la corrección no está en el test sino en el reducer: o las respuestas se hacen conmutativas —que cada una escriba en su campo y ninguna dependa de la otra— o el flujo se serializa de forma explícita.
El caso del efecto en vuelo es el más instructivo porque parece injusto. Si arrancas un temporizador y terminas la prueba, el TestStore falla aunque todo lo que afirmaste fuese cierto; la solución no es silenciarlo sino enviar la acción que lo detiene, o quedarte con la tarea que devuelve send y cancelarla explícitamente con await tarea.cancel(), o cerrar el flujo con await store.finish(). La lección de diseño es que un efecto de larga vida sin un camino claro de terminación es un recurso que la feature no sabe soltar, y eso en producción se llama fuga: la prueba no está siendo quisquillosa, está haciendo visible un final que nunca escribiste.
Cuando la espera es el problema: controlar el reloj
El fallo por timeout merece un tratamiento aparte porque su lectura ingenua lleva al error opuesto al deseado. La tentación es subir el margen; la respuesta correcta es eliminar la espera.
@Test
func temporizador() async {
let reloj = TestClock()
let store = TestStore(initialState: Temporizador.State()) {
Temporizador()
} withDependencies: {
$0.continuousClock = reloj
}
await store.send(.iniciar)
await reloj.advance(by: .seconds(1))
await store.receive(\.tick) { $0.segundos = 1 }
await store.send(.parar) // sin esto: fallo por efecto en vuelo
}
Repara en que el propio send devuelve una tarea que representa los efectos que esa acción arrancó, y guardarla te da un tercer gesto de cierre además de la acción de parada y de store.finish():
let tarea = await store.send(.iniciar)
await reloj.advance(by: .seconds(1))
await store.receive(\.tick) { $0.segundos = 1 }
await tarea.cancel() // cierra el efecto sin pasar por el reducer
TestClock no avanza solo: sólo se mueve cuando se lo ordenas, y advance(by:) libera exactamente los efectos que ese salto produce, ni uno más. Con eso el retardo de un segundo cuesta cero milisegundos reales y, sobre todo, deja de ser una fuente de aleatoriedad. ImmediateClock va más lejos y colapsa toda espera a cero, útil cuando el cuándo no forma parte de lo que quieres afirmar. Subir el timeout de receive, en cambio, no arregla nada: sustituye una prueba inestable por una prueba lenta e inestable, porque el margen sigue dependiendo de la carga de la máquina. La regla práctica es dura y sencilla: si necesitas un timeout mayor que el que viene por defecto, lo que te falta no es paciencia sino una dependencia de reloj.
flowchart TD
A[send de la accion del usuario] --> R[Reducer devuelve un efecto]
R --> E[El efecto emite una accion]
E --> Q[Cola de acciones pendientes del TestStore]
Q --> RC[receive la reclama y afirma el estado]
Q --> FIN{Cola vacia al terminar}
FIN -->|si| V[Verde]
FIN -->|no| X[Rojo: accion huerfana o efecto vivo]
style V fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111bEn flujos largos existen escapes deliberados: store.skipReceivedActions() vacía la cola sin afirmarla y store.skipInFlightEffects() cierra las tareas vivas. Son útiles al final de una prueba de integración cuya tesis ya quedó demostrada, y son veneno como hábito, porque cada salto silencia justo la clase de sorpresa que el TestStore existe para detectar. Úsalos con la misma prudencia con la que se silencia un aviso del compilador: de forma local, consciente y con un motivo que puedas escribir en una línea.
Toda suite de pruebas es, mirada de cerca, una teoría sobre el sistema, y como toda teoría se juzga menos por lo que afirma que por lo que excluye. Un test que sólo puede afirmar tiene un poder pobre: dice que ocurrió A, y el sistema queda libre de hacer también B, C y D sin contradecirlo. Lo que receive introduce es la posibilidad de excluir, y lo hace por un camino elegante: en vez de pedirte que enumeres lo que no debe pasar —una lista infinita, imposible de escribir—, convierte el silencio en fallo. Cada acción que un efecto emite queda registrada en una cola que tiene que quedar vacía, y así la afirmación «no ocurre nada más» deja de ser un enunciado que alguien tendría que redactar y pasa a ser una condición estructural que la máquina comprueba sola. Ese giro es el que da a la prueba su fuerza: no describe una trayectoria posible del sistema, describe la trayectoria, y cualquier desviación —una acción de más, una en distinto orden, una tarea que no muere— rompe el enunciado. Y hay algo más, que es lo que convierte a este mecanismo en una herramienta de diseño y no sólo de verificación. La cola vacía al final de la prueba es la formalización de una idea que rara vez se escribe en ninguna parte: que una interacción bien modelada tiene un final. Cuando el TestStore te obliga a cancelar el temporizador, a esperar la última respuesta o a cerrar la suscripción, no está pidiéndote burocracia de test; está preguntándote dónde termina el flujo que escribiste, y con incómoda frecuencia la respuesta honesta es que no lo habías pensado. Esa pregunta, hecha en cada prueba y sin excepciones, es probablemente la contribución más silenciosa de TCA a la calidad de una app.
- Escribe una prueba de una feature que cargue datos:
sendde la acción de aparición,receivede la respuesta con el estado final afirmado campo a campo. Déjala en verde. - Borra el
receivey ejecuta. Anota el mensaje exacto, en qué línea aparece y por qué llega al final y no donde ocurrió el efecto. - Haz que la acción dispare dos efectos concurrentes. Recíbelos en orden invertido a propósito y describe qué te dice el fallo sobre el determinismo de tu feature.
- Añade un temporizador de larga vida y termina el test sin pararlo. Después arréglalo de tres maneras distintas —acción de parada,
cancel()sobre la tarea ystore.finish()— y razona cuál documenta mejor el flujo real. - Sustituye un
Task.sleepreal porTestClocken tu prueba más lenta y mide el antes y el después. Comprueba además que ya no falla de forma intermitente al ejecutar la suite completa.