Redux Toolkit 22 ⚛️ Testing Thunks — Mocking API calls in async thunks
An async thunk is not a pure function. It calls an API, waits for a response, and dispatches actions based on the result. Testing it requires either mocking the API layer and running the thunk through a real store, or extracting the payload creator and testing it in isolation. The Redux documentation recommends the first approach: mock the network request at the fetch/XHR level, dispatch the thunk through a real store, and assert the resulting actions and state. The thunk’s logic does not change in the test; it still makes a “real” request, but the request is intercepted by the mock .
This chapter covers the integration test pattern: a real store, a mocked API, and assertions on the state after the thunk resolves. It uses Mock Service Worker (MSW) as the mocking layer, Vitest as the runner, and waitFor to handle the async nature of the thunk. It also covers the unit-test alternative that tests the payload creator directly, and the unwrap() pattern for asserting the thunk’s resolved or rejected value.
Key point: Do not mock the thunk itself. Mock the network request, dispatch the real thunk through a real store, and wait for the state to reflect the result. Mocking the thunk tests nothing but the mock.
Why integration tests beat thunk unit tests
The implementation-detail problem. A thunk is an implementation detail of the application. Testing it in isolation verifies that the function calls the API and dispatches actions, but it does not verify that the store, the reducer, and the component work together. The Redux documentation states this explicitly: thunk behavior should be covered by testing the group of components that use it, not by testing the thunk in isolation .
The network-level mock problem. Mocking fetch directly is brittle. The test must construct a response object with ok, status, json, and any other fields the code accesses. MSW intercepts at the network level, so the thunk’s fetch call receives a response that behaves like a real HTTP response. The thunk code does not need to know it is being tested .
The async timing problem. A thunk resolves asynchronously. The test must wait for the promise to settle before asserting. React Testing Library’s waitFor retries the assertion until it passes or the timeout expires. Without it, the assertion runs before the thunk’s fulfilled action has updated the state .
a. Setting up MSW
MSW uses request handlers to intercept network calls. The handler matches a URL and method, and returns a mocked response. The setupServer function creates a server that intercepts requests in Node.js (the test environment).
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: '1', title: 'Test Todo', completed: false },
])
}),
]
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
The test file starts the server before all tests, resets the handlers after each test, and closes the server after all tests.
// features/todos/todosSlice.test.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from '../../mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
The resetHandlers call is important. It removes any per-test handlers added with server.use and restores the default handlers. Without it, a handler added in one test leaks into the next .
b. Dispatching the thunk through a real store
The test creates a real store with the slice’s reducer, dispatches the thunk, and asserts the state after it resolves. The thunk is not mocked; only the network is.
// features/todos/todosSlice.test.ts
import { describe, it, expect } from 'vitest'
import { configureStore } from '@reduxjs/toolkit'
import todosReducer, { fetchTodos } from './todosSlice'
function createTestStore() {
return configureStore({
reducer: { todos: todosReducer },
})
}
describe('fetchTodos', () => {
it('loads todos from the API', async () => {
const store = createTestStore()
await store.dispatch(fetchTodos())
const state = store.getState().todos
expect(state.status).toBe('idle')
expect(state.items).toHaveLength(1)
expect(state.items[0].title).toBe('Test Todo')
})
})
The await store.dispatch(fetchTodos()) waits for the thunk to complete. The thunk dispatches pending, then fulfilled (or rejected), and the reducer updates the state. The assertion checks the final state .
If the test needs to assert the actions that were dispatched, the store can be wrapped with a spy on dispatch, or the unwrap() method can be used to assert the resolved value directly.
it('resolves with the todo data', async () => {
const store = createTestStore()
const result = await store.dispatch(fetchTodos()).unwrap()
expect(result).toHaveLength(1)
})
The unwrap() method throws if the thunk rejects, which makes the test fail with the rejection reason instead of a timeout .
c. Testing the rejected state
To test the rejection path, the test overrides the MSW handler for that specific test with server.use. The handler returns an error response, and the thunk’s rejected action updates the state.
import { http, HttpResponse } from 'msw'
import { server } from '../../mocks/server'
it('sets the error state on API failure', async () => {
server.use(
http.get('/api/todos', () => {
return new HttpResponse(null, { status: 500 })
})
)
const store = createTestStore()
await store.dispatch(fetchTodos())
const state = store.getState().todos
expect(state.status).toBe('failed')
expect(state.error).toBeDefined()
})
The server.use call adds a handler that takes precedence over the default handler for the duration of the test. The afterEach resetHandlers call removes it before the next test .
If the thunk uses rejectWithValue, the rejected action’s payload is the value passed to rejectWithValue. The test asserts on action.payload in the reducer, or uses unwrap() and catches the error in the test.
it('rejects with a typed error', async () => {
server.use(
http.get('/api/todos', () => {
return HttpResponse.json({ message: 'Not found' }, { status: 404 })
})
)
const store = createTestStore()
await expect(store.dispatch(fetchTodos()).unwrap()).rejects.toEqual({
message: 'Not found',
})
})
Complete Example Session
// ============================================
// PART 1: THE SLICE WITH A THUNK
// ============================================
// features/todos/todosSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
interface Todo {
id: string
title: string
completed: boolean
}
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error('Failed to fetch')
return (await response.json()) as Todo[]
})
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] as Todo[], status: 'idle', error: null as string | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'idle'
state.items = action.payload
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown'
})
},
})
export default todosSlice.reducer
// ============================================
// PART 2: THE MSW HANDLER
// ============================================
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: '1', title: 'Test Todo', completed: false },
])
}),
]
// ============================================
// PART 3: THE MSW SERVER
// ============================================
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// ============================================
// PART 4: THE TEST SETUP
// ============================================
// features/todos/todosSlice.test.ts
import { beforeAll, afterEach, afterAll, describe, it, expect } from 'vitest'
import { configureStore } from '@reduxjs/toolkit'
import { server } from '../../mocks/server'
import todosReducer, { fetchTodos } from './todosSlice'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function createTestStore() {
return configureStore({ reducer: { todos: todosReducer } })
}
// ============================================
// PART 5: THE SUCCESS TEST
// ============================================
it('loads todos on fulfilled', async () => {
const store = createTestStore()
await store.dispatch(fetchTodos())
const state = store.getState().todos
expect(state.status).toBe('idle')
expect(state.items).toHaveLength(1)
expect(state.items[0].title).toBe('Test Todo')
})
// ============================================
// PART 6: THE UNWRAP ASSERTION
// ============================================
it('resolves with the payload', async () => {
const store = createTestStore()
const todos = await store.dispatch(fetchTodos()).unwrap()
expect(todos[0].title).toBe('Test Todo')
})
// ============================================
// PART 7: THE REJECTED TEST
// ============================================
import { http, HttpResponse } from 'msw'
it('sets the error on rejected', async () => {
server.use(
http.get('/api/todos', () => new HttpResponse(null, { status: 500 }))
)
const store = createTestStore()
await store.dispatch(fetchTodos())
const state = store.getState().todos
expect(state.status).toBe('failed')
expect(state.error).toBe('Failed to fetch')
})
// ============================================
// PART 8: THE UNIT TEST ALTERNATIVE
// ============================================
// Test the payload creator directly
import { fetchTodos } from './todosSlice'
it('payload creator returns todos', async () => {
const todos = await fetchTodos() // thunk not dispatched
// This does not work because fetchTodos is a thunk, not the payload creator.
})
// Correct: extract the payload creator
// const fetchTodosPayload = async () => { ... }
// export const fetchTodos = createAsyncThunk('todos/fetch', fetchTodosPayload)
// ============================================
// PART 9: THE COMPONENT INTEGRATION TEST
// ============================================
// Testing through a component that dispatches the thunk
import { render, screen, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
it('renders todos after fetch', async () => {
const store = createTestStore()
render(
<Provider store={store}>
<TodoList />
</Provider>
)
await waitFor(() => {
expect(screen.getByText('Test Todo')).toBeInTheDocument()
})
})
// ============================================
// PART 10: RUNNING THE TESTS
// ============================================
// package.json
// "scripts": { "test": "vitest" }
//
// npm test
// All thunk tests pass with a real store and a mocked network.
The ten parts covered the slice, the MSW handler, the server, the test setup, the success test, unwrap, the rejected test, the unit-test alternative, the component integration test, and running the tests.
Quick Reference
Mocking approaches
| Approach | What it mocks | Confidence |
|---|---|---|
| MSW | Network layer | High (thunk runs unchanged) |
vi.mock('fetch') | fetch function | Medium (response shape must be constructed) |
| Mock the API module | The API function | Low (thunk’s call is replaced) |
| Mock the thunk | The thunk itself | None (tests the mock) |
MSW setup
| Step | Code |
|---|---|
| Handler | http.get('/api/todos', () => HttpResponse.json(...)) |
| Server | setupServer(...handlers) |
| Before all | server.listen() |
| After each | server.resetHandlers() |
| After all | server.close() |
Test assertions
| Assertion | Method |
|---|---|
| Fulfilled state | expect(state.status).toBe('idle') |
| Loaded data | expect(state.items).toHaveLength(1) |
| Rejected state | expect(state.status).toBe('failed') |
| Resolved value | await store.dispatch(thunk()).unwrap() |
| Rejected value | await expect(...).rejects.toEqual(...) |
Store setup
| Item | Value |
|---|---|
| Reducer | The slice’s reducer |
| Middleware | Default (includes thunk) |
| Dispatch | await store.dispatch(thunk(arg)) |
| State | store.getState().sliceName |
Best Practices
✅ Do This:
// Mock the network with MSW
server.use(http.get('/api/todos', () => HttpResponse.json(data))) // ✅
// Dispatch the real thunk through a real store
await store.dispatch(fetchTodos()) // ✅
// Use waitFor for component assertions
await waitFor(() => expect(screen.getByText('Todo')).toBeInTheDocument()) // ✅
// Reset handlers after each test
afterEach(() => server.resetHandlers()) // ✅
❌ Don’t Do This:
// Mock the thunk itself
vi.mock('./todosSlice', () => ({ fetchTodos: vi.fn() })) // ❌
// Assert immediately after dispatch without awaiting
store.dispatch(fetchTodos())
expect(store.getState().todos.items).toHaveLength(1) // ❌ (not resolved yet)
// Leak handlers between tests
// No resetHandlers in afterEach // ❌
// Construct a fake fetch response by hand
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => ... }) // ❌ (brittle)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test passes but app fails | Thunk mocked instead of network | Mock at the network level with MSW |
| Assertion runs before state updates | No await on dispatch | await store.dispatch(thunk()) |
Cannot read property 'map' of undefined | Component renders before data arrives | Wrap assertion in waitFor |
| Handler not matching | Relative URL in MSW handler | Use absolute URL or configure base URL |
| Error state not set | Thunk does not throw on non-OK response | Add if (!response.ok) throw in payload creator |
| Tests interfere | Handlers leak between tests | server.resetHandlers() in afterEach |
Real-World Examples
1. MSW handler for GET
http.get('/api/todos', () => HttpResponse.json([todo]))
2. MSW handler for error
http.get('/api/todos', () => new HttpResponse(null, { status: 500 }))
3. Server setup
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
4. Test store
const store = configureStore({ reducer: { todos: todosReducer } })
5. Dispatch and await
await store.dispatch(fetchTodos())
6. Assert fulfilled state
expect(store.getState().todos.status).toBe('idle')
7. Assert rejected state
expect(store.getState().todos.status).toBe('failed')
8. Unwrap resolved value
const data = await store.dispatch(fetchTodos()).unwrap()
9. Unwrap rejected value
await expect(store.dispatch(fetchTodos()).unwrap()).rejects.toEqual(error)
10. Component waitFor
await waitFor(() => expect(screen.getByText('Todo')).toBeInTheDocument())
Visual
The integration test flow
┌─────────────────────────────────────────────────────────────┐
│ INTEGRATION TEST │
│ │
│ createTestStore() │
│ │ │
│ ▼ │
│ store.dispatch(fetchTodos()) │
│ │ │
│ ▼ │
│ thunk runs → fetch('/api/todos') │
│ │ │
│ ▼ │
│ MSW intercepts → returns mocked response │
│ │ │
│ ▼ │
│ thunk dispatches fulfilled(payload) │
│ │ │
│ ▼ │
│ reducer updates state │
│ │ │
│ ▼ │
│ await resolves │
│ │ │
│ ▼ │
│ expect(store.getState().todos) │
└─────────────────────────────────────────────────────────────┘
MSW lifecycle
┌─────────────────────────────────────────────────────────────┐
│ MSW LIFECYCLE │
│ │
│ beforeAll: server.listen() │
│ │ │
│ ▼ │
│ test 1: default handlers │
│ │ │
│ ▼ │
│ afterEach: server.resetHandlers() │
│ │ │
│ ▼ │
│ test 2: server.use(override) │
│ │ │
│ ▼ │
│ afterEach: server.resetHandlers() │
│ │ │
│ ▼ │
│ afterAll: server.close() │
└─────────────────────────────────────────────────────────────┘
What to mock
┌─────────────────────────────────────────────────────────────┐
│ MOCK LEVELS │
│ │
│ Component → Thunk → API → Network │
│ │ │ │ │ │
│ │ │ │ └── MSW (best) │
│ │ │ └── vi.mock(api) (ok) │
│ │ └── Mock thunk (worst) │
│ └── Mock store (component tests) │
│ │
│ Mock as low as possible. │
│ The thunk and store should run for real. │
└─────────────────────────────────────────────────────────────┘
waitFor retry loop
┌─────────────────────────────────────────────────────────────┐
│ WAITFOR │
│ │
│ waitFor(() => expect(...).toBeInTheDocument()) │
│ │ │
│ ├── assertion fails ──► wait 50ms ──► retry │
│ │ │
│ ├── assertion passes ──► resolve │
│ │ │
│ └── timeout ──► reject with last error │
│ │
│ Default timeout: 1000ms │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Mock level | Network (fetch/XHR) |
| Tool | MSW (msw/node) |
| Store | Real store with the slice’s reducer |
| Dispatch | await store.dispatch(thunk(arg)) |
| Fulfilled assertion | store.getState().slice.status |
| Rejected assertion | store.getState().slice.error |
| Resolved value | await store.dispatch(thunk()).unwrap() |
| Component assertion | waitFor(() => screen.getByText(...)) |
| Handler cleanup | server.resetHandlers() in afterEach |
Key takeaways:
- Mock the network, not the thunk. The Redux documentation recommends mocking at the
fetch/XHR level so the thunk runs unchanged. MSW is the recommended tool for this because it intercepts requests at the network layer and returns real-looking responses . - Use a real store. The test creates a store with the slice’s reducer, dispatches the real thunk, and asserts the resulting state. This verifies the thunk, the reducer, and the action creators together, which is the behavior the application actually depends on .
- Await the dispatch.
store.dispatch(fetchTodos())returns a promise that resolves when the thunk completes. Withoutawait, the assertion runs before thefulfilledaction has updated the state. - Use
unwrap()for value assertions. Theunwrap()method returns the fulfilled payload or throws the rejected payload. It is the cleanest way to assert what the thunk resolved or rejected with . - Use
waitForfor component assertions. A component that dispatches a thunk renders asynchronously.waitForretries the assertion until the component re-renders with the new state . - Reset handlers after each test.
server.resetHandlers()removes per-test overrides and restores the default handlers. Without it, a handler added in one test leaks into the next .
Remember: Testing a thunk is not about testing the function. It is about testing the chain: thunk → network → action → reducer → state. Mocking the thunk breaks the chain. Mocking the network preserves it. Use MSW to intercept the request, a real store to dispatch the thunk, and await to wait for the result. Assert the state that the reducer produced. For the component layer, wrap the assertion in waitFor so it retries until the UI reflects the new state. The test that mocks the thunk verifies nothing but the mock. The test that mocks the network verifies the code that will run in production.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!