wandres.dev
TESTING DE REDUX · reducers y efectos

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Elegir la costura correcta: sustituir la red con msw y conservar el store y los reducers reales.
  • Afirmar sobre el ciclo pending, fulfilled y rejected de createAsyncThunk y sobre el estado resultante.
  • Probar caminos de error, cancelación y la opción condition que 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())
💡
onUnhandledRequest en modo error no es opcional

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.

⚠️
redux-mock-store y por qué el test de lista de acciones envejece mal

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.

Elegir dónde poner el doble es elegir qué contrato consideras estable

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.

⚔️ Traslada tus tests asíncronos de la costura interna a la frontera
  1. 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.
  2. Monta msw con setupServer, onUnhandledRequest en modo error y el ciclo de vida completo de listen, resetHandlers y close.
  3. Reescribe el caso feliz con store real y afirmación sobre el estado final. Comprueba que sigue pasando si renombras una acción intermedia.
  4. Añade el camino de error con server.use y un estado 500, y verifica que el error queda representado en el estado y no solo registrado.
  5. Prueba el estado intermedio despachando sin esperar la promesa, y documenta con un comentario por qué falta el await.
  6. Escribe un test de cancelación con abort y otro que verifique que condition evita la segunda petición. Confirma que ambos fallan si eliminas esa lógica.