# 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

```bash
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.

```js
// 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' },
})
```

```js
// 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.

```ts
// 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']
```

```ts
// 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.

```tsx
// 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

```ts
// 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)
```

```ts
// 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

```tsx
// 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

```tsx
// 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.

```ts
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.

```ts
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](https://blog.aakibshah.com.np/the-complete-redux-handbook-best-practices-and-common-pitfalls).

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](https://aakibshah.com.np/services). For teams that need ongoing help, my monthly retainer adds a senior engineer for feature delivery, code review and refactors.

[Book a free consultation →](https://calendly.com/skywalker_03/one-on-one)

## Read next

*   [Redux Toolkit in the Next.js App Router](https://blog.aakibshah.com.np/redux-toolkit-nextjs-app-router)
    
*   [Migrating a legacy Redux codebase to Redux Toolkit](https://blog.aakibshah.com.np/migrate-legacy-redux-to-redux-toolkit)
