Testear thunks: sustituir la red, no el store
La lógica asíncrona es donde la mayoría de las suites de Redux se rompen, y casi siempre por el mismo error: sustituir el store por un doble en lugar de sustituir la red. Esta lección defiende la doctrina moderna —store real más servidor simulado con msw— frente al viejo redux-mock-store, explica cómo interceptar peticiones sin tocar el código de producción, cómo afirmar sobre el ciclo pending, fulfilled y rejected de createAsyncThunk, cuándo usar unwrap y los matchers de acción, cómo probar caminos de error, cancelación y la opción condition, y cómo tratar los temporizadores con relojes falsos. Cierra distinguiendo el thunk como unidad de dominio del thunk como orquestador de efectos.
Un thunk no es puro y ahí termina la ganga que disfrutamos con reducers y selectores. Lee el estado, habla con la red, decide, despacha y a veces se cancela a mitad. La pregunta de diseño de esta lección es dónde poner la costura: qué parte del sistema sustituimos por un doble para poder observarlo. Durante años la respuesta del ecosistema fue sustituir el store, con redux-mock-store recogiendo la lista de acciones despachadas; hoy esa respuesta se considera un error de dirección, porque un store falso no ejecuta reducers y convierte el test en una afirmación sobre mensajes internos en lugar de sobre resultados. La doctrina moderna, la que el propio equipo de Redux recomienda, invierte la costura: el store es real, los reducers corren de verdad, y lo que se sustituye es la única frontera que de verdad no controlas, la red, con msw interceptando en la capa del protocolo. El test deja de preguntar qué acciones se despacharon y pasa a preguntar en qué estado quedó el sistema.
- Elegir la costura correcta: sustituir la red con
mswy conservar el store y los reducers reales. - Afirmar sobre el ciclo
pending,fulfilledyrejecteddecreateAsyncThunky sobre el estado resultante. - Probar caminos de error, cancelación y la opción
conditionque evita peticiones redundantes. - Distinguir cuándo un test de acciones despachadas es legítimo y cuándo es acoplamiento a la implementación.
La costura correcta: red simulada, store real
El argumento contra el store falso es estructural. Un thunk tiene valor porque coordina: pide datos, interpreta la respuesta y traduce el resultado a acciones que los reducers convierten en estado. Si sustituyes el store, amputas la segunda mitad de esa cadena y te quedas afirmando sobre una lista de objetos de acción, que es exactamente la representación interna que mañana quieres poder cambiar. Además el test se vuelve ciego a la clase de defecto más común en esta capa: que la acción se despachó bien pero el reducer no la manejó, o la manejó en el slice equivocado.
Sustituir la red, en cambio, corta por donde de verdad hay una frontera de proceso. msw intercepta a nivel de fetch y XMLHttpRequest, de modo que tu código de producción no se entera de nada: no hay inyección de un cliente falso, no hay parámetro extra que solo existe para los tests, no hay bifurcación por entorno. El código que corre en el test es literalmente el que corre en producción.
sequenceDiagram participant T as test participant S as store real participant H as thunk participant M as msw T->>S: dispatch del thunk S->>H: ejecuta la orquestacion H->>M: peticion http interceptada M-->>H: respuesta simulada H->>S: accion fulfilled o rejected S-->>T: estado final observable
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { beforeAll, afterEach, afterAll } from 'vitest'
export const server = setupServer(
http.get('/api/usuarios', () =>
HttpResponse.json([{ id: 'u1', nombre: 'Ada' }]),
),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Configurar onUnhandledRequest con el valor error convierte cualquier petición no declarada en un fallo inmediato del test. Sin esa opción, una llamada que se escapa al servidor real o que cuelga indefinidamente produce tests lentos, intermitentes y, lo que es peor, dependientes de una red que en integración continua no existe. Con ella, la suite te obliga a declarar explícitamente toda la superficie HTTP que tu código toca, y esa declaración se convierte con el tiempo en la documentación más fiable del contrato con el backend.
Afirmar sobre el resultado, no sobre el recorrido
Con el store real, el test recupera la simplicidad de una función: despachas, esperas y miras el estado. createAsyncThunk devuelve una promesa que resuelve con la acción final, y unwrap la convierte en el valor de retorno o en una excepción, que es la forma más natural de escribir el caso de error.
import { describe, it, expect } from 'vitest'
import { setupStore } from '../store'
import { cargarUsuarios } from './usuariosSlice'
import { server } from '../test/server'
import { http, HttpResponse } from 'msw'
it('carga usuarios y los deja en el estado', async () => {
const store = setupStore()
await store.dispatch(cargarUsuarios())
const estado = store.getState().usuarios
expect(estado.estado).toBe('exito')
expect(estado.ids).toEqual(['u1'])
})
it('registra el error cuando el servidor falla', async () => {
server.use(
http.get('/api/usuarios', () => new HttpResponse(null, { status: 500 })),
)
const store = setupStore()
const resultado = await store.dispatch(cargarUsuarios())
expect(cargarUsuarios.rejected.match(resultado)).toBe(true)
expect(store.getState().usuarios.estado).toBe('error')
})
Dos detalles merecen atención. El primero es server.use, que sobreescribe un manejador solo para ese test y se revierte con resetHandlers; es el mecanismo con el que se prueban el fallo del servidor, la respuesta vacía, el cuerpo malformado o la latencia alta sin duplicar la configuración global. El segundo son los matchers cargarUsuarios.rejected.match y sus hermanos pending y fulfilled: son predicados con estrechamiento de tipos, así que además de afirmar te dan acceso tipado a la carga útil sin aserciones manuales.
Camino feliz
Respuesta correcta, estado final poblado y bandera de carga apagada. Es el test que documenta el contrato con el backend.
Camino de error
Estado 500, red caída o cuerpo inválido. Verifica que el error queda representado en el estado y no solo en la consola.
Estado intermedio
Que pending deja la bandera de carga encendida. Se comprueba sin esperar la promesa, mirando el estado justo tras despachar.
Cancelación
abort y la opción condition. Verifica que una petición redundante o abandonada no corrompe el estado final.
El estado intermedio se prueba con una sutileza que conviene conocer: despachas sin esperar la promesa, afirmas que la bandera de carga está encendida, y solo entonces esperas. Es el único momento del test donde la ausencia de await es deliberada y no un descuido, así que merece un comentario en el código para que nadie lo arregle en una revisión.
Cancelación, condition y relojes falsos
La lógica asíncrona no trivial incluye decisiones que nada tienen que ver con la respuesta del servidor: no pedir dos veces lo mismo, abandonar una petición cuando el usuario navega, esperar a que el usuario deje de teclear. Esas reglas son lógica de dominio y merecen tests tanto como una transición de reducer.
it('no vuelve a pedir si ya hay datos cargados', async () => {
const store = setupStore()
await store.dispatch(cargarUsuarios())
const resultado = await store.dispatch(cargarUsuarios())
expect(resultado.meta.condition).toBe(true) // fue abortado por condition
expect(store.getState().usuarios.peticiones).toBe(1)
})
it('descarta el resultado de una peticion cancelada', async () => {
const store = setupStore()
const promesa = store.dispatch(cargarUsuarios())
promesa.abort()
await promesa
expect(store.getState().usuarios.estado).not.toBe('exito')
})
Con temporizadores, la regla es sustituir el reloj y nunca la espera real. Un debounce probado con esperas de verdad convierte una suite de segundos en una de minutos y añade intermitencia; con relojes falsos avanzas el tiempo de forma determinista y controlas exactamente el instante en que el efecto debe dispararse. La combinación de relojes falsos con promesas exige cuidado —hay que ceder el control al bucle de eventos entre el avance del reloj y la afirmación—, y por eso conviene apoyarse en las utilidades de espera de la librería de testing en lugar de improvisar.
El patrón heredado consistía en montar un store falso, despachar el thunk y comparar la lista de acciones recogidas con una lista esperada. Falla en tres frentes. Es frágil: añadir una acción intermedia inofensiva rompe el test aunque el comportamiento sea idéntico. Es incompleto: no verifica que los reducers hagan nada con esas acciones. Y es engañoso: da sensación de cobertura sobre la parte del sistema que menos riesgo tiene. Hay un caso legítimo residual —un thunk cuya única responsabilidad es orquestar y que no toca estado propio, donde la salida observable son literalmente las acciones que emite— y ahí un espía sobre dispatch o un middleware que las recoja es razonable. Fuera de ese caso, afirma sobre el estado.
Todo test asincrónico es, en el fondo, una decisión sobre dónde cortar el sistema, y esa decisión determina qué refactorizaciones podrás hacer sin pagar peaje. Si cortas en el store, declaras que la lista de acciones que tu thunk emite es un contrato estable, y a partir de ahí cada reorganización interna —fusionar dos acciones, renombrar una, mover la escritura a otro slice— te costará arreglar tests que no describían ningún defecto. Si cortas en la red, declaras que el contrato estable es el HTTP con el backend y el estado observable de tu aplicación, que son exactamente las dos cosas que no puedes cambiar unilateralmente porque una la comparte otro equipo y la otra la ve el usuario. Todo lo que queda entre ambas fronteras —cuántas acciones despachas, cómo se llaman, qué slice las maneja, si usas un thunk, un listener o una saga— pasa a ser interior refactorizable, y esa es precisamente la libertad por la que se escriben tests en primer lugar. La lección general trasciende a Redux y a msw: la calidad de una suite no se mide por cuánto código ejecuta sino por dónde coloca sus costuras, porque cada doble de prueba es una hipótesis congelada sobre qué parte de tu sistema no va a cambiar. Colocar el doble en la frontera de proceso —ahí donde de verdad hay otra máquina, otro equipo, otro ciclo de despliegue— te da tests que sobreviven a los rediseños. Colocarlo dentro de tu propio módulo te da tests que documentan tu implementación de hoy y que mañana votarán en contra de mejorarla.
- Localiza los tests de thunks que usen un store falso o que afirmen sobre listas de acciones y anota, para cada uno, qué comportamiento de usuario pretendía verificar.
- Monta
mswconsetupServer,onUnhandledRequesten modo error y el ciclo de vida completo delisten,resetHandlersyclose. - Reescribe el caso feliz con store real y afirmación sobre el estado final. Comprueba que sigue pasando si renombras una acción intermedia.
- Añade el camino de error con
server.usey un estado 500, y verifica que el error queda representado en el estado y no solo registrado. - Prueba el estado intermedio despachando sin esperar la promesa, y documenta con un comentario por qué falta el
await. - Escribe un test de cancelación con
aborty otro que verifique queconditionevita la segunda petición. Confirma que ambos fallan si eliminas esa lógica.