Se você já escreveu um teste que verifica se dispatch({ type: 'FETCH_USER_SUCCESS' }) foi chamado com o formato exato do payload, você escreveu um teste que quebra toda vez que alguém renomeia uma constante.
Isso não é testar sua lógica de estado. É testar se seus dedos digitaram as strings certas.
Tutoriais de teste com Redux frequentemente começam com mocks do Jest: espie o dispatch, verifique se o action creator foi chamado, verifique se o type bate, pronto. Isso funciona para um tutorial. Desmorona em uma codebase real onde os action types mudam, payloads são refatorados e seus testes viram uma lista de tarefas de cada chamada dispatch no app.
A alternativa é testar a store como uma unidade, não os action creators como indivíduos.
Por que o teste action por action colapsa em escala
Imagine um componente de perfil de usuário que busca dados, lida com estados de loading e exibe erros. O teste ingênuo se parece com isso:
// A espiral de mock
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' },
});
});
Esse teste passa. Ele também falha no momento em que alguém adiciona um campo meta à action de sucesso, ou envolve o payload em uma chave data, ou migra para createAsyncThunk.
Mais importante, ele não diz nada sobre se o reducer realmente fez alguma coisa com aquela action. A action poderia cair em um reducer que a ignora e o teste continuaria verde.
A abordagem de integration test: trate a store como uma caixa preta
A store já é uma pure function. Dado um estado e uma action, ela retorna um novo estado. O teste mais honesto é alimentá-la com actions e inspecionar o estado.
Você não precisa mockar os reducers. Você não precisa mockar os action creators. Você configura uma store real, dispara actions reais (ou action creators) e lê o 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' });
});
Isso é uma linha de dispatch e uma asserção. Ele testa o contrato do qual sua UI realmente depende: que depois que algo acontece, o estado tem uma certa aparência.
Testando thunks e lógica assíncrona com uma store real
Testar middleware assíncrono é onde a maioria das equipes recorre a mocks. Você não precisa.
Se você está usando Redux Toolkit, o configureStore já inclui redux-thunk e o middleware de devtools. Uma store de teste com a mesma stack de middleware se comporta de forma idêntica à sua store de produção.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
middleware: (getDefault) => getDefault(), // mesmo 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');
});
Se o fetchUser chama uma API, você ainda precisa lidar com a camada de rede. Mas isso é um problema de fetch, não um problema de Redux. Use msw para interceptar a requisição HTTP, ou passe um client de API customizado pelo extraArgument do thunk. De qualquer forma, suas asserções permanecem focadas no estado, não em quais funções foram chamadas em qual ordem.
Quando mockar, e quando não
Não estou defendendo zero mocks. Mocks são apropriados nos limites do sistema: requisições HTTP, APIs do navegador, timers, bibliotecas externas com side effects.
Reducers não são um limite do sistema. Eles são funções determinísticas dentro da sua própria codebase. Mocká-los é como mockar uma chamada de filter.
Action creators, especialmente thunks, são lógica de orchestration. Você pode testá-los através de seus efeitos no estado ao invés de suas chamadas internas de dispatch. Isso remove o acoplamento entre seus testes e o formato das suas actions.
A contrapartida é velocidade. Um teste que exercita a stack completa de reducers e middleware é mais lento que um teste que faz asserções em um mock. Na prática, a diferença é de milissegundos. Se sua test suite está lenta, o culpado quase nunca são os testes da store Redux. É subir uma instância de JSDOM para cada arquivo, ou chamar um banco de dados real, ou não limpar timers.
E se você não está usando Redux Toolkit?
Redux puro funciona da mesma forma. Construa uma store com createStore, aplique seu middleware e faça dispatch.
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);
});
Se você tem uma cadeia complexa de middleware (logging, analytics, sagas), inclua-a na store de teste ou corte-a. O objetivo é testar o comportamento do qual seus componentes dependem, não replicar a produção exatamente.
Testes de selectors: a outra metade do contrato
Testar a store em isolamento é apenas metade da história. Seus componentes leem o estado através de selectors. Um teste de selector é igualmente simples e igualmente valioso.
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');
});
Note que o teste de selector não toca em uma store. Ele recebe um formato de estado e retorna um valor derivado. É isso que o torna rápido e preciso. O contrato entre seus reducers e seus selectors é o formato do estado, e ambos os lados podem ser testados de forma independente.
Um padrão para codebases maiores
Em uma codebase com muitos slices, criar uma store de teste por arquivo de teste fica repetitivo. Centralize-a.
// 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,
}),
});
}
Então importe createTestStore em cada teste. Consistência torna mais fácil para os membros da equipe escrever testes que se comportam da mesma forma, e te dá um único lugar para ajustar middleware ou adicionar reducers específicos de teste.
Onde isso para de funcionar
Há casos em que você quer testar no nível da action. Se você tem middleware customizado que transforma actions, você precisa verificar a action transformada. Se você está construindo uma biblioteca que exporta action creators, o formato da action é a API pública.
Para código de aplicação, porém, a action é um detalhe de implementação. O estado é o que importa.
FAQ
Devo testar componentes React conectados?
Teste o componente em isolamento com props mockadas, ou teste a integração com @testing-library/react e um provider real. Ambos são válidos. A chave é não testar o plumbing do Redux através do componente. Teste o que o usuário vê.
E quanto às sagas?
Redux-Saga é uma besta diferente porque ela gerencia seu próprio fluxo de controle. Você pode testar sagas executando-as com redux-saga-test-plan e fazendo asserções sobre mudanças de estado, ou pode testar a saga em isolamento com cloneableGenerator. Sagas são o único lugar onde fazer mock de effects geralmente vale a pena.
Como eu lido com side effects como analytics?
Extraia-os em middleware e teste o middleware separadamente, ou use o padrão extraArgument para injetar um client de analytics mockado nos seus thunks.
Isso funciona com Zustand ou outros state managers?
Sim. O princípio é o mesmo: inicialize a store, dispare uma mudança de estado, leia o estado. Stores do Zustand são ainda mais fáceis porque frequentemente são apenas funções.
Tente em um slice
Escolha o slice que você mais toca. Substitua um teste cheio de mocks por um teste com uma store real. Execute-o. Se ele for mais lento por mais de 10ms, eu te pago um café.
A maioria das equipes descobre que seus testes Redux ficam mais curtos, sobrevivem a refatorações e realmente pegam bugs quando param de verificar chamadas de dispatch e começam a verificar o estado.