编写测试
- 使用 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 hooks! 模拟库的导入不稳定,而且不能让你确信实际的应用代码在工作。
关于我们为何推荐集成测试风格,详见:
- Kent C Dodds: 测试实现细节:他为何建议避免测试实现细节的思考。
- Mark Erikson: 博客回应:Redux 测试方法演进:Redux 测试如何从“隔离”演进到“集成”的思考。
设置测试环境
测试运行器
Redux 可使用任何测试运行器进行测试,因为它只是普通的 JavaScript。一个越来越常用的选择是 Vitest(Redux 官方库仓库使用),但 Jest 仍广泛使用。
通常,测试运行器需配置以编译 JavaScript/TypeScript 语法。如果你要测试 UI 组件 无浏览器情况下,很可能需要配置测试运行器使用 JSDOM,以提供模拟 DOM 环境。
本文示例假设你使用 Vitest,但无论使用什么测试运行器,模式都类似。
参考以下资源了解测试运行器的典型配置:
- Vitest
- Jest:
UI 与网络测试工具
Redux 团队推荐使用 Vitest Browser Mode 或 React Testing Library (RTL) 来测试连接 Redux 的 React 组件。
React Testing Library 是一个简单且功能完整的 React DOM 测试工具,鼓励良好的测试实践。它使用 ReactDOM 的 render 函数和 react-dom/tests-utils 中的 act。 (Testing Library 家族工具也包括 很多流行框架的适配器。)
Vitest Browser Mode 在真实浏览器中运行集成测试,无需“模拟”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'
// 独立创建根 reducer 方便获得 RootState 和 PreloadedState 类型
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'
// 独立创建根 reducer 方便获得 RootState 和 PreloadedState 类型
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'
// 在应用中替代普通的 `useDispatch` 和 `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
import { useDispatch, useSelector } from 'react-redux'
// 在应用中替代普通的 `useDispatch` 和 `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>
{/* 显示当前用户名 */}
<div>{userName}</div>
{/* 点击按钮时,派发 thunk 动作获取用户 */}
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
{/* 只要正在加载用户,显示加载状态 */}
{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>
{/* 显示当前用户名 */}
<div>{userName}</div>
{/* 点击按钮时,派发 thunk 动作获取用户 */}
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
{/* 只要正在加载用户,显示加载状态 */}
{userFetchStatus === 'loading' && <div>Fetching user...</div>}
</div>
)
}
这个应用涉及 thunk、reducer 和 selector。所有这些都可以通过集成测试进行测试,重点关注:
- 应用初次加载时应无用户,屏幕显示“无用户”。
- 点击“Fetch user”按钮后,开始加载用户,屏幕应显示“Fetching user...”。
- 过一段时间后,接收到用户,应显示 API 返回的用户名,不再显示“Fetching user...”。
编写测试聚焦以上整体流程,可以最大限度避免模拟应用内容。我们也能确认应用交互时能按预期工作。
要测试组件,我们将其 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 实例,以便测试时使用 dispatch 或检查状态
方便起见,我们也设置一个 用户事件实例。
典型自定义渲染函数示例如下:
- 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'
// 该接口扩展了 RTL 渲染的默认选项,并允许指定 preloadedState 和 store。
interface ExtendedRenderOptions
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}
export function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// 如果未传 store,自动创建 store 实例
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)
// 返回 store、user 和 RTL 的所有查询函数
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 = {},
// 如果未传 store,自动创建 store 实例
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
)
// 返回 store、user 和 RTL 的所有查询函数
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'
// 使用自定义渲染函数而非 RTL 的 render
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'
// 利用 msw 拦截测试时的网络请求
// 当收到对 `/api/user` 的 GET 请求时,150ms 后返回 'John Smith'
export const handlers = [
http.get('/api/user', async () => {
await delay(150)
return HttpResponse.json('John Smith')
})
]
const server = setupServer(...handlers)
// 启用请求模拟
beforeAll(() => server.listen())
// 重置运行时请求处理器,避免测试间影响
afterEach(() => server.resetHandlers())
// 测试结束关闭请求模拟
afterAll(() => server.close())
test('点击获取用户按钮后,能成功取回用户', async () => {
const { user } = renderWithProviders(<UserDisplay />)
// 初始应显示无用户,且不处于加载状态
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
// 点击“Fetch 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()
// 过一段时间,接收到用户信息
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'
// 使用自定义渲染函数而非 RTL 的 render
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'
// 利用 msw 拦截测试时的网络请求
// 当收到对 `/api/user` 的 GET 请求时,150ms 后返回 'John Smith'
export const handlers = [
http.get('/api/user', async () => {
await delay(150)
return HttpResponse.json('John Smith')
})
]
const server = setupServer(...handlers)
// 启用请求模拟
beforeAll(() => server.listen())
// 重置运行时请求处理器,避免测试间影响
afterEach(() => server.resetHandlers())
// 测试结束关闭请求模拟
afterAll(() => server.close())
test('点击获取用户按钮后,能成功取回用户', async () => {
const { user } = renderWithProviders(<UserDisplay />)
// 初始应显示无用户,且不处于加载状态
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
// 点击“Fetch 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()
// 过一段时间,接收到用户信息
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 代码,将其视为实现细节。这样就可以自由重构 实现,测试依然通过,不会出现假阴性(应用仍正常但测试失败)。无论我们如何改变状态结构,是否用 RTK-Query,甚至移除 Redux,测试都能正常工作。我们可以有很强的信心:如果测试报告失败,说明我们的应用真的有问题。
准备初始测试状态
很多测试需要在组件渲染前,Redux store 中已有特定状态。通过自定义渲染函数,你可以用下面两种方式做到。
一种是向自定义渲染函数传入 preloadedState:
test('使用预加载状态进行渲染', () => {
const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]
const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
todos: initialTodos
}
})
})
另一种是先创建自定义 Redux store,派发一些动作准备好状态,再传入该 store 实例:
test('通过派发动作设置初始状态', () => {
const store = setupStore()
store.dispatch(todoAdded('Buy milk'))
const { getByText } = renderWithProviders(<TodoList />, { store })
})
你也可以从自定义渲染函数返回的对象中取出 store,测试中后续再派发动作。
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'
// 该接口扩展了 vitest-browser-react 渲染的默认选项,并允许指定 preloadedState 和 store。
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}
export async function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// 自动创建 store 实例(无传入时)
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)
const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// 返回 store 和渲染结果
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 = {},
// 自动创建 store 实例(无传入时)
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
)
const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// 返回 store 和渲染结果
return {
store,
...screen
}
}
为了方便,我们也可以在 setup 文件中把它挂载到 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('点击获取用户按钮后,能成功取回用户', 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)
// 初始应显示无用户,且不处于加载状态
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
// 点击“Fetch user”按钮后,应显示正在加载用户
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()
// 过一段时间,接收到用户信息
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('点击获取用户按钮后,能成功取回用户', 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)
// 初始应显示无用户,且不处于加载状态
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
// 点击“Fetch user”按钮后,应显示正在加载用户
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()
// 过一段时间,接收到用户信息
await expect.element(userNameText).toBeInTheDocument()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
})
单元测试单个函数
虽默认推荐集成测试,因它们测试全部 Redux 逻辑的协作,但你有时也可能想对单个函数编写单元测试。
Reducer
reducer 是纯函数,根据前一个状态和动作返回新的状态。大多数情况下,reducer 是实现细节,无需显式测试。但如 reducer 逻辑特别复杂,想单元测试获取信心,也是可以的。
因 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('应返回初始状态', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual([
{ text: 'Use Redux', completed: false, id: 0 }
])
})
test('应能向空列表添加 todo', () => {
const previousState: Todo[] = []
expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
{ text: 'Run the tests', completed: false, id: 0 }
])
})
test('应能向已有列表添加 todo', () => {
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('应返回初始状态', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual([
{ text: 'Use Redux', completed: false, id: 0 }
])
})
test('应能向空列表添加 todo', () => {
const previousState = []
expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
{ text: 'Run the tests', completed: false, id: 0 }
])
})
test('应能向已有列表添加 todo', () => {
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 }
])
})
Selector
Selector 通常也是纯函数,同样适用 reducer 的测试方法:准备初始值,调用 selector,断言输出符合预期。
但由于大多数 selector 都会记忆上次输入,可能会遇到由于缓存而返回旧值的情况,注意测试中使用场景。
Action Creator 和 Thunk
Redux 中,action creator 是返回普通对象的函数。我们建议不要手写 action creators,而是使用 createSlice 自动生成,或通过 createAction 创建。因此,无需单独测 action creator(Redux Toolkit 维护者已测试过!)。
action creator 返回值被视为应用的实现细节,使用集成测试风格时无需显式测试。
使用 Redux Thunk 的 thunk 也最好通过 createAsyncThunk 创建。该 thunk 会根据生命周期派发 pending、fulfilled 和 rejected 动作类型。
我们认为 thunk 行为是应用的实现细节,推荐通过测试组件(或整个应用)来覆盖,而非单独测试 thunk。
建议在 fetch/xhr 层使用 msw、miragejs、jest-fetch-mock、fetch-mock 等工具模拟异步请求。这样测试时 thunk 仍尝试真实请求,只是被拦截。参阅"编写集成测试"示例查看组件中内含 thunk 行为的测试示例。
如果你更愿意,或者必须为 action creator 或 thunk 写单元测试,可参考 Redux Toolkit 为 createAction 和 createAsyncThunk 写的测试。
Middleware
Middleware 函数包装了 Redux 中的 dispatch 调用行为,因此测试时需模拟 dispatch 的行为。
示例
先写个 middleware 函数,类似真实的 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('非函数动作正常传递', () => {
const { next, invoke } = create()
const action = { type: 'TEST' }
invoke(action)
expect(next).toHaveBeenCalledWith(action)
})
test('函数动作被调用', () => {
const { invoke } = create()
const fn = jest.fn()
invoke(fn)
expect(fn).toHaveBeenCalled()
})
test('传入 dispatch 和 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 组件测试工具,基于 react-dom 和 react-dom/test-utils,鼓励更好的测试实践。核心原则是:“越接近软件的使用方式,测试越可信。”
- 博客回应:Redux 测试方法演进:Mark Erikson 关于 Redux 测试从“隔离”到“集成”演进的看法。
- 测试实现细节:Kent C. Dodds 表达为何建议避免测试实现细节的博客。