wandres.dev
TESTING DE REDUX · reducers y efectos

Integración con el store real: verificar el bucle completo

Los tests de reducers, selectores y thunks verifican piezas correctas; ninguno verifica que estén bien conectadas entre sí. Esta lección construye el test de integración canónico de Redux: una utilidad renderWithProviders que crea un store nuevo por caso, lo alimenta con preloadedState, monta el componente real dentro del Provider y ejerce la interfaz con user-event mientras msw sustituye la red. Explica por qué nunca se deben simular useSelector ni useDispatch, por qué el aislamiento exige un store por test y no un singleton de módulo, cómo esperar el estado asíncrono con las consultas find y waitFor, y por qué este es el único nivel donde el test observa exactamente lo que observa el usuario.

⏱ 18 min

Puedes tener reducers impecables, selectores memoizados con precisión quirúrgica y thunks que manejan todos los caminos de error, y aun así entregar una pantalla rota. Basta con que un componente lea el selector equivocado, que despache una acción que ningún reducer maneja, que el middleware que necesitabas no esté registrado o que el estado inicial precargado no tenga la forma que la interfaz espera. Ninguno de los tests anteriores puede detectar esa clase de fallo, porque todos verifican piezas en aislamiento y el defecto vive en las juntas. El test de integración con store real existe exactamente para eso: montar el componente de verdad, dentro del Provider de verdad, con un store construido con tus reducers de verdad, y recorrer el bucle completo del flujo unidireccional —el usuario actúa, se despacha una acción, el reducer produce un estado nuevo, el selector deriva, la interfaz se repinta— afirmando únicamente sobre lo que aparece en pantalla.

🎯 Al terminar esta lección sabrás
  • Construir una utilidad renderWithProviders que cree un store aislado por test con preloadedState.
  • Ejercer la interfaz con user-event y afirmar sobre el resultado visible, nunca sobre el store.
  • Combinar store real y msw para cubrir el bucle completo incluyendo la capa asíncrona.
  • Entender por qué simular useSelector o useDispatch invalida el test y qué se pierde al hacerlo.

El bucle que solo este test recorre

Conviene ver con precisión qué añade este nivel. Los tests anteriores cubren cada arista del diagrama por separado; el de integración cubre el ciclo entero y, sobre todo, cubre el cableado: qué componente consume qué selector, qué acción emite cada interacción, qué middleware está montado en este store concreto.

flowchart LR
A[usuario hace clic] --> B[componente despacha accion]
B --> C[middleware]
C --> D[reducer produce estado nuevo]
D --> E[selector deriva]
E --> F[componente se repinta]
F --> G[afirmacion sobre lo visible]
style A fill:#fab387,color:#11111b
style G fill:#a6e3a1,color:#11111b

Vale la pena nombrar los defectos que solo aquí aparecen, porque justifican el coste. Un componente que consume un selector obsoleto tras un renombrado; una acción despachada con el nombre correcto pero manejada por el slice equivocado; un middleware olvidado al reorganizar configureStore; un preloadedState que la interfaz interpreta de otra forma; un componente memoizado que deja de repintarse porque un selector perdió su estabilidad referencial. Los cinco pasan las pruebas unitarias con nota y rompen la pantalla.

La regla que gobierna todo el diseño de estos tests se enuncia en una línea: se actúa como el usuario y se afirma como el usuario. Se hace clic en un botón identificado por su rol y su nombre accesible, se escribe en un campo por su etiqueta, y se comprueba que aparece un texto. En ningún momento se llama a store.dispatch para preparar la escena ni se consulta store.getState() para verificar el resultado; si haces eso, has escrito un test de thunk con un componente pegado encima y has perdido justo la garantía que buscabas.

⚠️
Nunca simules useSelector ni useDispatch

La tentación de sustituir los hooks de react-redux por dobles aparece cuando el estado necesario es incómodo de construir. Cederle destruye el test: con useSelector simulado ya no verificas que el componente lea el selector correcto ni que el selector devuelva lo que crees; con useDispatch simulado afirmas que se llamó a una función con un objeto, que es la definición misma de acoplarse a la implementación. Y lo peor es que el test seguirá pasando cuando alguien rompa el cableado real. Si el estado te resulta incómodo de construir, la solución es preloadedState o plegar acciones sobre el store real, nunca amputar la mitad del sistema.

renderWithProviders y el aislamiento por test

La pieza de infraestructura es una función de render propia que envuelve el componente en los proveedores que la aplicación necesita y devuelve además el store para los casos límite en que hace falta. Lo esencial es que el store se crea dentro de la función, una vez por invocación: un store de módulo compartido filtra estado entre casos y produce la clase de intermitencia que depende del orden de ejecución, el defecto más caro de diagnosticar de una suite.

import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'react-redux'
import { setupStore } from '../store'
import type { AppStore, RootState } from '../store'

interface Opciones extends Omit<RenderOptions, 'queries'> {
  preloadedState?: Partial<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: React.ReactElement,
  { preloadedState = {}, store = setupStore(preloadedState), ...resto }: Opciones = {},
) {
  const Envoltorio = ({ children }: { children: React.ReactNode }) => (
    <Provider store={store}>{children}</Provider>
  )
  return { store, usuario: userEvent.setup(), ...render(ui, { wrapper: Envoltorio, ...resto }) }
}

La función setupStore es la misma que usa la aplicación en producción, parametrizada con preloadedState. Ese detalle importa más de lo que parece: si el test construyera su store con una configuración distinta —sin un middleware, con reducers recortados— estaría verificando un sistema que no es el tuyo. El objetivo es que la única diferencia entre el store del test y el de producción sea el estado inicial.

Sobre preloadedState conviene fijar una disciplina, porque es a la vez el atajo legítimo y una puerta trasera. Tiparlo como parcial del RootState permite precargar solo la rama que el flujo necesita, lo cual mantiene los tests legibles; pero un estado precargado que describe una situación inalcanzable —una lista de identificadores sin entidades, una bandera de error junto a datos cargados— produce tests que verifican ficción y que pasan mientras el flujo real falla. Cuando dudes de si un estado es alcanzable, constrúyelo despachando acciones sobre el store y pásalo luego; el reducer es el mejor validador de plausibilidad que tienes.

import { screen } from '@testing-library/react'
import { renderWithProviders } from '../test/utils'
import { ListaTareas } from './ListaTareas'

it('marca una tarea como hecha al hacer clic', async () => {
  const { usuario } = renderWithProviders(<ListaTareas />, {
    preloadedState: {
      todos: { entidades: { a1: { id: 'a1', texto: 'leer', hecho: false } }, ids: ['a1'], filtro: 'todos' },
    },
  })
  await usuario.click(screen.getByRole('checkbox', { name: /leer/i }))
  expect(screen.getByRole('checkbox', { name: /leer/i })).toBeChecked()
})
🧩

Cableado

Que el componente lea el selector correcto y despache la acción que algún reducer maneja de verdad. Nadie más lo verifica.

🪟

Aislamiento

Un store nuevo por test. Sin singletons de módulo, sin estado que se filtre entre casos, sin dependencia del orden.

⌨️

Interacción real

user-event reproduce la secuencia completa de eventos del navegador, incluido el foco y el teclado, no un disparo sintético.

🎛️

Estado precargado

preloadedState coloca la escena sin ejercitar cinco pantallas previas. Es el atajo legítimo, el mock de hooks no lo es.

Asincronía, precisión y coste

Cuando el componente carga datos, este test se combina con la lección anterior: msw responde y la interfaz espera. La regla es no esperar tiempos sino condiciones. Las consultas de la familia find devuelven una promesa que se resuelve cuando el elemento aparece, y waitFor cubre las afirmaciones que no se expresan como presencia de un elemento. Cualquier espera fija por milisegundos es una intermitencia esperando a ocurrir en una máquina de integración continua más lenta que tu portátil.

it('muestra los usuarios que llegan del servidor', async () => {
  renderWithProviders(<PanelUsuarios />)
  expect(screen.getByRole('status', { name: /cargando/i })).toBeInTheDocument()
  expect(await screen.findByText('Ada')).toBeInTheDocument()
  expect(screen.queryByRole('status', { name: /cargando/i })).not.toBeInTheDocument()
})

Ese test recorre las tres capas de una sola vez: el estado de carga que produce la acción pending, la respuesta simulada por msw, la acción fulfilled, el reducer, el selector y el repintado. Es la verificación más parecida a la experiencia real del usuario que puedes obtener sin arrancar un navegador completo, y por eso concentra más confianza por test que cualquier otro nivel.

Queda un caso que la gente resuelve mal por costumbre: los flujos que dependen de un middleware propio. Un listener que reacciona a una acción para lanzar otra, o una saga que coordina dos peticiones, no se prueban espiando lo que emiten sino comprobando su consecuencia visible en la interfaz, exactamente igual que un thunk. La ventaja de que el store del test sea el real es precisamente esa: el middleware está montado, corre de verdad, y si alguien lo desregistra por accidente al reorganizar la configuración del store, este test se pone rojo mientras ninguna prueba unitaria se entera.

📝
Los devuelve la función de render, pero úsalos con moderación

renderWithProviders devuelve el store porque hay casos donde ayuda: precargar una escena imposible de alcanzar por interfaz, o verificar en un único test de humo que una acción llegó a un slice sin representación visual todavía. Son excepciones, y conviene que se noten como tales. Si empiezas a ver store.getState() en las afirmaciones de muchos tests, lo que estás construyendo ya no es un test de integración sino uno de reducer disfrazado, con todo el coste del render y ninguna de sus garantías; la señal de alarma es tener que mirar el estado para saber si la interfaz hizo lo correcto, porque significa que la interfaz no lo estaba mostrando.

ℹ️
Cuántos escribir y de qué

Estos tests son un orden de magnitud más lentos y más caros de mantener que los de reducer, así que su número debe ser deliberado. La heurística útil: uno por flujo de usuario significativo —crear, editar, filtrar, recuperarse de un error— y ninguno por combinación de campos. La combinatoria se cubre abajo, en reducers y selectores, donde cuesta microsegundos; aquí arriba se verifica que el camino existe y está bien conectado. Cuando notes que estás replicando en integración casos que ya cubre un reducer, es señal de que el nivel inferior no te da confianza y ese es el problema a arreglar.

Este es el único test que observa lo mismo que observa el usuario

La jerarquía de tests de este nivel puede leerse como una escala de proximidad a la verdad. Un test de reducer verifica una ley del dominio, pero opera sobre una abstracción que el usuario no conoce ni le importa: nadie ha pedido nunca que un estado tenga cierta forma. Un test de selector verifica una derivación correcta, pero sobre datos que nadie ve hasta que un componente los pinta. Un test de thunk verifica una orquestación, pero sobre acciones que son un vocabulario privado tuyo. Solo el test de integración habla del sistema en los mismos términos en que lo hace la persona que lo usa: hay un elemento con este texto, tiene esta casilla marcada, hago clic aquí y aparece aquello. Esa coincidencia de vocabulario tiene dos consecuencias que definen su papel en la estrategia. La primera es que es el único nivel donde un test que pasa significa literalmente que algo funciona, en vez de que una pieza es correcta bajo el supuesto de que las demás lo sean; toda la garantía compuesta que las pruebas unitarias prometen depende de hipótesis de cableado que solo aquí se comprueban. La segunda es que es el único nivel inmune a la refactorización interna: puedes migrar de Redux a otra cosa, reescribir tus slices, renombrar cada acción y normalizar el estado entero, y estos tests seguirán pasando sin tocar una línea, porque nunca supieron que Redux existía. Esa inmunidad es lo que los hace caros —montan medio sistema— y a la vez lo que los hace valiosos: son la única parte de tu suite que sigue diciendo la verdad después de que hayas cambiado de opinión sobre todo lo demás. Escríbelos pocos, escríbelos sobre flujos que le importen a alguien, y no dejes que ninguna comodidad de mocking les quite lo único que los justifica, que es no saber nada de tu implementación.

⚔️ Monta el bucle completo para un flujo real
  1. Escribe tu propia renderWithProviders reutilizando la misma setupStore que usa producción y verificando que acepta preloadedState tipado.
  2. Comprueba el aislamiento: escribe dos tests que modifiquen el mismo slice y confirma que pasan en cualquier orden y también ejecutados por separado.
  3. Elige un flujo de usuario completo y cúbrelo actuando solo con user-event y afirmando solo con consultas por rol o texto accesible.
  4. Busca en tu suite cualquier simulación de useSelector o useDispatch, elimínala y reescribe ese test con estado precargado.
  5. Añade el caso asíncrono con msw: afirma el estado de carga, espera con una consulta find y verifica que el indicador desaparece.
  6. Rompe el cableado a propósito —cambia el componente para que despache una acción que ningún reducer maneja— y comprueba que este test falla mientras los unitarios siguen en verde.