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-jestjest.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.reducerTesting 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.