Testing Redux Toolkit: Reducers, Actions, Selectors, and Async Thunks with Jest
Redux Toolkit (RTK) is the official, opinionated way to write Redux logic.
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.