Day 9: Testing Redux
What You'll Learn Today
- Redux testing philosophy: test behavior, not implementation
- Testing slices (reducers + actions) as pure functions
- Testing createAsyncThunk with mocked API calls
- Integration testing with React Testing Library
- RTK Query testing strategies with MSW
- Best practices for Redux testing
Testing Philosophy
The most important principle in Redux testing is to test behavior, not implementation details. Rather than asserting on internal state shapes or action type strings, focus on outcomes like "when the user clicks add, a new item appears in the list."
graph TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests<br/>Few & Expensive"]
INT["Integration Tests<br/>Moderate & Recommended"]
UNIT["Unit Tests<br/>Many & Cheap"]
end
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
style Pyramid fill:transparent,stroke:#666
For Redux applications, integration tests offer the best return on investment. By testing components together with the store, you validate the actual user experience.
Unit Testing Slices
Reducers are pure functions, making them the easiest part of Redux to test. You simply pass in a state and an action, then assert on the output.
The todoSlice Definition
// features/todos/todoSlice.js
import { createSlice, nanoid } from '@reduxjs/toolkit';
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all', // 'all' | 'active' | 'completed'
},
reducers: {
addTodo: {
reducer(state, action) {
state.items.push(action.payload);
},
prepare(text) {
return { payload: { id: nanoid(), text, completed: false } };
},
},
toggleTodo(state, action) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(state, action) {
state.items = state.items.filter((item) => item.id !== action.payload);
},
setFilter(state, action) {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todoSlice.actions;
export default todoSlice.reducer;
TypeScript version
// features/todos/todoSlice.ts
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
completed: boolean;
}
type FilterType = 'all' | 'active' | 'completed';
interface TodoState {
items: Todo[];
filter: FilterType;
}
const initialState: TodoState = {
items: [],
filter: 'all',
};
const todoSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: {
reducer(state, action: PayloadAction<Todo>) {
state.items.push(action.payload);
},
prepare(text: string) {
return { payload: { id: nanoid(), text, completed: false } };
},
},
toggleTodo(state, action: PayloadAction<string>) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(state, action: PayloadAction<string>) {
state.items = state.items.filter((item) => item.id !== action.payload);
},
setFilter(state, action: PayloadAction<FilterType>) {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todoSlice.actions;
export default todoSlice.reducer;
Testing the Reducer
// features/todos/todoSlice.test.js
import todoReducer, {
addTodo,
toggleTodo,
removeTodo,
setFilter,
} from './todoSlice';
describe('todoSlice', () => {
const initialState = {
items: [],
filter: 'all',
};
// --- addTodo ---
describe('addTodo', () => {
it('should add a new todo to an empty list', () => {
const state = todoReducer(initialState, addTodo('Learn Redux'));
expect(state.items).toHaveLength(1);
expect(state.items[0].text).toBe('Learn Redux');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].id).toBeDefined();
});
it('should add a new todo to an existing list', () => {
const stateWithOne = {
...initialState,
items: [{ id: '1', text: 'Existing', completed: false }],
};
const state = todoReducer(stateWithOne, addTodo('New Todo'));
expect(state.items).toHaveLength(2);
expect(state.items[1].text).toBe('New Todo');
});
it('should generate unique IDs', () => {
let state = todoReducer(initialState, addTodo('First'));
state = todoReducer(state, addTodo('Second'));
expect(state.items[0].id).not.toBe(state.items[1].id);
});
});
// --- toggleTodo ---
describe('toggleTodo', () => {
it('should toggle the completed status', () => {
const stateWithTodo = {
...initialState,
items: [{ id: '1', text: 'Test', completed: false }],
};
const state = todoReducer(stateWithTodo, toggleTodo('1'));
expect(state.items[0].completed).toBe(true);
const toggled = todoReducer(state, toggleTodo('1'));
expect(toggled.items[0].completed).toBe(false);
});
it('should not change state for a non-existent ID', () => {
const stateWithTodo = {
...initialState,
items: [{ id: '1', text: 'Test', completed: false }],
};
const state = todoReducer(stateWithTodo, toggleTodo('999'));
expect(state.items[0].completed).toBe(false);
});
});
// --- removeTodo ---
describe('removeTodo', () => {
it('should remove a todo by ID', () => {
const stateWithTodos = {
...initialState,
items: [
{ id: '1', text: 'First', completed: false },
{ id: '2', text: 'Second', completed: true },
],
};
const state = todoReducer(stateWithTodos, removeTodo('1'));
expect(state.items).toHaveLength(1);
expect(state.items[0].id).toBe('2');
});
});
// --- setFilter ---
describe('setFilter', () => {
it('should update the filter value', () => {
const state = todoReducer(initialState, setFilter('completed'));
expect(state.filter).toBe('completed');
});
});
});
TypeScript version
// features/todos/todoSlice.test.ts
import todoReducer, {
addTodo,
toggleTodo,
removeTodo,
setFilter,
} from './todoSlice';
import type { TodoState } from './todoSlice';
describe('todoSlice', () => {
const initialState: TodoState = {
items: [],
filter: 'all',
};
describe('addTodo', () => {
it('should add a new todo to an empty list', () => {
const state = todoReducer(initialState, addTodo('Learn Redux'));
expect(state.items).toHaveLength(1);
expect(state.items[0].text).toBe('Learn Redux');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].id).toBeDefined();
});
});
// ... same tests with type annotations
});
Testing createAsyncThunk
Async thunks require mocking API calls and verifying behavior for both success and failure cases.
The Async Thunk Definition
// features/todos/todoApi.js
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error('Failed to fetch todos');
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const saveTodo = createAsyncThunk(
'todos/saveTodo',
async (text, { rejectWithValue }) => {
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error('Failed to save todo');
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
Adding Async Cases to the Reducer
// todoSlice.js (extraReducers section)
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
reducers: {
// ... (reducers from above)
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
},
});
Approach 1: Test the Reducer Directly
// todoSlice.async.test.js
import todoReducer from './todoSlice';
import { fetchTodos } from './todoApi';
describe('async thunks - reducer tests', () => {
const initialState = {
items: [],
filter: 'all',
status: 'idle',
error: null,
};
it('should set loading state on fetchTodos.pending', () => {
const state = todoReducer(initialState, fetchTodos.pending('requestId'));
expect(state.status).toBe('loading');
expect(state.error).toBeNull();
});
it('should set items on fetchTodos.fulfilled', () => {
const todos = [
{ id: '1', text: 'Test', completed: false },
];
const state = todoReducer(
initialState,
fetchTodos.fulfilled(todos, 'requestId')
);
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(todos);
});
it('should set error on fetchTodos.rejected', () => {
const state = todoReducer(
initialState,
fetchTodos.rejected(null, 'requestId', undefined, 'Network error')
);
expect(state.status).toBe('failed');
expect(state.error).toBe('Network error');
});
});
Approach 2: Dispatch to a Real Store
// todoApi.test.js
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
import { fetchTodos, saveTodo } from './todoApi';
// Mock the fetch API
global.fetch = jest.fn();
describe('async thunks - store dispatch', () => {
let store;
beforeEach(() => {
store = configureStore({
reducer: { todos: todoReducer },
});
fetch.mockClear();
});
it('should fetch todos successfully', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Testing', completed: false },
{ id: '2', text: 'Write Tests', completed: true },
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTodos,
});
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(mockTodos);
expect(fetch).toHaveBeenCalledWith('/api/todos');
});
it('should handle fetch failure', async () => {
fetch.mockResolvedValueOnce({
ok: false,
});
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('failed');
expect(state.error).toBe('Failed to fetch todos');
});
it('should save a new todo', async () => {
const newTodo = { id: '3', text: 'New Todo', completed: false };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => newTodo,
});
const result = await store.dispatch(saveTodo('New Todo'));
expect(result.payload).toEqual(newTodo);
expect(fetch).toHaveBeenCalledWith('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'New Todo' }),
});
});
});
TypeScript version
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
import { fetchTodos, saveTodo } from './todoApi';
const globalFetch = global.fetch as jest.MockedFunction<typeof fetch>;
global.fetch = jest.fn();
describe('async thunks - store dispatch', () => {
const createTestStore = () =>
configureStore({
reducer: { todos: todoReducer },
});
let store: ReturnType<typeof createTestStore>;
beforeEach(() => {
store = createTestStore();
globalFetch.mockClear();
});
it('should fetch todos successfully', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Testing', completed: false },
];
globalFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTodos,
} as Response);
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(mockTodos);
});
});
Integration Testing with React Testing Library
Integration tests that combine components with a Redux store provide the highest value. Let's start by creating a reusable test helper.
The renderWithProviders Helper
// test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import todoReducer from './features/todos/todoSlice';
export function setupStore(preloadedState) {
return configureStore({
reducer: {
todos: todoReducer,
},
preloadedState,
});
}
export function renderWithProviders(
ui,
{
preloadedState = {},
store = setupStore(preloadedState),
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
TypeScript version
// test-utils.tsx
import React, { PropsWithChildren } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { configureStore, PreloadedState } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import todoReducer from './features/todos/todoSlice';
import type { RootState } from './store';
export function setupStore(preloadedState?: PreloadedState<RootState>) {
return configureStore({
reducer: {
todos: todoReducer,
},
preloadedState,
});
}
type AppStore = ReturnType<typeof setupStore>;
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>;
store?: AppStore;
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {} as PreloadedState<RootState>,
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return <Provider store={store}>{children}</Provider>;
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
Full Component Integration Test
The component under test:
// features/todos/TodoApp.jsx
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo, setFilter } from './todoSlice';
function TodoApp() {
const [text, setText] = useState('');
const dispatch = useDispatch();
const { items, filter } = useSelector((state) => state.todos);
const filteredItems = items.filter((item) => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text.trim()));
setText('');
}
};
return (
<div>
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
aria-label="New todo"
/>
<button type="submit">Add</button>
</form>
<div role="group" aria-label="Filter">
{['all', 'active', 'completed'].map((f) => (
<button
key={f}
onClick={() => dispatch(setFilter(f))}
aria-pressed={filter === f}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
<ul>
{filteredItems.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.completed}
onChange={() => dispatch(toggleTodo(item.id))}
/>
<span style={{
textDecoration: item.completed ? 'line-through' : 'none'
}}>
{item.text}
</span>
</label>
<button
onClick={() => dispatch(removeTodo(item.id))}
aria-label={`Remove ${item.text}`}
>
Delete
</button>
</li>
))}
</ul>
<p>{items.filter((i) => !i.completed).length} items left</p>
</div>
);
}
export default TodoApp;
The test suite:
// features/todos/TodoApp.test.jsx
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test-utils';
import TodoApp from './TodoApp';
describe('TodoApp integration', () => {
it('should add a new todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
const input = screen.getByLabelText('New todo');
const addButton = screen.getByText('Add');
await user.type(input, 'Learn Redux Testing');
await user.click(addButton);
expect(screen.getByText('Learn Redux Testing')).toBeInTheDocument();
expect(input).toHaveValue('');
});
it('should toggle a todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [{ id: '1', text: 'Test Todo', completed: false }],
filter: 'all',
},
},
});
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it('should remove a todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [{ id: '1', text: 'Delete Me', completed: false }],
filter: 'all',
},
},
});
await user.click(screen.getByLabelText('Remove Delete Me'));
expect(screen.queryByText('Delete Me')).not.toBeInTheDocument();
});
it('should filter todos', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [
{ id: '1', text: 'Active Task', completed: false },
{ id: '2', text: 'Done Task', completed: true },
],
filter: 'all',
},
},
});
// All todos visible
expect(screen.getByText('Active Task')).toBeInTheDocument();
expect(screen.getByText('Done Task')).toBeInTheDocument();
// Filter to active only
await user.click(screen.getByText('Active'));
expect(screen.getByText('Active Task')).toBeInTheDocument();
expect(screen.queryByText('Done Task')).not.toBeInTheDocument();
// Filter to completed only
await user.click(screen.getByText('Completed'));
expect(screen.queryByText('Active Task')).not.toBeInTheDocument();
expect(screen.getByText('Done Task')).toBeInTheDocument();
});
it('should display the correct items count', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
expect(screen.getByText('0 items left')).toBeInTheDocument();
await user.type(screen.getByLabelText('New todo'), 'Task 1');
await user.click(screen.getByText('Add'));
expect(screen.getByText('1 items left')).toBeInTheDocument();
});
it('should not add empty todos', async () => {
const user = userEvent.setup();
const { store } = renderWithProviders(<TodoApp />);
await user.click(screen.getByText('Add'));
expect(store.getState().todos.items).toHaveLength(0);
});
it('full user workflow: add, complete, filter, delete', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
// Add two todos
const input = screen.getByLabelText('New todo');
await user.type(input, 'Buy groceries');
await user.click(screen.getByText('Add'));
await user.type(input, 'Clean house');
await user.click(screen.getByText('Add'));
expect(screen.getByText('2 items left')).toBeInTheDocument();
// Complete one
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
expect(screen.getByText('1 items left')).toBeInTheDocument();
// Filter to completed
await user.click(screen.getByText('Completed'));
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.queryByText('Clean house')).not.toBeInTheDocument();
// Delete the completed item
await user.click(screen.getByLabelText('Remove Buy groceries'));
expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
// Back to all - only Clean house remains
await user.click(screen.getByText('All'));
expect(screen.getByText('Clean house')).toBeInTheDocument();
});
});
Testing RTK Query
For RTK Query, MSW (Mock Service Worker) is the recommended approach. It intercepts network requests at the service worker level, giving you realistic mocking.
Setting Up MSW
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: '1', text: 'Mock Todo 1', completed: false },
{ id: '2', text: 'Mock Todo 2', completed: true },
]);
}),
http.post('/api/todos', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: '3',
text: body.text,
completed: false,
});
}),
http.delete('/api/todos/:id', ({ params }) => {
return HttpResponse.json({ id: params.id });
}),
];
// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
RTK Query API Definition
// features/todos/todoApiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const todoApiSlice = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query({
query: () => '/todos',
providesTags: ['Todo'],
}),
addTodo: builder.mutation({
query: (text) => ({
url: '/todos',
method: 'POST',
body: { text },
}),
invalidatesTags: ['Todo'],
}),
}),
});
export const { useGetTodosQuery, useAddTodoMutation } = todoApiSlice;
Testing Components That Use RTK Query
// features/todos/todoApiSlice.test.jsx
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { renderWithProviders } from '../../test-utils';
import TodoListWithQuery from './TodoListWithQuery';
describe('RTK Query - todoApiSlice', () => {
it('should fetch and display todos', async () => {
renderWithProviders(<TodoListWithQuery />);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
expect(screen.getByText('Mock Todo 2')).toBeInTheDocument();
});
});
it('should handle server error', async () => {
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
})
);
renderWithProviders(<TodoListWithQuery />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it('should add a new todo via mutation', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoListWithQuery />);
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
});
await user.type(screen.getByLabelText('New todo'), 'New API Todo');
await user.click(screen.getByText('Add'));
await waitFor(() => {
expect(screen.getByText('New API Todo')).toBeInTheDocument();
});
});
});
End-to-End Async Flow Testing
Here is a comprehensive test for a full async workflow.
// features/todos/AsyncTodoFlow.test.jsx
import React from 'react';
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { renderWithProviders } from '../../test-utils';
import AsyncTodoApp from './AsyncTodoApp';
describe('async todo flow', () => {
it('should load todos, add one, and verify the list updates', async () => {
const user = userEvent.setup();
renderWithProviders(<AsyncTodoApp />);
// Wait for initial load
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
// Verify initial data
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
// Add a new todo
await user.type(screen.getByLabelText('New todo'), 'E2E Test Todo');
await user.click(screen.getByText('Add'));
// Wait for the mutation and refetch
await waitFor(() => {
expect(screen.getByText('E2E Test Todo')).toBeInTheDocument();
});
});
it('should show error and allow retry', async () => {
const user = userEvent.setup();
// Override handler for this test
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(null, { status: 500 });
})
);
renderWithProviders(<AsyncTodoApp />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
// Restore handler and retry
server.resetHandlers();
await user.click(screen.getByText('Retry'));
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
});
});
});
Best Practices
flowchart LR
subgraph DO["Recommended"]
A["Test behavior"]
B["Prefer integration tests"]
C["Use preloadedState"]
D["Use userEvent"]
end
subgraph DONT["Avoid"]
E["Testing action type strings"]
F["Testing internal state shape"]
G["Using fireEvent"]
H["Testing implementation details"]
end
style DO fill:#22c55e,color:#fff
style DONT fill:#ef4444,color:#fff
Summary
| What to Test | Approach | Tools | Priority |
|---|---|---|---|
| Reducers | Pure function testing | Jest | Medium |
| Async Thunks | Store dispatch + mocks | Jest + fetch mock | Medium |
| Components + Store | Integration tests | React Testing Library | High |
| RTK Query | API mocking | MSW + RTL | High |
| Selectors | Input/output testing | Jest | Low |
| Middleware | Test through store | Jest | Low |
Key principles:
- Test behavior, not implementation -- don't rely on action type strings or state shapes
- Prioritize integration tests -- components wired to a store give the best coverage
- Use
renderWithProviders-- set any initial state via preloadedState - Use MSW for RTK Query -- network-level mocking is the most realistic
- Use userEvent over fireEvent -- it simulates real user interactions more accurately
Exercises
Exercise 1: Write Reducer Tests
Write tests for the following counterSlice:
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, history: [] },
reducers: {
increment(state) {
state.value += 1;
state.history.push(state.value);
},
decrement(state) {
state.value -= 1;
state.history.push(state.value);
},
reset(state) {
state.value = 0;
state.history = [];
},
},
});
Exercise 2: Write an Integration Test
Write an integration test for TodoApp that:
- Adds 3 todos
- Marks the second one as completed
- Switches to the "Active" filter and verifies the completed todo is hidden
- Checks that the remaining items count is correct
Exercise 3: Write an Async Test
Using MSW, write tests for a fetchTodos flow that verifies:
- A loading indicator appears while data is being fetched
- After data loads successfully, the list is displayed
- When the server returns an error, an error message is shown
Exercise 4: Choose a Testing Strategy
For each of the following scenarios, decide what testing approach would be most effective:
- Shopping cart total calculation
- User authentication flow (login -> redirect -> dashboard display)
- Theme toggle (dark mode / light mode)