Testear el flujo completo de una feature
Una prueba de una sola acción demuestra poco: las features fallan en las costuras, allí donde el gesto del usuario, el efecto que responde y el estado final se encadenan. Esta lección escribe el guion entero de una pantalla real —escritura con debounce, reloj controlado, respuesta del cliente inyectado, error y reintento— y muestra cómo la prueba resultante se lee como la transcripción íntegra de una sesión de uso; cierra con el criterio para bajar la exigencia con `exhaustivity` en flujos largos y con la tesis de que el test exhaustivo es la única documentación de una feature que no puede quedarse obsoleta sin avisar.
Las features no suelen romperse dentro de una acción sino entre dos: en el hueco donde el usuario ya pulsó pero la respuesta aún no llegó, en el instante en que una segunda escritura invalida la petición que sigue en vuelo, en el retorno de un error que deja encendido un indicador de carga que nadie apaga. Probar acciones sueltas —una por prueba, aislada y limpia— produce suites que están verdes mientras la app se comporta mal, porque las costuras son justo lo que nunca se ejerció. El TestStore invita a lo contrario: como cada paso deja el estado en una posición conocida y toda acción que vuelve queda encolada esperando ser reclamada, una sola prueba puede recorrer el guion entero de una interacción sin perder rigor en ningún punto. Esta lección escribe uno de esos guiones de principio a fin y defiende que el artefacto resultante no es sólo una verificación: es la mejor descripción de la feature que vas a tener.
- Escribir una prueba que recorra un flujo completo: gesto del usuario, efecto en vuelo, respuesta, estado final y camino de error.
- Encadenar
sendyreceivecon un reloj controlado para probar debounce, cancelaciones y reintentos sin esperas reales. - Leer una prueba exhaustiva como documentación de la feature y reconocer qué información contiene que ningún comentario contiene.
- Decidir con criterio cuándo bajar a
exhaustivityno exhaustiva y qué se pierde exactamente al hacerlo.
El guion entero de una interacción
Tomemos un buscador: el usuario escribe, un debounce espera a que pare, se lanza la petición, llegan resultados. Es el caso mínimo donde ya conviven tiempo, red, cancelación y error, y por tanto el caso donde una prueba parcial engaña más.
@Test
func busquedaConDebounceYError() async {
let reloj = TestClock()
let store = TestStore(initialState: Buscador.State()) {
Buscador()
} withDependencies: {
$0.continuousClock = reloj
$0.clienteBusqueda.buscar = { termino in
guard termino == "swift" else { throw ErrorDeRed.caido }
return [.demo]
}
}
// 1. El usuario escribe: el estado cambia, nada se dispara todavia
await store.send(.terminoCambio("swi")) { $0.termino = "swi" }
await store.send(.terminoCambio("swift")) { $0.termino = "swift" }
// 2. Pasa el debounce: solo entonces arranca la peticion
await reloj.advance(by: .milliseconds(300))
await store.receive(\.busquedaIniciada) { $0.cargando = true }
// 3. El mundo responde y el estado se cierra
await store.receive(\.resultados) {
$0.cargando = false
$0.items = [.demo]
}
}
Léelo como una partitura: cada línea es un instante y entre dos líneas no pasa nada que no esté escrito. Las dos primeras escrituras no disparan la petición y la prueba lo demuestra sin decirlo, porque si el reducer lanzase un efecto ahí la acción quedaría en la cola y el advance posterior fallaría con una acción no reclamada. El avance del reloj es el que fabrica el momento en que el debounce vence, y ese salto también prueba, de forma implícita pero rigurosa, que la primera escritura fue cancelada: si no lo hubiera sido, llegarían dos búsquedas y tendrías que reclamar dos. La exhaustividad convierte cada ausencia en una afirmación, y por eso este test dice muchas más cosas de las que aparenta.
El camino de error se escribe a continuación y es el que casi nadie escribe:
// 4. Nueva busqueda que falla: el error tiene su propio estado final
await store.send(.terminoCambio("zzz")) { $0.termino = "zzz" }
await reloj.advance(by: .milliseconds(300))
await store.receive(\.busquedaIniciada) { $0.cargando = true }
await store.receive(\.fallo) {
$0.cargando = false // el indicador se apaga tambien al fallar
$0.mensaje = "No se pudo buscar"
$0.items = []
}
}
Nota además que la segunda búsqueda parte del estado que dejó la primera, con items todavía lleno de los resultados anteriores, y que por eso la aserción de la rama de error tiene que vaciarlo explícitamente. Ese encadenamiento —cada paso hereda el mundo del anterior— es lo que distingue una prueba de flujo de cinco pruebas independientes puestas en fila: sólo aquí se ejerce el estado sucio, que es el estado en el que de verdad vive una app después de tres minutos de uso. Las pruebas que arrancan siempre desde el estado inicial comprueban el comportamiento de una app recién abierta, una situación que casi ningún usuario experimenta más de una vez al día.
Fíjate en la línea que apaga el indicador de carga en la rama de error. Es la clase de detalle que se olvida al escribir el reducer, que no se ve en el simulador porque hay que provocar un fallo de red a mano, y que en producción se manifiesta como una pantalla congelada con una ruedecita eterna. Aquí no puede olvidarse: si el reducer no lo hace, la aserción falla; si el reducer lo hace y tú no lo declaras, la aserción falla igual.
El test como documentación que no puede mentir
Es un guion, no una lista
Se lee en orden temporal, con el gesto del usuario y la respuesta del mundo alternándose como en una sesión real.
Fija el orden y las ausencias
Lo que no aparece entre dos líneas es una afirmación de que no ocurre nada más. El silencio también está verificado.
Documenta las ramas feas
El error, el vacío y la cancelación quedan escritos con el mismo detalle que el camino feliz, que es donde nadie mira.
Se rompe al quedar obsoleto
Un comentario desactualizado sigue ahí, tranquilo. Una prueba desactualizada se pone roja el mismo día.
Conviene además leer el guion como lo leería alguien que acaba de llegar al equipo. Los nombres de las acciones cuentan la historia desde dos puntos de vista distintos —terminoCambio es lo que hizo la persona, resultados es lo que hizo el mundo— y su alternancia en la prueba dibuja el reparto de responsabilidades de la feature con una claridad que el reducer, organizado por casos de un switch, nunca tiene. Un switch está ordenado por tipo de evento; la prueba está ordenada por tiempo, que es como ocurre la experiencia real. Por eso una feature se entiende antes leyendo su prueba de flujo que leyendo su implementación, aunque la implementación sea impecable.
La última tarjeta contiene el argumento central. Toda documentación de una feature —el comentario en la cabecera, la página del wiki, el diagrama de la pizarra— comparte un defecto estructural: nada la ata al código, así que envejece en silencio y llega un punto en que describe una app que ya no existe, con el agravante de que sigue siendo creíble. Una prueba exhaustiva es la única forma de documentación con un mecanismo de caducidad incorporado, porque en el instante en que el comportamiento se aparta de lo escrito el enunciado se vuelve rojo y alguien tiene que decidir cuál de los dos tiene razón. Esa decisión forzada —cambiar el código o cambiar la descripción— es lo que mantiene alineadas la intención y la implementación durante los años en que la feature cambia de manos.
Bajar la exigencia sin perder la tesis
Hay flujos donde afirmar cada campo de cada paso es ruido: pruebas de integración que atraviesan cinco pantallas para comprobar que el usuario acaba autenticado, donde el estado intermedio de la tercera no interesa a nadie. Para eso existe el modo no exhaustivo.
store.exhaustivity = .off(showSkippedAssertions: true)
await store.send(.iniciarSesionPulsado)
await store.receive(\.sesionAbierta) {
$0.usuario = .demo // solo se afirma lo que sostiene la tesis
}
| Pregunta | Prueba de una feature | Prueba de un flujo entre features |
|---|---|---|
| Cuál es su objeto | el comportamiento completo del reducer | una propiedad global del recorrido |
| Modo adecuado | exhaustivo, el de por defecto | no exhaustivo, declarado a propósito |
| Qué se afirma | cada campo y cada acción que vuelve | sólo los hitos que sostienen la tesis |
| Qué se pierde al relajarlo | todo: deja de ser una especificación | el detalle intermedio, ya cubierto abajo |
Con exhaustivity = .off los cambios que no declaras dejan de romper el test y las acciones que no reclamas dejan de ser deudas; con showSkippedAssertions: true además te imprime todo lo que te saltaste, para que puedas leerlo y decidir si alguna de esas omisiones merecía en realidad una aserción. El criterio para elegir modo es de nivel, no de comodidad: la prueba de una feature aislada se escribe exhaustiva porque su objeto es el comportamiento completo; la prueba de un flujo entre features se escribe no exhaustiva porque su objeto es una propiedad global —se llega, se vuelve, se guarda— y el detalle intermedio ya está cubierto por las pruebas de abajo. Relajar la exigencia porque cuesta escribirla no es una decisión de arquitectura, es una renuncia con otro nombre.
sequenceDiagram participant U as Usuario participant S as Store participant E as Efecto U->>S: send termino cambio S->>S: muta el termino y programa el debounce Note over S,E: avance del reloj de prueba S->>E: lanza la busqueda y cancela la anterior E-->>S: receive busqueda iniciada E-->>S: receive resultados o fallo S->>U: estado final afirmado campo a campo
La unidad natural de una prueba de TCA no es el método sino el recorrido: nombra el test por lo que el usuario hace y no por la acción del enum que envías. busquedaConDebounceYError dice algo a quien lo lee en un informe de fallos; testTerminoCambio no dice nada. El nombre importa porque estas pruebas se leen tanto como se ejecutan, y un catálogo de nombres bien puestos es el índice de la feature.
Hay una vieja aspiración en ingeniería de software —la especificación que se mantiene viva junto al código— que ha fracasado tantas veces que hoy provoca cinismo. Fracasó con los diagramas, fracasó con los comentarios estructurados, fracasó con los wikis, y siempre por la misma razón: entre el documento y el programa no había ninguna relación que la máquina pudiese comprobar, de modo que la coherencia dependía enteramente de la virtud humana y la virtud humana no escala a través de los años, los equipos y las prisas. Lo que un test exhaustivo consigue es cambiar el fundamento de esa coherencia: deja de estar sostenida por la disciplina de quien edita y pasa a estar sostenida por la ejecución. La descripción y el sistema descrito comparten destino porque la primera se evalúa contra el segundo en cada compilación, y esa evaluación es lo que ningún wiki puede ofrecer. Pero conviene ver por qué el TestStore puede aspirar a esto cuando otras suites no. Una prueba convencional documenta mal aunque esté verde, porque su enunciado es parcial: dice que si pulsas aquí el contador sube, y calla sobre el resto del universo, de manera que leerla no te enseña la feature, sólo te enseña una anécdota de la feature. Una prueba exhaustiva enuncia el estado completo tras cada paso y la secuencia completa de lo que vuelve, así que leerla es leer la feature; el guion no es un resumen del comportamiento, es el comportamiento escrito en prosa ejecutable. De ahí la consecuencia práctica que más cuesta creer hasta que se vive: cuando alguien hereda tu código dentro de dos años, el sitio donde va a entenderlo no será el reducer, donde ve el mecanismo, sino esta prueba, donde ve el sentido. Y a diferencia de todo lo demás que pudiste escribirle, esta seguirá siendo verdad, porque el día que dejó de serlo se puso roja y alguien tuvo que ocuparse.
- Elige la pantalla de tu app donde más bugs han aparecido. Escribe en papel, en lenguaje llano, la secuencia de una sesión típica: qué hace el usuario, qué responde el mundo y en qué estado queda todo.
- Traduce ese guion a una sola prueba de
TestStore, encadenandosendyreceiveen el mismo orden. Inyecta el reloj y el cliente de red en lugar de esperar y llamar de verdad. - Añade a continuación el camino de error dentro de la misma prueba y afirma explícitamente que el indicador de carga se apaga. Si el reducer no lo apagaba, acabas de encontrar tu primer bug del capítulo.
- Introduce una cancelación: dispara dos veces el gesto antes de que venza el debounce y comprueba, por la vía de las acciones que no llegan, que la primera petición fue realmente cancelada.
- Duplica la prueba en modo
exhaustivity = .off(showSkippedAssertions: true), lee el listado de omisiones y señala cuáles de ellas, de haberse silenciado para siempre, habrían dejado pasar un fallo real.