本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
编写测试
- 测试使用 Redux 应用的推荐实践
- 测试配置和设置的示例
指导原则
测试 Redux 逻辑的指导原则与 React Testing Library 的理念高度一致:
你的测试越是贴近软件的实际使用方式,它们就越能给你信心。 - Kent C. Dodds
由于你编写的大部分 Redux 代码都是函数(其中许多是纯函数),它们无需模拟即可轻松测试。但你应该评估每个 Redux 代码片段是否需要独立测试。在大多数场景中,最终用户不知道也不关心应用是否使用了 Redux。因此,Redux 代码可视为应用的实现细节,在多数情况下无需专门为 Redux 代码编写显式测试。
我们对使用 Redux 的应用进行测试的通用建议是:
-
优先编写整体协作的集成测试。对于使用 Redux 的 React 应用,应渲染包裹被测组件的真实 store 实例的
<Provider>。与被测页面的交互应使用真实 Redux 逻辑(通过模拟 API 调用避免修改应用代码),并断言 UI 是否正确更新。 -
_必要时_可为纯函数(如特别复杂的 reducer 或 selector)编写基础单元测试。但在多数情况下,这些仅是会被集成测试覆盖的实现细节。
-
切勿模拟 selector 函数或 React-Redux 钩子! 模拟库的导入很脆弱,且无法确保实际应用代码正常工作。
关于我们推荐集成测试的背景,请参阅:
- Kent C Dodds: 测试实现细节:解释为何应避免测试实现细节
- Mark Erikson: 博客解答:Redux 测试方法的演进:Redux 测试如何从"隔离"发展到"集成"
设置测试环境
测试运行器(Test Runners)
Redux 可与任何测试运行器配合使用,因为它只是纯 JavaScript。Vitest(Redux 官方库使用)是日益流行的选择,但 Jest 仍被广泛采用。
通常测试运行器需要配置以编译 JavaScript/TypeScript 语法。若需在无浏览器环境下测试 UI 组件,可能需要配置测试运行器使用 JSDOM 提供模拟 DOM 环境。
本文示例将使用 Vitest 演示,但相同模式适用于任何测试运行器。
测试运行器的典型配置说明请参考:
-
Vitest
-
Jest:
UI 和网络测试工具
Redux 团队推荐使用 Vitest 浏览器模式 或 React Testing Library (RTL) 测试连接 Redux 的 React 组件。
React Testing Library 是简洁完备的 React DOM 测试工具,倡导良好测试实践。它使用 ReactDOM 的 render 函数和 react-dom/test-utils 的 act 方法。(Testing Library 系列工具还包含多种流行框架的适配器)
Vitest 浏览器模式在真实浏览器中运行集成测试,无需"模拟"DOM 环境(支持视觉反馈和回归测试)。使用 React 时还需 vitest-browser-react,它提供类似 RTL 的 render 工具。
我们同时推荐使用 Mock Service Worker (MSW) 模拟网络请求,这意味着编写测试时无需修改或模拟应用逻辑。
-
Vitest 浏览器模式
-
DOM/React Testing Library
-
Mock Service Worker
集成测试:连接组件与 Redux 逻辑
我们建议通过集成测试来验证连接 Redux 的 React 组件,测试应覆盖完整工作流程,通过断言验证应用在用户交互时是否符合预期行为。
应用代码示例
参考以下 userSlice 切片、store 及 App 组件:
- TypeScript
- JavaScript
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
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
export const fetchUser = createAsyncThunk('user/fetchUser', async () => {
const response = await userAPI.fetchUser()
return response.data
})
const initialState = {
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 => state.user.name
export const selectUserFetchStatus = state => state.user.status
export default userSlice.reducer
- TypeScript
- JavaScript
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState and PreloadedState type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState?: PreloadedState) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
export type PreloadedState = Parameters<typeof rootReducer>[0]
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState and PreloadedState type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
- TypeScript
- JavaScript
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>()
import { useDispatch, useSelector } from 'react-redux'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes()
export const useAppSelector = useSelector.withTypes()
- TypeScript
- JavaScript
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>
)
}
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>
)
}
该应用包含 thunk、reducer 和 selector。可通过集成测试完整验证,重点包括:
-
应用初始加载时未获取用户 - 页面应显示"无用户"
-
点击"获取用户"按钮后 - 应显示"正在获取用户..."
-
接口返回后 - "正在获取用户..."提示消失,正确显示用户名
通过整体验证上述流程,可最大限度减少模拟代码,确保核心业务逻辑在真实交互场景中符合预期。
测试时需使用 render 将组件渲染至 DOM,并断言应用在模拟用户操作时的响应是否符合预期。
创建可复用的测试渲染函数
React Testing Library 的 render 函数接受 React 元素树进行渲染。与真实应用相同,所有 Redux 连接组件必须包裹在 React-Redux 的 <Provider> 组件中,并配置真实 Redux store。
每个测试应创建独立的 Redux store 实例而非复用,避免测试间状态泄漏。
与其在每个测试中复制粘贴相同的 store 创建和 Provider 设置,我们可以使用 render 函数中的 wrapper 选项,并导出自定义的 renderWithProviders 函数来创建新的 Redux store 并渲染 <Provider>,如 React Testing Library 的配置文档所述。
该自定义渲染函数需满足:
-
每次调用创建新 Redux store 实例,支持通过
preloadedState设置初始状态 -
或者传入已创建好的 Redux store 实例
-
将额外选项透传给 RTL 原生的
render函数 -
自动用
<Provider store={store}>包裹被测试组件 -
返回 store 实例以便测试中需要派发更多 action 或检查状态
为方便起见,建议同时设置 user 实例。
典型的自定义渲染函数配置示例如下:
- TypeScript
- JavaScript
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { Provider } from 'react-redux'
import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } 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 preloadedState, store.
interface ExtendedRenderOptions
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
preloadedState?: PreloadedState
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, user, and all of RTL's query functions
return {
store,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}
import React from 'react'
import { render } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { Provider } from 'react-redux'
import { setupStore } from '../app/store'
export function renderWithProviders(ui, extendedRenderOptions = {}) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
)
// Return an object with the store, user, and all of RTL's query functions
return {
store,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}
编写组件集成测试
实际测试文件应使用自定义 render 函数渲染 Redux 连接的组件。若测试代码涉及网络请求,还需配置 MSW 用测试数据模拟预期请求。
- TypeScript
- JavaScript
import React from 'react'
import { beforeAll, afterEach, afterAll, test, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { 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 () => {
const { user } = 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
await user.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.getByText(/Fetching 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()
})
import React from 'react'
import { beforeAll, afterEach, afterAll, test, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { 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 () => {
const { user } = 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
await user.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.getByText(/Fetching 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()
})
此测试中完全避免直接测试 Redux 代码,将其视为实现细节。因此可自由重构_实现_逻辑,测试仍能通过且避免假阴性(应用行为符合预期但测试失败)。无论我们改变状态结构、将 slice 转为使用 RTK-Query,还是完全移除 Redux,测试都会通过。当代码变更导致测试失败时,我们能高度确信应用确实_存在缺陷_。
准备初始测试状态
许多测试要求在组件渲染前 Redux store 已存在特定状态。通过自定义渲染函数,可通过以下方式实现:
选项一:向自定义渲染函数传入 preloadedState 参数:
test('Uses preloaded state to render', () => {
const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]
const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
todos: initialTodos
}
})
})
选项二:先创建自定义 Redux store 并派发 action 构建所需状态,再传入该 store 实例:
test('Sets up initial state state with actions', () => {
const store = setupStore()
store.dispatch(todoAdded('Buy milk'))
const { getByText } = renderWithProviders(<TodoList />, { store })
})
也可从自定义渲染函数返回对象中解构 store,在测试中后续派发更多 action。
Vitest 浏览器模式
创建可复用的测试渲染函数
类似 RTL,Vitest 浏览器模式提供 render 函数用于在真实浏览器中渲染组件。但测试 React-Redux 应用时,需确保渲染树中包含 <Provider>。
可创建自定义渲染函数,用 <Provider> 包裹组件并设置 Redux store,类似于前文展示的 RTL 自定义渲染函数。
- TypeScript
- JavaScript
import React, { PropsWithChildren } from 'react'
import { render } from 'vitest-browser-react'
import type { RenderOptions } from 'vitest-browser-react'
import { Provider } from 'react-redux'
import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } from '../app/store'
// This type interface extends the default options for render from vitest-browser-react, as well
// as allows the user to specify other things such as preloadedState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}
export async 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>
)
const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// Return an object with the store, and the result of rendering
return {
store,
...screen
}
}
import React from 'react'
import { render } from 'vitest-browser-react'
import { Provider } from 'react-redux'
import { setupStore } from '../app/store'
export async function renderWithProviders(ui, extendedRenderOptions = {}) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
)
const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// Return an object with the store, and the result of rendering
return {
store,
...screen
}
}
为方便使用,可在设置文件中将其附加到 page:
- TypeScript
- JavaScript
import { renderWithProviders } from './utils/test-utils'
import { page } from 'vitest/browser'
page.extend({ renderWithProviders })
declare module 'vitest/browser' {
interface BrowserPage {
renderWithProviders: typeof renderWithProviders
}
}
import { renderWithProviders } from './utils/test-utils'
import { page } from 'vitest/browser'
page.extend({ renderWithProviders })
此后即可在测试中类似 RTL 的方式使用:
- TypeScript
- JavaScript
import React from 'react'
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
import UserDisplay from '../UserDisplay'
test('fetches & receives a user after clicking the fetch user button', async () => {
const { store, ...screen } = await page.renderWithProviders(<UserDisplay />)
const noUserText = screen.getByText(/no user/i)
const fetchingUserText = screen.getByText(/Fetching user\.\.\./i)
const userNameText = screen.getByText(/John Smith/i)
// should show no user initially, and not be fetching a user
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()
// after some time, the user should be received
await expect.element(userNameText).toBeInTheDocument()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
})
import React from 'react'
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
import UserDisplay from '../UserDisplay'
test('fetches & receives a user after clicking the fetch user button', async () => {
const { store, ...screen } = await page.renderWithProviders(<UserDisplay />)
const noUserText = screen.getByText(/no user/i)
const fetchingUserText = screen.getByText(/Fetching user\.\.\./i)
const userNameText = screen.getByText(/John Smith/i)
// should show no user initially, and not be fetching a user
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()
// after some time, the user should be received
await expect.element(userNameText).toBeInTheDocument()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
})
单元测试独立函数
虽然默认推荐使用集成测试(因其能检验 Redux 逻辑整体协作),但有时仍需为独立函数编写单元测试。
归约器(Reducers)
Reducer 是纯函数,接收 action 和旧状态返回新状态。多数情况下 reducer 属于无需单独测试的实现细节。但若包含特别复杂的逻辑且需单元测试保障,可轻松测试。
由于 reducer 是纯函数,测试应简单直接:传入特定 state 和 action,断言返回状态是否符合预期。
示例
- TypeScript
- JavaScript
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
import { createSlice } from '@reduxjs/toolkit'
const initialState = [{ text: 'Use Redux', completed: false, id: 0 }]
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
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
可按如下方式测试:
- TypeScript
- JavaScript
import { test, expect } from 'vitest'
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 }
])
})
import { test, expect } from 'vitest'
import reducer, { todoAdded } 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 = []
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 = [{ 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)
Selector 通常也是纯函数,可采用与 reducer 相同的基本测试方法:设置初始值,用这些输入调用 selector 函数,断言输出符合预期。
但需注意多数 selector 具有记忆功能,测试中可能出现 selector 返回缓存值而预期其根据使用场景生成新值的情况。
Action 创建器 & Thunk
在Redux中,action创建函数是返回普通对象的函数。我们建议不要手动编写action创建函数,而是通过@reduxjs/toolkit中的createSlice自动生成,或使用createAction创建。因此,您无需单独测试action创建函数(Redux Toolkit维护者已为您完成了这项工作!)。
action创建函数的返回值被视为应用程序的实现细节,当采用集成测试方式时,不需要进行显式测试。
同样,对于使用Redux Thunk的thunk函数,我们建议不要手动编写,而是使用@reduxjs/toolkit中的createAsyncThunk。该thunk函数会根据其生命周期自动派发相应的pending、fulfilled和rejected action类型。
我们认为thunk函数的行为是应用程序的实现细节,建议通过测试使用它的组件组(或整个应用)来覆盖,而不是单独测试thunk函数。
我们建议使用msw、miragejs、jest-fetch-mock、fetch-mock等工具在fetch/xhr层级模拟异步请求。通过在此层级模拟请求,测试中无需更改任何thunk逻辑——thunk函数仍会尝试发起"真实"的异步请求,只是被拦截了。有关测试内部包含thunk行为的组件示例,请参阅"集成测试"示例。
如果您倾向于或需要为action创建函数或thunk函数编写单元测试,请参考Redux Toolkit为createAction和createAsyncThunk编写的测试用例。
中间件(Middleware)
中间件函数封装了Redux中dispatch调用的行为,因此要测试这种修改后的行为,我们需要模拟dispatch调用的行为。
示例
首先需要创建中间件函数,这与真实的redux-thunk类似。
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
我们需要创建模拟的getState、dispatch和next函数。使用jest.fn()创建桩函数,在其他测试框架中可以使用Sinon。
invoke函数会以与Redux相同的方式运行我们的中间件。
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 }
}
我们测试中间件是否在正确的时机调用了getState、dispatch和next函数。
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()
})
某些情况下,您需要修改create函数以使用不同的getState和next模拟实现。
扩展阅读
-
React Testing Library:React Testing Library是轻量级的React组件测试解决方案。它在react-dom和react-dom/test-utils基础上提供实用函数,鼓励更好的测试实践。其核心原则是:"您的测试越接近软件被使用的方式,它们就能给您越多的信心。"
-
博客解答:Redux 测试方法的演进:Mark Erikson 关于 Redux 测试如何从"隔离测试"演进到"集成测试"的思考。
-
测试实现细节:Kent C. Dodds 的博客文章,阐述为何他建议避免测试实现细节。