Testing Redux Toolkit: Reducers, Actions, Selectors, and Async Thunks with Jest

Testing Redux Toolkit: Reducers, Actions, Selectors, and Async Thunks with Jest

Redux Toolkit (RTK) is the official, opinionated way to write Redux logic. It eliminates the boilerplate of hand-written action creators and reducers, but testing RTK code still requires understanding what to test at each layer: slice reducers, selectors, createAsyncThunk flows, and the store itself.

Setup

npm install @reduxjs/toolkit react-redux
npm install -D jest @types/jest ts-jest

jest.config.js:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
}

Testing Slice Reducers

createSlice generates reducers and action creators together. Test the reducer function directly — it's a pure function that takes state and an action and returns the next state.

// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  status: 'idle' | 'loading' | 'failed'
}

const initialState: CartState = {
  items: [],
  status: 'idle',
}

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
      const existing = state.items.find(i => i.id === action.payload.id)
      if (existing) {
        existing.quantity += 1
      } else {
        state.items.push({ ...action.payload, quantity: 1 })
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload)
    },
    updateQuantity(
      state,
      action: PayloadAction<{ id: string; quantity: number }>
    ) {
      const item = state.items.find(i => i.id === action.payload.id)
      if (item) item.quantity = action.payload.quantity
    },
    clearCart(state) {
      state.items = []
    },
  },
})

export const { addItem, removeItem, updateQuantity, clearCart } =
  cartSlice.actions
export default cartSlice.reducer
// features/cart/cartSlice.test.ts
import reducer, {
  addItem,
  removeItem,
  updateQuantity,
  clearCart,
} from './cartSlice'

const item = { id: 'p1', name: 'Widget', price: 9.99 }

describe('cartSlice reducer', () => {
  it('returns initial state for unknown action', () => {
    expect(reducer(undefined, { type: '@@INIT' })).toEqual({
      items: [],
      status: 'idle',
    })
  })

  it('adds a new item with quantity 1', () => {
    const state = reducer(undefined, addItem(item))
    expect(state.items).toHaveLength(1)
    expect(state.items[0]).toEqual({ ...item, quantity: 1 })
  })

  it('increments quantity when same item added again', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, addItem(item))
    expect(state.items).toHaveLength(1)
    expect(state.items[0].quantity).toBe(2)
  })

  it('removes an item by id', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, removeItem('p1'))
    expect(state.items).toHaveLength(0)
  })

  it('ignores removeItem when id does not exist', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, removeItem('unknown'))
    expect(state.items).toHaveLength(1)
  })

  it('updates item quantity', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, updateQuantity({ id: 'p1', quantity: 5 }))
    expect(state.items[0].quantity).toBe(5)
  })

  it('ignores updateQuantity for unknown id', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, updateQuantity({ id: 'nope', quantity: 5 }))
    expect(state.items[0].quantity).toBe(1)
  })

  it('clears all items', () => {
    let state = reducer(undefined, addItem(item))
    state = reducer(state, clearCart())
    expect(state.items).toHaveLength(0)
  })
})

RTK uses Immer under the hood. Even though the reducer mutates state directly inside the slice, the returned value is a new frozen object — your tests can safely use toEqual for deep equality checks.

Testing Action Creators

Action creators generated by createSlice are type-safe. You can test their shape to catch regressions:

describe('cartSlice actions', () => {
  it('addItem creates correct action', () => {
    expect(addItem(item)).toEqual({
      type: 'cart/addItem',
      payload: item,
    })
  })

  it('clearCart creates correct action', () => {
    expect(clearCart()).toEqual({ type: 'cart/clearCart', payload: undefined })
  })
})

Testing Selectors

Selectors are pure functions over state. Test them directly without mounting a store:

// features/cart/cartSelectors.ts
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '../../store'

export const selectCartItems = (state: RootState) => state.cart.items

export const selectCartTotal = createSelector(selectCartItems, items =>
  items.reduce((sum, i) => sum + i.price * i.quantity, 0)
)

export const selectCartCount = createSelector(selectCartItems, items =>
  items.reduce((sum, i) => sum + i.quantity, 0)
)
// features/cart/cartSelectors.test.ts
import { selectCartTotal, selectCartCount } from './cartSelectors'

const makeState = (items: any[]) => ({
  cart: { items, status: 'idle' as const },
})

describe('cart selectors', () => {
  it('selectCartTotal returns 0 for empty cart', () => {
    expect(selectCartTotal(makeState([]))).toBe(0)
  })

  it('selectCartTotal sums price * quantity', () => {
    const state = makeState([
      { id: 'a', name: 'A', price: 10, quantity: 2 },
      { id: 'b', name: 'B', price: 5, quantity: 3 },
    ])
    expect(selectCartTotal(state)).toBe(35)
  })

  it('selectCartCount sums quantities', () => {
    const state = makeState([
      { id: 'a', name: 'A', price: 10, quantity: 2 },
      { id: 'b', name: 'B', price: 5, quantity: 1 },
    ])
    expect(selectCartCount(state)).toBe(3)
  })

  it('memoized selector returns same reference for equal input', () => {
    const state = makeState([{ id: 'a', name: 'A', price: 10, quantity: 1 }])
    const first = selectCartTotal(state)
    const second = selectCartTotal(state)
    expect(first).toBe(second)
  })
})

Testing createAsyncThunk

createAsyncThunk is the most complex piece to test because it handles three lifecycle actions: pending, fulfilled, and rejected. Test the reducer for each phase, and test the thunk's API call in isolation.

// features/products/productsSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

interface Product {
  id: string
  name: string
  price: number
}

interface ProductsState {
  items: Product[]
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  error: string | null
}

const initialState: ProductsState = {
  items: [],
  status: 'idle',
  error: null,
}

export const fetchProducts = createAsyncThunk(
  'products/fetchAll',
  async (categoryId: string) => {
    const response = await fetch(`/api/products?category=${categoryId}`)
    if (!response.ok) throw new Error('Failed to fetch products')
    return response.json() as Promise<Product[]>
  }
)

const productsSlice = createSlice({
  name: 'products',
  initialState,
  reducers: {},
  extraReducers(builder) {
    builder
      .addCase(fetchProducts.pending, state => {
        state.status = 'loading'
        state.error = null
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items = action.payload
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.error.message ?? 'Unknown error'
      })
  },
})

export default productsSlice.reducer

Testing the Reducer Response to Thunk Actions

// features/products/productsSlice.test.ts
import reducer, { fetchProducts } from './productsSlice'

const products = [
  { id: '1', name: 'Widget', price: 9.99 },
  { id: '2', name: 'Gadget', price: 19.99 },
]

describe('productsSlice extraReducers', () => {
  it('sets status to loading on pending', () => {
    const action = fetchProducts.pending('req-1', 'electronics')
    const state = reducer(undefined, action)
    expect(state.status).toBe('loading')
    expect(state.error).toBeNull()
  })

  it('populates items and sets succeeded on fulfilled', () => {
    const action = fetchProducts.fulfilled(products, 'req-1', 'electronics')
    const state = reducer(undefined, action)
    expect(state.status).toBe('succeeded')
    expect(state.items).toEqual(products)
  })

  it('sets status to failed and captures error message on rejected', () => {
    const error = new Error('Network error')
    const action = fetchProducts.rejected(error, 'req-1', 'electronics')
    const state = reducer(undefined, action)
    expect(state.status).toBe('failed')
    expect(state.error).toBe('Network error')
  })

  it('clears error when re-fetching after failure', () => {
    const errorAction = fetchProducts.rejected(
      new Error('oops'),
      'req-1',
      'electronics'
    )
    let state = reducer(undefined, errorAction)
    const pendingAction = fetchProducts.pending('req-2', 'electronics')
    state = reducer(state, pendingAction)
    expect(state.error).toBeNull()
    expect(state.status).toBe('loading')
  })
})

Testing the Thunk Dispatch

Test the thunk itself by mocking fetch and dispatching into a real store:

import { configureStore } from '@reduxjs/toolkit'
import reducer, { fetchProducts } from './productsSlice'

function makeStore() {
  return configureStore({ reducer: { products: reducer } })
}

describe('fetchProducts thunk', () => {
  beforeEach(() => {
    global.fetch = jest.fn()
  })
  afterEach(() => jest.restoreAllMocks())

  it('dispatches fulfilled with fetched products', async () => {
    ;(global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => products,
    })

    const store = makeStore()
    await store.dispatch(fetchProducts('electronics'))

    const state = store.getState().products
    expect(state.status).toBe('succeeded')
    expect(state.items).toEqual(products)
  })

  it('dispatches rejected when response is not ok', async () => {
    ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false })

    const store = makeStore()
    await store.dispatch(fetchProducts('electronics'))

    const state = store.getState().products
    expect(state.status).toBe('failed')
    expect(state.error).toBe('Failed to fetch products')
  })

  it('dispatches rejected on network failure', async () => {
    ;(global.fetch as jest.Mock).mockRejectedValueOnce(
      new Error('Network offline')
    )

    const store = makeStore()
    await store.dispatch(fetchProducts('electronics'))

    const state = store.getState().products
    expect(state.status).toBe('failed')
    expect(state.error).toBe('Network offline')
  })
})

Testing the Configured Store

For integration tests, configure a real store and drive it through dispatch sequences:

import { configureStore } from '@reduxjs/toolkit'
import cartReducer, { addItem, removeItem } from '../cart/cartSlice'
import productsReducer from '../products/productsSlice'
import { selectCartTotal } from '../cart/cartSelectors'

function makeStore() {
  return configureStore({
    reducer: {
      cart: cartReducer,
      products: productsReducer,
    },
  })
}

describe('store integration', () => {
  it('adds items and reflects correct total via selector', () => {
    const store = makeStore()
    store.dispatch(addItem({ id: 'a', name: 'A', price: 10 }))
    store.dispatch(addItem({ id: 'b', name: 'B', price: 5 }))
    store.dispatch(addItem({ id: 'a', name: 'A', price: 10 }))

    const total = selectCartTotal(store.getState())
    expect(total).toBe(25) // 10*2 + 5*1
  })

  it('reflects empty state after clearCart', () => {
    const store = makeStore()
    store.dispatch(addItem({ id: 'a', name: 'A', price: 10 }))
    store.dispatch(clearCart())
    expect(selectCartTotal(store.getState())).toBe(0)
  })
})

What to Test vs. Skip

Layer Test it? Why
Slice reducer (pure function) Yes Core business logic
Action creator shape Only for regressions Generated code; test sparingly
Selectors Yes, including memoization Pure functions with edge cases
createAsyncThunk reducer cases Yes Covers all three lifecycle phases
Thunk dispatch (fetch mocked) Yes Verifies wiring end-to-end
RTK's internal Immer logic No Library code, already tested

End-to-End Validation with HelpMeTest

Unit tests catch reducer bugs in isolation, but they can't verify what users see. HelpMeTest lets you write natural language test scenarios that run against a real browser:

Go to /shop
Add "Widget" to cart
Verify cart badge shows "1"
Add "Widget" again
Verify cart badge shows "2"
Go to /cart
Verify total is "$19.98"
Remove "Widget"
Verify total is "$0.00"

These tests catch integration failures between your Redux store and React components — the kind of bugs that slip through unit tests when a selector returns the right value but the component renders the wrong one.

Free plan: Up to 10 tests, 24/7 monitoring. No code required.

Summary

Testing RTK well means testing at the right layers:

  • Reducers — pure functions, test directly with action creators
  • Selectors — test edge cases and verify memoization behavior
  • createAsyncThunk — test all three lifecycle phases in the reducer, then test the full dispatch with mocked API
  • Store integration — drive the store through realistic action sequences and assert via selectors

RTK's structure makes tests predictable. A slice reducer is a function; a selector is a function; a thunk is an async function. None of them need a mounted component to test.

Read more