Si alguna vez escribiste una prueba que verifica que se llamó a dispatch({ type: 'FETCH_USER_SUCCESS' }) con la forma exacta del payload, escribiste una prueba que se rompe cada vez que alguien renombra una constante.
Eso no está probando tu lógica de estado. Está probando que tus dedos escribieron las cadenas correctas.
Los tutoriales de testing de Redux suelen empezar con mocks de Jest: espiar dispatch, afirmar que se llamó al action creator, afirmar que el tipo coincide, listo. Eso funciona para un tutorial. Se desmorona en una base de código real donde los tipos de action cambian, los payloads se refactorizan y tus pruebas se convierten en una lista de tareas de cada llamada a dispatch en la app.
La alternativa es probar el store como una unidad, no los action creators como individuos.
Por qué las pruebas action por action colapsan a escala
Imagina un componente de perfil de usuario que obtiene datos, maneja estados de carga y muestra errores. La prueba ingenua se ve así:
// The mock spiral
jest.mock('./api', () => ({ fetchUser: jest.fn() }));
it('dispatches fetch user action', async () => {
const dispatch = jest.fn();
await loadUser(42)(dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_REQUEST',
});
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_SUCCESS',
payload: { id: 42, name: 'Ada' },
});
});
Esta prueba pasa. También falla en el momento en que alguien agrega un campo meta a la action de éxito, o envuelve el payload en una clave data, o cambia a createAsyncThunk.
Más importante aún, no te dice nada sobre si el reducer realmente hizo algo con esa action. La action podría entrar en un reducer que la ignora y la prueba seguiría estando verde.
El enfoque de integration tests: tratar el store como una caja negra
El store ya es una función pura. Dado un estado y una action, devuelve un nuevo estado. La prueba más honesta es alimentarlo con acciones e inspeccionar el estado.
No necesitas mockear los reducers. No necesitas mockear los action creators. Configuras un store real, despachas acciones reales (o action creators) y lees el estado resultante.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
});
}
it('loads a user into state', () => {
const store = createTestStore();
store.dispatch({ type: 'user/fetchUser/fulfilled', payload: { id: 42, name: 'Ada' } });
expect(store.getState().user.entities[42]).toEqual({ id: 42, name: 'Ada' });
});
Esto es una línea de dispatch y una aserción. Prueba el contrato del que tu UI realmente depende: que después de que algo suceda, el estado se vea de cierta manera.
Probar thunks y lógica asíncrona con un store real
Probar middleware asíncrono es donde la mayoría de los equipos recurre a los mocks. No tienes por qué hacerlo.
Si usas Redux Toolkit, configureStore ya incluye redux-thunk y el middleware de devtools. Un store de prueba con la misma pila de middleware se comporta idénticamente a tu store de producción.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
middleware: (getDefault) => getDefault(), // same thunk middleware
});
}
it('handles the full fetch lifecycle', async () => {
const store = createTestStore();
// dispatch the thunk
const promise = store.dispatch(fetchUser(42));
// check loading state immediately
expect(store.getState().user.loading).toBe('pending');
await promise;
// check final state
expect(store.getState().user.loading).toBe('idle');
expect(store.getState().user.entities[42].name).toBe('Ada');
});
Si fetchUser llama a una API, todavía necesitas manejar la capa de red. Pero eso es un problema de fetch, no un problema de Redux. Usa msw para interceptar la solicitud HTTP, o pasa un cliente de API personalizado a través del extraArgument del thunk. De cualquier manera, tus aserciones se mantienen enfocadas en el estado, no en qué funciones se llamaron en qué orden.
Cuándo mockear y cuándo no
No estoy argumentando a favor de cero mocks. Los mocks son apropiados en los límites del sistema: solicitudes HTTP, APIs del navegador, temporizadores, bibliotecas externas con effects secundarios.
Los reducers no son un límite del sistema. Son funciones deterministas dentro de tu propia base de código. Mockearlos es como mockear una llamada a filter.
Los action creators, especialmente los thunks, son lógica de orchestration. Puedes probarlos a través de sus effects en el estado en lugar de sus llamadas internas a dispatch. Eso elimina el acoplamiento entre tus pruebas y la forma de tus acciones.
La compensación es la velocidad. Una prueba que ejercita la pila completa de reducers y middleware es más lenta que una prueba que afirma sobre un mock. En la práctica, la diferencia es de milisegundos. Si tu suite de pruebas es lenta, el culpable casi nunca son las pruebas del store de Redux. Es iniciar una instancia de JSDOM para cada archivo, o llamar a una base de datos real, o no limpiar los temporizadores.
¿Y si no usas Redux Toolkit?
Redux puro funciona de la misma manera. Construye un store con createStore, aplica tu middleware y despacha.
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';
function createTestStore(preloadedState) {
return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}
it('toggles the feature flag', () => {
const store = createTestStore({ features: { darkMode: false } });
store.dispatch({ type: 'features/TOGGLE_DARK_MODE' });
expect(store.getState().features.darkMode).toBe(true);
});
Si tienes una cadena de middleware compleja (logging, analytics, sagas), inclúyela en el store de prueba o recórtala. El objetivo es probar el comportamiento del que tus componentes dependen, no replicar la producción exactamente.
Pruebas de selectores: la otra mitad del contrato
Probar el store de forma aislada es solo la mitad de la historia. Tus componentes leen el estado a través de selectores. Una prueba de selector es igual de simple e igual de valiosa.
import { selectUserById, selectActiveUsers } from './userSelectors';
it('selects only active users', () => {
const state = {
user: {
entities: {
1: { id: 1, name: 'Ada', active: true },
2: { id: 2, name: 'Grace', active: false },
},
ids: [1, 2],
},
};
expect(selectActiveUsers(state)).toHaveLength(1);
expect(selectActiveUsers(state)[0].name).toBe('Ada');
});
Fíjate en que la prueba de selector no toca un store en absoluto. Toma una forma de estado y devuelve un valor derivado. Esto es lo que la hace rápida y precisa. El contrato entre tus reducers y tus selectores es la forma del estado, y ambos lados pueden probarse de forma independiente.
Un patrón para bases de código más grandes
En una base de código con muchos slices, crear un store de prueba por archivo de prueba se vuelve repetitivo. Centralízalo.
// test-utils/store.js
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from '../src/store';
export function createTestStore(preloadedState = {}) {
return configureStore({
reducer: rootReducer,
preloadedState,
middleware: (getDefault) =>
getDefault({
// disable immutable and serializable checks in tests
// to avoid noise when testing edge cases
immutableCheck: false,
serializableCheck: false,
}),
});
}
Luego importa createTestStore en cada prueba. La consistencia facilita que los compañeros de equipo escriban pruebas que se comportan de la misma manera, y te da un solo lugar para ajustar el middleware o agregar reducers específicos de prueba.
Dónde esto deja de funcionar
Hay casos en los que quieres probar a nivel de action. Si tienes middleware personalizado que transforma acciones, necesitas afirmar sobre la action transformada. Si estás construyendo una biblioteca que exporta action creators, la forma de la action es la API pública.
Para código de aplicación, sin embargo, la action es un detalle de implementación. El estado es lo que importa.
FAQ
¿Debería probar componentes de React conectados?
Prueba el componente de forma aislada con props mockeadas, o prueba la integración con @testing-library/react y un provider real. Ambos son válidos. La clave es no probar la plomería de Redux a través del componente. Prueba lo que ve el usuario.
¿Qué hay de las sagas?
Redux-Saga es una bestia diferente porque gestiona su propio flujo de control. Puedes probar sagas ejecutándolas con redux-saga-test-plan y afirmando sobre cambios de estado, o puedes probar la saga de forma aislada con cloneableGenerator. Las sagas son el único lugar donde mockear effects suele valer la pena.
¿Cómo manejo effects secundarios como analytics?
Extraelos en middleware y prueba el middleware por separado, o usa el patrón extraArgument para inyectar un cliente de analytics mockeado en tus thunks.
¿Esto funciona con Zustand u otros state managers?
Sí. El principio es el mismo: inicializa el store, desencadena un cambio de estado, lee el estado. Los stores de Zustand son aún más fáciles porque a menudo son solo funciones.
Pruébalo en un slice
Elige el slice que tocas con más frecuencia. Reemplaza una prueba llena de mocks con una prueba de store real. Ejecútala. Si es más lenta por más de 10ms, te invito a un café.
La mayoría de los equipos descubre que sus pruebas de Redux se vuelven más cortas, sobreviven a refactorizaciones y realmente capturan bugs una vez que dejan de verificar llamadas a dispatch y empiezan a verificar el estado.