Testing de sagas: afirmar sobre lo declarado sin ejecutar nada
El cierre del nivel cobra la promesa que abrió el primer capítulo: si el efecto es un objeto plano, testearlo es comparar objetos. Esta lección monta el test unitario clásico —iterar el generador a mano y afirmar por igualdad profunda que rinde el call esperado, inyectarle un resultado falso y afirmar el put siguiente— sin red, sin mocks, sin relojes falsos y sin promesas que esperar. Después nombra su defecto real, que es fijar la secuencia exacta y por tanto romperse ante refactorizaciones que no cambian el comportamiento, y presenta el contrapeso: expectSaga con proveedores estáticos y runSaga con un intérprete propio, que afirman sobre el resultado observable en lugar de sobre cada paso. Termina fijando el criterio de qué nivel de test merece cada saga y cerrando el arco del nivel.
Todo el nivel apuntaba aquí. Si una saga no ejecuta efectos sino que los describe, entonces probarla no requiere simular el mundo: basta con comprobar que pide lo correcto en el momento correcto, y como lo que pide son objetos planos, la afirmación es una igualdad de valores. Nada de interceptar fetch, nada de relojes falsos, nada de esperar microtareas, nada de dobles que hay que mantener sincronizados con la implementación real. Es, con diferencia, la mejor propiedad de la librería y la razón por la que mucha gente que ya no elegiría sagas para un proyecto nuevo sigue defendiéndolas cuando el flujo es complejo. Pero la misma virtud esconde una trampa que conviene ver antes de enamorarse: un test que afirma cada paso está fijando la forma del código, no su comportamiento, y ese contrato es más rígido de lo que casi nadie quiere.
- Escribir el test unitario canónico: iterar el generador, comparar el efecto rendido e inyectar resultados falsos.
- Cubrir ramas de error y de cancelación sin provocar fallos reales, lanzando dentro con
gen.throw. - Reconocer la fragilidad del test paso a paso: fija el orden y se rompe con refactorizaciones inocuas.
- Elegir entre el test paso a paso,
expectSagacon proveedores yrunSagacon intérprete propio según lo que quieras garantizar.
El test como guion de efectos previstos
El procedimiento es mecánico. Llamas al generador, pides el primer valor y compruebas por igualdad profunda que es la descripción esperada; luego lo reanudas inyectando el resultado que quieras fingir y compruebas el siguiente. El test acaba siendo la transcripción del guion que la saga debería seguir, escrito con el mismo vocabulario que la saga usa. No hay capa de simulación porque no hay nada que simular: el valor de retorno de una llamada no existe hasta que tú se lo das.
import { call, put, select } from "redux-saga/effects";
import { api } from "./api";
import { guardarPerfil } from "./sagas";
test("guarda el perfil y anuncia el exito", () => {
const gen = guardarPerfil();
const borrador = { nombre: "Ada" };
// La saga solo describe: comparamos objetos planos.
expect(gen.next().value).toEqual(select(seleccionarBorrador));
expect(gen.next(borrador).value).toEqual(put({ type: "perfil/guardando" }));
expect(gen.next().value).toEqual(call(api.guardarPerfil, borrador));
// Inyectamos una respuesta falsa sin tocar la red.
expect(gen.next({ id: "7" }).value).toEqual(put({ type: "perfil/guardado", payload: { id: "7" } }));
expect(gen.next().done).toBe(true);
});
test("la rama de error no necesita provocar un fallo real", () => {
const gen = guardarPerfil();
gen.next(); gen.next({ nombre: "Ada" }); gen.next();
// throw inyecta el error en el punto suspendido: entra en el catch de la saga.
expect(gen.throw(new Error("500")).value).toEqual(put({ type: "perfil/error", payload: "Error: 500" }));
});
Compara ese segundo test con su equivalente para un thunk. Allí, probar la rama de error exige que fetch falle de verdad, lo que obliga a interceptar el módulo de red, decidir si el doble rechaza con Response o con excepción, y esperar a que la promesa se asiente antes de afirmar. Aquí solo dices en este punto llegó un error y la saga hace el resto. La misma facilidad se extiende a los tiempos —un delay es un objeto, no una espera— y a la cancelación, que se prueba llamando a gen.return() y comprobando que el finally rinde los efectos de limpieza esperados.
Cuando una saga se bifurca tarde, repetir en cada test los cinco next previos es ruidoso y frágil. cloneableGenerator, de @redux-saga/testing-utils, envuelve el generador y permite clonarlo justo antes de la bifurcación para continuar cada rama por separado desde el mismo estado. El test queda con un tronco compartido y ramas cortas, que es exactamente la forma del código que prueba. Es una utilidad menor pero revela algo del enfoque: como el estado de la saga es un objeto suspendido, se puede duplicar, y probar un árbol de decisiones no obliga a reejecutar el camino común.
El precio: fijar el orden no es fijar el comportamiento
Ahora la parte incómoda. Ese test afirma que la saga rinde exactamente estos efectos, exactamente en este orden. Si mañana mueves el put de estado guardando una línea más arriba, o cambias dos call independientes por un all que los pide a la vez, el comportamiento observable de la aplicación es idéntico y sin embargo todos los tests fallan. Has escrito un test acoplado a la implementación, que es precisamente lo que la literatura de testing lleva décadas pidiendo evitar, y lo has hecho sin darte cuenta porque la facilidad de escribirlo lo hacía atractivo.
El efecto a largo plazo es conocido: los tests dejan de ser una red de seguridad que anima a refactorizar y se convierten en un lastre que la penaliza, porque cualquier reorganización obliga a reescribir la transcripción entera. Peor aún, un test paso a paso puede pasar con una saga que no funciona: si comparas call(api.guardar, borrador) pero el nombre real del endpoint está mal, el objeto es igual y el test es verde. Estás verificando que la saga dice lo que esperabas que dijera, no que lo dicho sea correcto. La reificación te dio observabilidad total sobre la intención y ninguna sobre su adecuación al mundo.
flowchart TD
A[que quiero garantizar?] --> B{me importa la secuencia exacta?}
B -->|si, es un algoritmo de flujo| C[test paso a paso con next y throw]
B -->|no, me importa el resultado| D{necesito el store real?}
D -->|no| E[expectSaga con proveedores]
D -->|si| F[runSaga con interprete propio]
C --> G[fragil ante refactor, preciso]
E --> H[robusto ante refactor, menos preciso]
F --> H
style C fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111bAfirmar sobre el resultado: expectSaga y runSaga
El contrapeso consiste en ejecutar la saga de verdad, pero con un intérprete que sustituye los efectos peligrosos por valores fijos y registra lo ocurrido. expectSaga, de redux-saga-test-plan, hace justo eso: corre la saga completa, provide empareja descripciones de efecto con respuestas falsas y luego afirmas sobre lo observable —qué acciones se despacharon, en qué estado quedó el reducer— sin decir nada de los pasos intermedios. Reordenar el interior de la saga ya no rompe el test, porque el test no sabe qué hay dentro.
import { expectSaga } from "redux-saga-test-plan";
import { call } from "redux-saga/effects";
import { api } from "./api";
import { guardarPerfil } from "./sagas";
import { reducerPerfil } from "./slice";
test("al guardar, el estado acaba en guardado", () =>
expectSaga(guardarPerfil)
.withReducer(reducerPerfil)
.provide([[call(api.guardarPerfil, { nombre: "Ada" }), { id: "7" }]])
.put({ type: "perfil/guardado", payload: { id: "7" } })
.hasFinalState({ estado: "guardado", id: "7" })
.run());
La opción de más bajo nivel es runSaga, que el propio núcleo expone: le pasas un objeto de entrada y salida con tu dispatch, tu getState y tu manejo de errores, y ejecutas la saga fuera de cualquier store. Sirve para pruebas de integración a medida y para entender qué es realmente el middleware, porque escribir ese objeto es escribir la mitad de un intérprete. El criterio entre los tres niveles es simple: usa el test paso a paso cuando la secuencia es la lógica —protocolos, apretones de manos, máquinas de flujo donde el orden es el contrato—; usa expectSaga para la mayoría de las sagas corrientes, donde lo que importa es qué acabó pasando; y reserva runSaga para cuando necesites el store de verdad.
import { runSaga } from "redux-saga";
import { guardarPerfil } from "./sagas";
test("integracion con un interprete propio", async () => {
const despachadas: unknown[] = [];
const estado = { perfil: { borrador: { nombre: "Ada" } } };
await runSaga(
{
dispatch: (a) => despachadas.push(a),
getState: () => estado,
onError: (e) => { throw e; },
},
guardarPerfil,
).toPromise();
expect(despachadas).toContainEqual({ type: "perfil/guardado", payload: { id: "7" } });
});
Fíjate en que ese objeto de tres funciones es, literalmente, la mitad del middleware: dispatch para atender los put, getState para atender los select y un manejador para los errores que suben desde la raíz del árbol. Escribirlo una vez cierra el círculo del primer capítulo, porque comprueba de forma tangible que el intérprete no era una caja negra sino una pieza intercambiable, y que sustituirla es exactamente lo que hace posible que la misma saga sirva en producción y en un test.
Paso a paso
Igualdad profunda sobre cada efecto rendido. Máxima precisión y máxima fragilidad; ideal si el orden es el contrato.
expectSaga
Corre la saga con proveedores estáticos y afirma sobre acciones y estado final. Sobrevive a las refactorizaciones.
runSaga
Intérprete propio con tu dispatch y tu getState. Integración a medida y la mejor forma de entender el middleware.
El límite
Ningún test de descripciones comprueba que la descripción sea correcta. La integración real sigue haciendo falta.
Que el efecto sea un dato inspeccionable no es exclusivo de sagas y conviene saberlo antes de elegir por esta razón. El listener middleware permite probar la lógica sin red inyectando extras falsos, TCA en Swift construye su reputación sobre dependencias inyectadas y un TestStore que exige agotar todos los efectos, y Elm empuja los efectos al runtime para que la parte tuya sea pura por construcción. La familia de soluciones es la misma tesis con distinta ergonomía: si quieres testear sin simular el mundo, el efecto tiene que dejar de ser un acto en algún punto del camino. Sagas fue de las primeras en llevarlo a JavaScript y sigue siendo la más literal.
El arco del nivel se cierra aquí y conviene verlo entero. En el primer capítulo la saga renunció a actuar y se limitó a describir; de esa renuncia salieron, sin esfuerzo adicional, la linealidad de take, la concurrencia estructurada del árbol de tareas, la cancelación como decisión del intérprete y ahora el test sin dobles. Cuatro capacidades que en el modelo de actos exigían cuatro maquinarias distintas —banderas, contadores, plomería de errores y una biblioteca de simulación— aparecen aquí como corolarios de una única decisión de representación. Esa es la lección transferible, y sobrevive a redux-saga: cuando conviertes comportamiento en datos, ganas de golpe todo lo que se puede hacer con datos, que es inspeccionarlos, compararlos, guardarlos, repetirlos y descartarlos. Pero el mismo movimiento fija el límite exacto de lo que puedes garantizar, y este capítulo es donde se ve con más crudeza. Un test que compara descripciones prueba que tu programa quiso lo correcto; no prueba que lo correcto fuese eso. El endpoint mal escrito, el contrato del backend que cambió, el argumento en el orden equivocado, el resultado que en producción viene con otra forma: todo eso pasa por delante de una batería verde sin despeinarse, porque vive del lado del intérprete y el intérprete no está en tu test. De ahí la disciplina que cierra el nivel: usa la facilidad de estos tests para cubrir densamente la lógica de flujo, que es donde de verdad se esconden los bugs difíciles, y no dejes que esa facilidad te haga creer que sustituyen a un puñado de pruebas que sí toquen el mundo. Reificar separa el qué del cómo con una limpieza admirable, y precisamente por eso ninguna cantidad de certeza sobre el qué te dice nada del cómo.
- Escribe el test paso a paso de una saga con
select,cally dosput, afirmando cada efecto por igualdad profunda. - Cubre la rama de error inyectando el fallo con
gen.throwy comprueba que no necesitas tocar la red. - Prueba la cancelación llamando a
gen.return()y afirma los efectos que rinde elfinally. - Refactoriza la saga sin cambiar su comportamiento —reordena un
put, agrupa doscallen unall— y observa cuántos tests se rompen. - Reescribe ese mismo caso con
expectSagay proveedores, repite la refactorización y comprueba que ahora el test aguanta. - Añade
withReducery afirma sobre el estado final en vez de sobre las acciones; decide cuál de las dos afirmaciones describe mejor tu intención. - Escribe un test con
runSagay un intérprete propio que registre los despachos, y explica con tus palabras qué parte del middleware acabas de reimplementar.