Redux Toolkit 21 ⚛️ Testing Slices — Unit testing reducers and action creators
A Redux slice is the easiest part of a Redux application to test. The reducer is a pure function: given a state and an action, it returns a new state. The action creators are small functions that return plain objects. The async thunks are the only part that requires mocking, because they call the network. The slice’s generated reducer can be called directly with a state and an action, and the result can be asserted with a deep equality check.
This chapter covers the three things a slice test can verify: the reducer’s handling of synchronous actions, the reducer’s handling of the three states of an async thunk (pending, fulfilled, rejected), and the shape of the action creators. It uses Vitest as the test runner, but the patterns translate to Jest without changes. It also covers the one thing a slice test should not do: test the thunk’s network call, which belongs in a separate integration test with a mocked API.
Key point: The slice’s reducer is a plain function. Call it with the current state and an action, and assert the result. No store, no Provider, no rendering. The slice test is a unit test in the strict sense.
Why slice tests are the easiest tests to write
The purity problem is the solution. A reducer has no side effects, no async work, and no dependencies. It takes two arguments and returns one value. The test is a function call followed by an assertion. There is no setup, no teardown, and no mocking. The documentation for Redux testing recommends testing the reducer directly rather than through a store, because the store adds indirection without adding coverage .
The generated action types problem. A slice’s extraReducers and reducers generate action types that are not exported as constants. The test can reference them through the action creators (slice.actions.addTodo(...)) or through the thunk’s generated actions (fetchTodos.pending, fetchTodos.fulfilled, fetchTodos.rejected). The test does not need to construct the action objects manually.
The initial state problem. The slice’s initialState is often not exported. The test can call the reducer with undefined as the state to get the initial state, or the slice can export it explicitly. The documentation recommends exporting the initial state when the test needs to compare against it.
a. Testing synchronous reducers
A synchronous reducer test calls the reducer with a state and an action created by the slice’s action creator, then asserts that the result matches the expected state.
// features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface Todo {
id: string
title: string
completed: boolean
}
interface TodosState {
items: Todo[]
}
const initialState: TodosState = { items: [] }
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo(state, action: PayloadAction<Todo>) {
state.items.push(action.payload)
},
toggleTodo(state, action: PayloadAction<string>) {
const todo = state.items.find((t) => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
removeTodo(state, action: PayloadAction<string>) {
state.items = state.items.filter((t) => t.id !== action.payload)
},
},
})
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions
export default todosSlice.reducer
// features/todos/todosSlice.test.ts
import { describe, it, expect } from 'vitest'
import reducer, { addTodo, toggleTodo } from './todosSlice'
describe('todosSlice', () => {
it('returns the initial state when called with undefined', () => {
const state = reducer(undefined, { type: '@@INIT' })
expect(state).toEqual({ items: [] })
})
it('adds a todo', () => {
const state = reducer(undefined, addTodo({ id: '1', title: 'Test', completed: false }))
expect(state.items).toHaveLength(1)
expect(state.items[0].title).toBe('Test')
})
it('toggles a todo', () => {
const previous = { items: [{ id: '1', title: 'Test', completed: false }] }
const state = reducer(previous, toggleTodo('1'))
expect(state.items[0].completed).toBe(true)
})
})
The undefined state in the first test is how Redux calls the reducer during store initialization. The reducer returns initialState when it receives undefined, which is the mechanism that supplies the default state. Calling reducer(undefined, { type: '@@INIT' }) is the standard way to assert the initial state .
The toEqual assertion is a deep equality check. The toBe assertion is a reference check, which is not appropriate for objects. The test should use toEqual for state objects and toBe for primitive values.
b. Testing async thunk states
An async thunk generates three action types. The reducer handles them in extraReducers. The test calls the reducer with each generated action and asserts the resulting state. The thunk itself is not called; the test only exercises the reducer’s handling of the actions.
// features/todos/todosSlice.ts (continued)
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos')
return (await response.json()) as Todo[]
})
const todosSlice = createSlice({
name: 'todos',
initialState,
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 error'
})
},
})
// features/todos/todosSlice.test.ts (continued)
import { fetchTodos } from './todosSlice'
describe('fetchTodos', () => {
it('sets status to loading on pending', () => {
const state = reducer(undefined, fetchTodos.pending('requestId'))
expect(state.status).toBe('loading')
})
it('stores the payload on fulfilled', () => {
const todos = [{ id: '1', title: 'Test', completed: false }]
const state = reducer(undefined, fetchTodos.fulfilled(todos, 'requestId'))
expect(state.items).toEqual(todos)
expect(state.status).toBe('idle')
})
it('sets the error on rejected', () => {
const state = reducer(
undefined,
fetchTodos.rejected(new Error('Network error'), 'requestId')
)
expect(state.status).toBe('failed')
expect(state.error).toBe('Network error')
})
})
The generated action creators (fetchTodos.pending, fetchTodos.fulfilled, fetchTodos.rejected) accept the arguments that the thunk would pass to them. The pending action accepts the request ID. The fulfilled action accepts the payload and the request ID. The rejected action accepts the error, the request ID, and an optional rejectValue payload .
The rejected action’s error field is a SerializedError, which has message, name, and stack properties. The test constructs it by passing an Error object to the action creator, and the action creator serializes it .
c. What a slice test should not do
A slice test does not call the thunk. It does not mock fetch. It does not render a component. It calls the reducer with the actions that the thunk would generate. The thunk’s network call belongs in an integration test that mocks the API layer, and the component’s rendering belongs in a component test that uses a real store with a mocked API.
The slice test is a unit test of the reducer’s logic: given this action, does the state transition match the expected shape? That is all it needs to verify.
Complete Example Session
// ============================================
// PART 1: THE SLICE UNDER TEST
// ============================================
// features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface CounterState {
value: number
}
const initialState: CounterState = { value: 0 }
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) { state.value += 1 },
decrement(state) { state.value -= 1 },
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
// ============================================
// PART 2: THE INITIAL STATE TEST
// ============================================
import reducer from './counterSlice'
it('returns the initial state', () => {
expect(reducer(undefined, { type: '@@INIT' })).toEqual({ value: 0 })
})
// ============================================
// PART 3: THE INCREMENT TEST
// ============================================
import { increment } from './counterSlice'
it('increments the value', () => {
expect(reducer({ value: 0 }, increment())).toEqual({ value: 1 })
})
// ============================================
// PART 4: THE DECREMENT TEST
// ============================================
import { decrement } from './counterSlice'
it('decrements the value', () => {
expect(reducer({ value: 5 }, decrement())).toEqual({ value: 4 })
})
// ============================================
// PART 5: THE PAYLOAD TEST
// ============================================
import { incrementByAmount } from './counterSlice'
it('increments by the payload amount', () => {
expect(reducer({ value: 0 }, incrementByAmount(10))).toEqual({ value: 10 })
})
// ============================================
// PART 6: THE ACTION CREATOR TEST
// ============================================
it('creates the correct action', () => {
expect(increment()).toEqual({ type: 'counter/increment', payload: undefined })
expect(incrementByAmount(5)).toEqual({ type: 'counter/incrementByAmount', payload: 5 })
})
// ============================================
// PART 7: THE ASYNC THUNK PENDING TEST
// ============================================
import { fetchTodos } from './todosSlice'
it('sets loading on pending', () => {
const state = reducer(undefined, fetchTodos.pending('req-1'))
expect(state.status).toBe('loading')
})
// ============================================
// PART 8: THE ASYNC THUNK FULFILLED TEST
// ============================================
it('stores data on fulfilled', () => {
const todos = [{ id: '1', title: 'Test', completed: false }]
const state = reducer(undefined, fetchTodos.fulfilled(todos, 'req-1'))
expect(state.items).toEqual(todos)
expect(state.status).toBe('idle')
})
// ============================================
// PART 9: THE ASYNC THUNK REJECTED TEST
// ============================================
it('stores the error on rejected', () => {
const state = reducer(undefined, fetchTodos.rejected(new Error('Failed'), 'req-1'))
expect(state.status).toBe('failed')
expect(state.error).toBe('Failed')
})
// ============================================
// PART 10: RUNNING THE TESTS
// ============================================
// package.json
// "scripts": { "test": "vitest" }
//
// Run: npm test
// All slice tests pass without a store or a component.
The ten parts covered the slice, the initial state, increment, decrement, payload, action creators, thunk pending, thunk fulfilled, thunk rejected, and running the tests.
Quick Reference
What to test
| Test | Action | Assertion |
|---|---|---|
| Initial state | reducer(undefined, { type: '@@INIT' }) | toEqual(initialState) |
| Sync reducer | reducer(state, actionCreator()) | toEqual(expected) |
| Thunk pending | reducer(state, thunk.pending(id)) | Status is loading |
| Thunk fulfilled | reducer(state, thunk.fulfilled(data, id)) | Data is stored |
| Thunk rejected | reducer(state, thunk.rejected(error, id)) | Error is stored |
| Action creator | actionCreator(arg) | toEqual({ type, payload }) |
Generated thunk actions
| Action | Arguments |
|---|---|
thunk.pending | (requestId) |
thunk.fulfilled | (payload, requestId) |
thunk.rejected | (error, requestId, rejectValue?) |
Assertion methods
| Method | Use for |
|---|---|
toEqual | Deep equality (objects, arrays) |
toBe | Reference equality (primitives) |
toHaveLength | Array length |
toHaveProperty | Nested property |
Test setup
| Tool | Purpose |
|---|---|
| Vitest or Jest | Test runner |
reducer import | Call the function directly |
undefined state | Get initial state |
| No store | Not needed for slice tests |
Best Practices
✅ Do This:
// Call the reducer directly
const state = reducer(undefined, addTodo(todo)) // ✅
// Use the generated thunk actions
reducer(state, fetchTodos.pending('req')) // ✅
// Assert with deep equality
expect(state).toEqual(expected) // ✅
// Test the initial state
expect(reducer(undefined, { type: '@@INIT' })).toEqual(initialState) // ✅
❌ Don’t Do This:
// Create a store to test the reducer
const store = configureStore({ reducer }); store.dispatch(...) // ❌ (unnecessary)
// Mock fetch in a slice test
vi.mock('fetch') // ❌ (thunk test, not slice test)
// Construct actions manually
reducer(state, { type: 'todos/addTodo', payload: todo }) // ❌ (use the action creator)
// Use toBe for objects
expect(state).toBe({ items: [] }) // ❌ (reference check)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Initial state test fails | undefined not passed | Use reducer(undefined, { type: '@@INIT' }) |
| Thunk action creator type error | Wrong arguments | Check the generated action signature |
action.payload undefined in rejected | No rejectValue | Use action.error.message |
| Tests pass but app fails | Thunk network call not tested | Add an integration test for the thunk |
| Mutation not detected | Reducer mutates state directly | Immer handles mutations; use toEqual on the returned state |
Real-World Examples
1. Initial state
expect(reducer(undefined, { type: '@@INIT' })).toEqual({ value: 0 })
2. Sync reducer
expect(reducer({ value: 0 }, increment())).toEqual({ value: 1 })
3. Payload reducer
expect(reducer({ value: 0 }, incrementByAmount(5))).toEqual({ value: 5 })
4. Action creator
expect(increment()).toEqual({ type: 'counter/increment', payload: undefined })
5. Thunk pending
reducer(undefined, fetchTodos.pending('req'))
6. Thunk fulfilled
reducer(undefined, fetchTodos.fulfilled(data, 'req'))
7. Thunk rejected
reducer(undefined, fetchTodos.rejected(new Error('fail'), 'req'))
8. Deep equality
expect(state.items).toEqual([todo])
9. Multiple actions
let state = reducer(undefined, addTodo(todo1))
state = reducer(state, addTodo(todo2))
expect(state.items).toHaveLength(2)
10. Rejected with rejectValue
reducer(undefined, fetchTodos.rejected(null, 'req', { message: 'fail' }))
Visual
The slice test flow
┌─────────────────────────────────────────────────────────────┐
│ SLICE TEST │
│ │
│ import reducer from './slice' │
│ import { actionCreator } from './slice' │
│ │ │
│ ▼ │
│ state = reducer(previousState, actionCreator(payload)) │
│ │ │
│ ▼ │
│ expect(state).toEqual(expectedState) │
│ │
│ No store. No Provider. No render. │
└─────────────────────────────────────────────────────────────┘
The three thunk states
┌─────────────────────────────────────────────────────────────┐
│ ASYNC THUNK STATES │
│ │
│ thunk.pending('req') │
│ → reducer sets status = 'loading' │
│ │
│ thunk.fulfilled(data, 'req') │
│ → reducer sets status = 'idle', stores data │
│ │
│ thunk.rejected(error, 'req') │
│ → reducer sets status = 'failed', stores error │
└─────────────────────────────────────────────────────────────┘
What a slice test does not cover
┌─────────────────────────────────────────────────────────────┐
│ COVERED NOT COVERED │
│ │
│ Reducer logic Thunk network call │
│ Action shapes Component rendering │
│ State transitions Store integration │
│ Initial state Selector memoization │
│ │
│ Add a thunk integration test and a component test │
│ for the uncovered parts. │
└─────────────────────────────────────────────────────────────┘
The assertion choice
┌─────────────────────────────────────────────────────────────┐
│ ASSERTION │
│ │
│ toEqual │
│ Deep equality │
│ Use for state objects and arrays │
│ │
│ toBe │
│ Reference equality │
│ Use for primitives and identity checks │
│ │
│ expect(state).toEqual({ items: [] }) │
│ expect(state.status).toBe('idle') │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Test target | Slice reducer and action creators |
| Initial state | reducer(undefined, { type: '@@INIT' }) |
| Sync action | reducer(state, actionCreator(payload)) |
| Thunk pending | reducer(state, thunk.pending(requestId)) |
| Thunk fulfilled | reducer(state, thunk.fulfilled(payload, requestId)) |
| Thunk rejected | reducer(state, thunk.rejected(error, requestId)) |
| Assertion | toEqual for objects, toBe for primitives |
| Store | Not needed |
| Mocking | Not needed for slice tests |
Key takeaways:
- The slice reducer is a plain function. Call it with a state and an action, and assert the result. No store, no Provider, no rendering. This is a unit test in the strict sense .
- The
undefinedstate returns the initial state. Redux calls the reducer withundefinedduring store initialization. The test can do the same to assert the initial state without importinginitialStateseparately. - The generated thunk actions are the test inputs.
thunk.pending,thunk.fulfilled, andthunk.rejectedare action creators that accept the same arguments the thunk would pass. The test calls them directly to exercise theextraReducersbranches. - The
rejectedaction’serroris aSerializedError. Passing anErrorobject to the action creator serializes it to{ message, name, stack }. The reducer readsaction.error.message. If the thunk usedrejectWithValue, the payload is inaction.payloadinstead. - Slice tests do not mock the network. The thunk’s network call belongs in a separate integration test with a mocked API. The slice test only verifies the state transitions that the thunk’s actions produce.
- Use
toEqualfor state,toBefor primitives. The reducer returns a new state object. A reference check on an object will fail even if the contents are correct.
Remember: A slice test is the cheapest test in a Redux application. It has no dependencies, no setup, and no async work. The reducer takes a state and an action and returns a new state. The test asserts that the transition matches the expectation. The action creators produce the actions. The thunk’s generated actions exercise the extraReducers branches. The store and the network are not part of the test. Write one test per reducer branch, one test for the initial state, and one test per thunk state. That is the coverage the slice needs.
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!