Skip to main content

Command Palette

Search for a command to run...

Mastering Jest: Day 6 — Testing Redux Toolkit with Jest, React Testing Library and MSW

Updated
•7 min read•View as Markdown
Mastering Jest: Day 6 — Testing Redux Toolkit with Jest, React Testing Library and MSW

Welcome to Day 6 of the Jest series. Yesterday we covered snapshot testing. Today we test Redux, the way it's written in 2026: Redux Toolkit slices, async thunks and RTK Query.

Updated October 2026. The first version of this post tested connect() components and hand-written action creators against createStore. That still runs, but it's not how new Redux code is written, and testing implementation details made those tests brittle. This version follows the approach the Redux team now recommends.

The idea: test behaviour, not wiring

The old approach tested every action creator, every reducer and every connected component in isolation. Most of those tests asserted that Redux works, which you don't need to prove.

The better split:

  • Integration tests (most of your tests): render a component with a real store, interact like a user, assert on what's on screen. Mock only the network.

  • Unit tests (a few): slices or selectors that hold real business logic, such as pricing rules or permission checks.

If you refactor a slice and the UI still works, no test should break.

Setup

npm install -D jest jest-fixed-jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom msw

jest-fixed-jsdom is a jsdom environment that keeps Node's fetch, Request and TextEncoder globals. MSW 2.x needs those, and plain jest-environment-jsdom removes them.

// jest.config.js (Next.js project)
const nextJest = require('next/jest')
const createJestConfig = nextJest({ dir: './' })

module.exports = createJestConfig({
  testEnvironment: 'jest-fixed-jsdom',
  setupFiles: ['<rootDir>/jest.env.js'],
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
})
// jest.env.js — runs before modules load
process.env.NEXT_PUBLIC_API_URL = 'http://localhost/api'

Why the absolute URL? In the browser, a relative baseUrl like /api works. In Node, new Request('/api/products') throws an invalid URL error. Reading the base URL from an env variable fixes tests without changing production behaviour.

The store, built for testing

The store file exports a factory that accepts preloaded state, so every test gets its own isolated store.

// src/lib/store.ts
import { combineSlices, configureStore } from '@reduxjs/toolkit'
import { cartSlice } from '@/features/cart/cartSlice'
import { api } from '@/services/api'

const rootReducer = combineSlices(cartSlice, api)
export type RootState = ReturnType<typeof rootReducer>

export const makeStore = (preloadedState?: Partial<RootState>) =>
  configureStore({
    reducer: rootReducer,
    preloadedState,
    middleware: (getDefault) => getDefault().concat(api.middleware),
  })

export type AppStore = ReturnType<typeof makeStore>
export type AppDispatch = AppStore['dispatch']
// src/services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export type Product = { id: string; name: string; price: number }

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_API_URL ?? '/api' }),
  endpoints: (build) => ({
    getProducts: build.query<Product[], void>({ query: () => 'products' }),
  }),
})

export const { useGetProductsQuery } = api

The renderWithProviders helper

This replaces the renderWithRedux function from the original post.

// src/test/test-utils.tsx
import type { ReactElement, ReactNode } from 'react'
import { render, type RenderOptions } from '@testing-library/react'
import { Provider } from 'react-redux'
import { makeStore, type AppStore, type RootState } from '@/lib/store'

type Options = Omit<RenderOptions, 'queries'> & {
  preloadedState?: Partial<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: ReactElement,
  { preloadedState, store = makeStore(preloadedState), ...options }: Options = {}
) {
  function Wrapper({ children }: { children: ReactNode }) {
    return <Provider store={store}>{children}</Provider>
  }
  return { store, ...render(ui, { wrapper: Wrapper, ...options }) }
}

It returns the store too, so a test can dispatch actions or inspect state when it needs to.

Mock the network with MSW

// src/test/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

export const handlers = [
  http.get('*/api/products', () =>
    HttpResponse.json([
      { id: 'p1', name: 'Keyboard', price: 120 },
      { id: 'p2', name: 'Mouse', price: 40 },
    ])
  ),
]

export const server = setupServer(...handlers)
// jest.setup.ts
import '@testing-library/jest-dom'
import { server } from '@/test/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: 'error' fails any test that hits an endpoint you forgot to mock, instead of letting it silently hang.

Test 1: a component reading from a slice

// src/features/cart/CartList.test.tsx
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@/test/test-utils'
import { CartList } from './CartList'

test('removes an item from the cart', async () => {
  const user = userEvent.setup()
  renderWithProviders(<CartList />, {
    preloadedState: {
      cart: { items: [{ id: 'p1', name: 'Keyboard', qty: 1 }] },
    },
  })

  expect(screen.getByText(/Keyboard/)).toBeInTheDocument()
  await user.click(screen.getByRole('button', { name: /remove/i }))
  expect(screen.queryByText(/Keyboard/)).not.toBeInTheDocument()
})

No mock store and no assertions about which action was dispatched. The test passes as long as the user can remove an item.

Test 2: RTK Query loading, success and error

// src/features/products/ProductList.test.tsx
import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/test/server'
import { renderWithProviders } from '@/test/test-utils'
import { ProductList } from './ProductList'

test('shows products from the API', async () => {
  renderWithProviders(<ProductList />)

  expect(screen.getByText(/loading/i)).toBeInTheDocument()
  expect(await screen.findByText('Keyboard')).toBeInTheDocument()
  expect(screen.getByText('Mouse')).toBeInTheDocument()
})

test('shows an error when the API fails', async () => {
  server.use(
    http.get('*/api/products', () => new HttpResponse(null, { status: 500 }))
  )
  renderWithProviders(<ProductList />)

  expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i)
})

Because every test creates a fresh store, the RTK Query cache never leaks between tests. If you ever share one store across tests, call store.dispatch(api.util.resetApiState()) in afterEach.

Test 3: an async thunk against a real store

When a thunk holds real logic, dispatch it to a real store and assert on the resulting state.

import { http, HttpResponse } from 'msw'
import { server } from '@/test/server'
import { makeStore } from '@/lib/store'
import { checkout } from './checkoutSlice'

test('stores the order id after checkout', async () => {
  server.use(
    http.post('*/api/checkout/:cartId', () => HttpResponse.json({ orderId: 'o_123' }))
  )
  const store = makeStore()

  await store.dispatch(checkout('cart_1'))

  expect(store.getState().checkout.orderId).toBe('o_123')
})

Test 4: a reducer with real business logic

Keep pure reducer tests for rules that are easy to get wrong.

import cartReducer, { itemAdded } from './cartSlice'

test('adding an existing item increases its quantity', () => {
  const start = { items: [{ id: 'p1', name: 'Keyboard', qty: 1 }] }
  const next = cartReducer(start, itemAdded({ id: 'p1', name: 'Keyboard', qty: 2 }))
  expect(next.items).toEqual([{ id: 'p1', name: 'Keyboard', qty: 3 }])
})

Skip tests like expect(increment()).toEqual({ type: 'INCREMENT' }). createSlice generates those action creators, and testing them only tests Redux Toolkit.

Before and after

Old approach (2024 post) New approach
Test every action creator Skip generated action creators
createStore + connect() makeStore + hooks + renderWithProviders
Mock thunks or the store Real store, mock the network with MSW
fireEvent userEvent
Assert on dispatched actions Assert on what the user sees

Conclusion

Test Redux through the UI with a real store, and mock only the network. You get fewer tests, they break less often, and they catch the bugs users actually hit.

For the full picture of how these slices and API endpoints are built, see The Complete Redux Handbook.

Tomorrow, on Day 7, we wrap up the series with advanced Jest techniques.

Is your test suite slowing your team down?

Flaky tests, a suite nobody trusts, or no tests at all on the parts of the app that make money: these are some of the first things I check in an Architecture Audit. For teams that need ongoing help, my monthly retainer adds a senior engineer for feature delivery, code review and refactors.

Book a free consultation →