Learn Redux in 10 DaysDay 9: Testing Redux

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:

  1. Test behavior, not implementation -- don't rely on action type strings or state shapes
  2. Prioritize integration tests -- components wired to a store give the best coverage
  3. Use renderWithProviders -- set any initial state via preloadedState
  4. Use MSW for RTK Query -- network-level mocking is the most realistic
  5. 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:

  1. A loading indicator appears while data is being fetched
  2. After data loads successfully, the list is displayed
  3. 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)