跳到主要内容

Writing Tests

What You'll Learn
  • Recommended practices for testing apps using Redux
  • Examples of test configuration and setup

Guiding Principles

The guiding principles for testing Redux logic closely follow that of React Testing Library:

The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds

Because most of the Redux code you write are functions, and many of them are pure, they are easy to test without mocking. However, you should consider whether each piece of your Redux code needs its own dedicated tests. In the majority of scenarios, the end-user does not know, and does not care whether Redux is used within the application at all. As such, the Redux code can be treated as an implementation detail of the app, without requiring explicit tests for the Redux code in many circumstances.

Our general advice for testing an app using Redux is:

  • Prefer writing integration tests with everything working together. For a React app using Redux, render a <Provider> with a real store instance wrapping the components being tested. Interactions with the page being tested should use real Redux logic, with API calls mocked out so app code doesn't have to change, and assert that the UI is updated appropriately.
  • If needed, use basic unit tests for pure functions such as particularly complex reducers or selectors. However, in many cases, these are just implementation details that are covered by integration tests instead.
  • Do not try to mock selector functions or the React-Redux hooks! Mocking imports from libraries is fragile, and doesn't give you confidence that your actual app code is working.
信息

For background on why we recommend integration-style tests, see:

Setting Up a Test Environment

Test Runners

Redux can be tested with any test runner, since it's just plain JavaScript. One common option is Jest, a widely used test runner that comes with Create-React-App, and is used by the Redux library repos. If you're using Vite to build your project, you may be using Vitest as your test runner.

Typically, your test runner needs to be configured to compile JavaScript/TypeScript syntax. If you're going to be testing UI components, you will likely need to configure the test runner to use JSDOM to provide a mock DOM environment.

The examples in this page will assume you're using Jest, but the same patterns apply no matter what test runner you're using.

See these resources for typical test runner configuration instructions:

UI and Network Testing Tools

The Redux team recommends using React Testing Library (RTL) to test React components that connect to Redux. React Testing Library is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's render function and act from react-dom/tests-utils. (The Testing Library family of tools also includes adapters for many other popular frameworks as well.)

We also recommend using Mock Service Worker (MSW) to mock network requests, as this means your application logic does not need to be changed or mocked when writing tests.

Integration Testing Connected Components and Redux Logic

Our recommendation for testing Redux-connected React components is via integration tests that include everything working together, with assertions aimed at verifying that the app behaves the way you expect when the user interacts with it in a given manner.

Example App Code

Consider the following userSlice slice, store, and App component:

features/users/usersSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
import type { RootState } from '../../app/store'

export const fetchUser = createAsyncThunk('user/fetchUser', async () => {
const response = await userAPI.fetchUser()
return response.data
})

interface UserState {
name: string
status: 'idle' | 'loading' | 'complete'
}

const initialState: UserState = {
name: 'No user',
status: 'idle'
}

const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(fetchUser.pending, (state, action) => {
state.status = 'loading'
})
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'complete'
state.name = action.payload
})
}
})

export const selectUserName = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status

export default userSlice.reducer
app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
features/users/UserDisplay.tsx
import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUserName, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
const dispatch = useAppDispatch()
const userName = useAppSelector(selectUserName)
const userFetchStatus = useAppSelector(selectUserFetchStatus)

return (
<div>
{/* Display the current user name */}
<div>{userName}</div>
{/* On button click, dispatch a thunk action to fetch a user */}
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
{/* At any point if we're fetching a user, display that on the UI */}
{userFetchStatus === 'loading' && <div>Fetching user...</div>}
</div>
)
}

This app involves thunks, reducers and selectors. All of these can be tested by writing an integration test with the following in mind:

  • Upon first loading the app, there should be no user yet - we should see 'No user' on the screen.
  • After clicking the button that says 'Fetch user', we expect it to start fetching the user. We should see 'Fetching user...' displayed on the screen.
  • After some time, the user should be received. We should no longer see 'Fetching user...', but instead should see the expected user's name based on the response from our API.

Writing our tests to focus on the above as a whole, we can avoid mocking as much of the app as possible. We will also have confidence that the critical behavior of our app does what we expect it to when interacted with in the way we expect the user to use the app.

To test the component, we render it into the DOM, and assert that the app responds to interactions in the way we expect the user to use the app.

Setting Up a Reusable Test Render Function

React Testing Library's render function accepts a tree of React elements and renders those components. Just like in a real app, any Redux-connected components will need a React-Redux <Provider> component wrapped around them, with a real Redux store set up and provided.

Additionally, the test code should create a separate Redux store instance for every test, rather than reusing the same store instance and resetting its state. That ensures no values accidentally leak between tests.

Instead of copy-pasting the same store creation and Provider setup in every test, we can use the wrapper option in the render function and export our own customized renderWithProviders function that creates a new Redux store and renders a <Provider>, as explained in React Testing Library's setup docs.

The custom render function should let us:

  • Create a new Redux store instance every time it's called, with an optional preloadedState value that can be used for an initial value
  • Alternately pass in an already-created Redux store instance
  • Pass through additional options to RTL's original render function
  • Automatically wrap the component being tested with a <Provider store={store}>
  • Return the store instance in case the test needs to dispatch more actions or check state

A typical custom render function setup could look like this:

utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

import type { AppStore, RootState } from '../app/store'
import { setupStore } from '../app/store'
// As a basic setup, import your same slice reducers
import userReducer from '../features/users/userSlice'

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
store?: AppStore
}

export function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions

const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)

// Return an object with the store and all of RTL's query functions
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}

In this example, we're directly importing the same slice reducers that the real app uses to create the store. It may be helpful to create a reusable setupStore function that does the actual store creation with the right options and configuration, and use that in the custom render function instead.

app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'

import userReducer from '../features/users/userSlice'

// Create the root reducer separately so we can extract the RootState type
const rootReducer = combineReducers({
user: userReducer
})

export const setupStore = (preloadedState?: Partial<RootState>) => {
return configureStore({
reducer: rootReducer,
preloadedState
})
}

export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']

Then, use setupStore in the test utils file instead of calling configureStore again:

import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { Provider } from 'react-redux'

import { setupStore } from '../app/store'
import type { AppStore, RootState } from '../app/store'

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
store?: AppStore
}

export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return <Provider store={store}>{children}</Provider>
}
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

Writing Integration Tests With Components

The actual test files should use the custom render function to actually render our Redux-connected components. If the code that we're testing involves making network requests, we should also configure MSW to mock the expected requests with appropriate test data.

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { fireEvent, screen } from '@testing-library/react'
// We're using our own custom render function and not RTL's render.
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'

// We use msw to intercept the network request during the test,
// and return the response 'John Smith' after 150ms
// when receiving a get request to the `/api/user` endpoint
export const handlers = [
http.get('/api/user', async () => {
await delay(150)
return HttpResponse.json('John Smith')
})
]

const server = setupServer(...handlers)

// Enable API mocking before tests.
beforeAll(() => server.listen())

// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())

// Disable API mocking after the tests are done.
afterAll(() => server.close())

test('fetches & receives a user after clicking the fetch user button', async () => {
renderWithProviders(<UserDisplay />)

// should show no user initially, and not be fetching a user
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

// after clicking the 'Fetch user' button, it should now show that it is fetching the user
fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.getByText(/no user/i)).toBeInTheDocument()

// after some time, the user should be received
expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
})

In this test, we have completely avoided testing any Redux code directly, treating it as an implementation detail. As a result, we are free to re-factor the implementation, while our tests will continue to pass and avoid false negatives (tests that fail despite the app still behaving how we want it to). We might change our state structure, convert our slice to use RTK-Query, or remove Redux entirely, and our tests will still pass. We have a strong degree of confidence that if we change some code and our tests report a failure, then our app really is broken.

Preparing Initial Test State

Many tests require that certain pieces of state already exist in the Redux store before the component is rendered. With the custom render function, there are a couple different ways you can do that.

One option is to pass a preloadedState argument in to the custom render function:

TodoList.test.tsx
test('Uses preloaded state to render', () => {
const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]

const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
todos: initialTodos
}
})
})

Another option is to create a custom Redux store first and dispatch some actions to build up the desired state, then pass in that specific store instance:

TodoList.test.tsx
test('Sets up initial state state with actions', () => {
const store = setupStore()
store.dispatch(todoAdded('Buy milk'))

const { getByText } = renderWithProviders(<TodoList />, { store })
})

You can also extract store from the object returned by the custom render function, and dispatch more actions later as part of the test.

Unit Testing Individual Functions

While we recommend using integration tests by default, since they exercise all the Redux logic working together, you may sometimes want to write unit tests for individual functions as well.

Reducers

Reducers are pure functions that return the new state after applying the action to the previous state. In the majority of cases, the reducer is an implementation detail that does not need explicit tests. However, if your reducer contains particularly complex logic that you would like the confidence of having unit tests for, reducers can be easily tested.

Because reducers are pure functions, so testing them should be straightforward. Call the reducer with a specific input state and action, and assert that the result state matches expectations.

Example

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export type Todo = {
id: number
text: string
completed: boolean
}

const initialState: Todo[] = [{ text: 'Use Redux', completed: false, id: 0 }]

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action: PayloadAction<string>) {
state.push({
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.payload
})
}
}
})

export const { todoAdded } = todosSlice.actions

export default todosSlice.reducer

can be tested like:

import reducer, { todoAdded, Todo } from './todosSlice'

test('should return the initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual([
{ text: 'Use Redux', completed: false, id: 0 }
])
})

test('should handle a todo being added to an empty list', () => {
const previousState: Todo[] = []

expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
{ text: 'Run the tests', completed: false, id: 0 }
])
})

test('should handle a todo being added to an existing list', () => {
const previousState: Todo[] = [
{ text: 'Run the tests', completed: true, id: 0 }
]

expect(reducer(previousState, todoAdded('Use Redux'))).toEqual([
{ text: 'Run the tests', completed: true, id: 0 },
{ text: 'Use Redux', completed: false, id: 1 }
])
})

Selectors

Selectors are also generally pure functions, and thus can be tested using the same basic approach as reducers: set up an initial value, call the selector function with those inputs, and assert that the result matches the expected output.

However, since most selectors are memoized to remember their last inputs, you may need to watch for cases where a selector is returning a cached value when you expected it to generate a new one depending on where it's being used in the test.

Action Creators & Thunks

In Redux, action creators are functions which return plain objects. Our recommendation is not to write action creators manually, but instead have them generated automatically by createSlice, or created via createAction from @reduxjs/toolkit. As such, you should not feel the need to test action creators by themselves (the Redux Toolkit maintainers have already done that for you!).

The return value of action creators is considered an implementation detail within your application, and when following an integration testing style, do not need explicit tests.

Similarly for thunks using Redux Thunk, our recommendation is not to write them manually, but instead use createAsyncThunk from @reduxjs/toolkit. The thunk handles dispatching the appropriate pending, fulfilled and rejected action types for you based on the lifecycle of the thunk.

We consider thunk behavior to be an implementation detail of the application, and recommend that it be covered by testing the group of components (or whole app) using it, rather than testing the thunk in isolation.

Our recommendation is to mock async requests at the fetch/xhr level using tools like msw, miragejs, jest-fetch-mock, fetch-mock, or similar. By mocking requests at this level, none of the thunk logic has to change in a test - the thunk still tries to make a "real" async request, it just gets intercepted. See the "Integration Test" example for an example of testing a component which internally includes the behavior of a thunk.

信息

If you prefer, or are otherwise required to write unit tests for your action creators or thunks, refer to the tests that Redux Toolkit uses for createAction and createAsyncThunk.

Middleware

Middleware functions wrap behavior of dispatch calls in Redux, so to test this modified behavior we need to mock the behavior of the dispatch call.

Example

First, we'll need a middleware function. This is similar to the real redux-thunk.

const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}

return next(action)
}

We need to create fake getState, dispatch, and next functions. We use jest.fn() to create stubs, but with other test frameworks you would likely use Sinon.

The invoke function runs our middleware in the same way Redux does.

const create = () => {
const store = {
getState: jest.fn(() => ({})),
dispatch: jest.fn()
}
const next = jest.fn()

const invoke = action => thunkMiddleware(store)(next)(action)

return { store, next, invoke }
}

We test that our middleware is calling the getState, dispatch, and next functions at the right time.

test('passes through non-function action', () => {
const { next, invoke } = create()
const action = { type: 'TEST' }
invoke(action)
expect(next).toHaveBeenCalledWith(action)
})

test('calls the function', () => {
const { invoke } = create()
const fn = jest.fn()
invoke(fn)
expect(fn).toHaveBeenCalled()
})

test('passes dispatch and getState', () => {
const { store, invoke } = create()
invoke((dispatch, getState) => {
dispatch('TEST DISPATCH')
getState()
})
expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH')
expect(store.getState).toHaveBeenCalled()
})

In some cases, you will need to modify the create function to use different mock implementations of getState and next.

Further Information

  • React Testing Library: React Testing Library is a very light-weight solution for testing React components. It provides light utility functions on top of react-dom and react-dom/test-utils, in a way that encourages better testing practices. Its primary guiding principle is: "The more your tests resemble the way your software is used, the more confidence they can give you."
  • Blogged Answers: The Evolution of Redux Testing Approaches: Mark Erikson's thoughts on how Redux testing has evolved from 'isolation' to 'integration'.
  • Testing Implementation Details: Blog post by Kent C. Dodds on why he recommends to avoid testing implementation details.