| | |

Redux Toolkit 21 ⚛️ Testing Slices — Unit testing reducers and action creators

⏱️ Estimated read: 12 min | |

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

TestActionAssertion
Initial statereducer(undefined, { type: '@@INIT' })toEqual(initialState)
Sync reducerreducer(state, actionCreator())toEqual(expected)
Thunk pendingreducer(state, thunk.pending(id))Status is loading
Thunk fulfilledreducer(state, thunk.fulfilled(data, id))Data is stored
Thunk rejectedreducer(state, thunk.rejected(error, id))Error is stored
Action creatoractionCreator(arg)toEqual({ type, payload })

Generated thunk actions

ActionArguments
thunk.pending(requestId)
thunk.fulfilled(payload, requestId)
thunk.rejected(error, requestId, rejectValue?)

Assertion methods

MethodUse for
toEqualDeep equality (objects, arrays)
toBeReference equality (primitives)
toHaveLengthArray length
toHavePropertyNested property

Test setup

ToolPurpose
Vitest or JestTest runner
reducer importCall the function directly
undefined stateGet initial state
No storeNot 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

PitfallWhy It HappensFix
Initial state test failsundefined not passedUse reducer(undefined, { type: '@@INIT' })
Thunk action creator type errorWrong argumentsCheck the generated action signature
action.payload undefined in rejectedNo rejectValueUse action.error.message
Tests pass but app failsThunk network call not testedAdd an integration test for the thunk
Mutation not detectedReducer mutates state directlyImmer 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

ItemValue
Test targetSlice reducer and action creators
Initial statereducer(undefined, { type: '@@INIT' })
Sync actionreducer(state, actionCreator(payload))
Thunk pendingreducer(state, thunk.pending(requestId))
Thunk fulfilledreducer(state, thunk.fulfilled(payload, requestId))
Thunk rejectedreducer(state, thunk.rejected(error, requestId))
AssertiontoEqual for objects, toBe for primitives
StoreNot needed
MockingNot 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 undefined state returns the initial state. Redux calls the reducer with undefined during store initialization. The test can do the same to assert the initial state without importing initialState separately.
  • The generated thunk actions are the test inputs. thunk.pending, thunk.fulfilled, and thunk.rejected are action creators that accept the same arguments the thunk would pass. The test calls them directly to exercise the extraReducers branches.
  • The rejected action’s error is a SerializedError. Passing an Error object to the action creator serializes it to { message, name, stack }. The reducer reads action.error.message. If the thunk used rejectWithValue, the payload is in action.payload instead.
  • 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 toEqual for state, toBe for 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.

Was this helpful?


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!