React 基础体系 · 第 14/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 测试体系:Testing Library、Vitest、用户行为和端到端测试
React 测试的目标不是证明某个函数“被调用过”,而是验证用户、浏览器、React 渲染过程和服务端之间的关键契约仍然成立。一个可靠的测试体系通常同时覆盖三类问题:
- 组件是否按可访问的用户界面呈现,并正确响应交互;
- 客户端状态、异步请求和错误状态是否按预期变化;
- 从真实浏览器进入应用,到路由、资源、服务端和数据库的完整链路是否可用。
Testing Library、Vitest 和端到端测试分别解决不同层次的问题:
- Testing Library 提供接近用户使用方式的 DOM 测试工具;
- Vitest 提供测试运行器、断言、Mock、Fake Timer 和覆盖率能力;
- 用户行为测试 是一种测试思想:通过点击、输入、提交、等待界面变化来验证行为,而不是直接修改组件内部状态;
- 端到端测试 在真实或接近真实的浏览器和应用环境中验证完整系统。
它们不是互相替代的工具。Testing Library 通常运行在 Vitest 中,用户行为测试可以发生在组件测试或端到端测试中,而端到端测试通常还需要 Playwright 这类浏览器自动化工具。
一、先定义测试对象:从实现细节到外部契约
1. 用户可观察行为是什么
对一个表单而言,用户可观察的行为可以描述为:
- 用户看到邮箱输入框和提交按钮;
- 用户输入非法邮箱;
- 用户提交表单;
- 页面显示校验错误;
- 用户修正邮箱;
- 页面发起请求;
- 请求期间按钮不可重复提交;
- 请求成功后显示成功信息;
- 请求失败后显示可理解的错误;
- 网络请求结束后恢复可交互状态。
测试应该围绕这些行为组织,而不是围绕实现细节组织。例如:
expect(component.state.email).toBe('a@example.com')
通常不是好的组件测试。用户看不到 component.state,React 也没有对外保证组件内部状态的结构。
更接近契约的测试是:
await user.type(screen.getByRole('textbox', { name: '邮箱' }), 'a@example.com')
expect(screen.getByRole('textbox', { name: '邮箱' })).toHaveValue('a@example.com')
这里验证的是浏览器中真实存在的输入框及其可见值。
2. 可观察性边界
一个测试可以观察到的内容越接近真实用户,通常越能抵抗内部重构。但观察范围并不是越远越好。
可以把测试目标分成三层:
| 层次 | 主要观察对象 | 适合发现的问题 |
|---|---|---|
| 纯函数测试 | 输入和返回值 | 校验算法、格式化、转换逻辑 |
| 组件/集成测试 | DOM、可访问名称、用户交互、请求结果 | 状态流转、表单行为、错误显示 |
| 端到端测试 | 浏览器中的完整页面和真实系统链路 | 路由、构建、服务端、鉴权、数据库、部署配置 |
例如,邮箱校验规则可以用纯函数测试;表单提交、禁用按钮和错误信息适合 Testing Library;登录后跳转和服务端会话则更适合端到端测试。
这不是绝对的测试金字塔规则。关键判断标准是:故障发生在哪个边界,测试就应尽量在该边界验证。
二、Vitest:测试运行器、断言和隔离环境
1. Vitest 负责什么
Vitest 是测试运行器。它负责:
- 发现和执行测试文件;
- 提供
describe、it、test、expect等 API; - 提供 Mock、Spy 和 Fake Timer;
- 管理测试生命周期;
- 生成覆盖率报告;
- 通过不同环境运行 Node 或 DOM 测试。
Vitest 本身不是 React 测试库。它不知道怎样渲染 React,也不知道什么叫“可访问的按钮”。这些能力由 @testing-library/react 和 DOM 环境提供。
一个最小的纯函数测试如下:
// src/validation.ts
export function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
// src/validation.test.ts
import { describe, expect, it } from 'vitest'
import { isValidEmail } from './validation'
describe('isValidEmail', () => {
it('接受常见邮箱地址', () => {
expect(isValidEmail('alice@example.com')).toBe(true)
})
it('拒绝缺少域名的地址', () => {
expect(isValidEmail('alice@')).toBe(false)
})
it('拒绝包含空格的地址', () => {
expect(isValidEmail('alice @example.com')).toBe(false)
})
})
这里测试的契约是:
当且仅当字符串 x 符合当前定义的邮箱格式。这个正则并不等价于完整 RFC 邮箱语法,而是一个产品级输入校验规则。测试应明确验证这条业务规则,而不能暗示它实现了完整的邮箱标准。
2. 安装和配置
一个常见的 React + TypeScript 项目可以安装:
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
如果项目使用 Vite,可以使用以下配置:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
restoreMocks: true,
},
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
})
各项配置的作用不同:
environment: 'jsdom'提供window、document和 DOM API;setupFiles在测试文件执行前加载断言扩展和清理逻辑;@testing-library/jest-dom/vitest提供toBeVisible()、toBeDisabled()、toHaveAccessibleName()等 DOM 断言;cleanup()卸载上一个测试渲染的 React 树,避免 DOM 和副作用泄漏;restoreMocks: true在测试后恢复 Spy 和 Mock 的原始实现。
jsdom 不是完整浏览器。它通常没有真实布局、绘制、网络栈、Web Worker、浏览器导航和所有浏览器 API。因此:
expect(element).toBeInTheDocument()
可以在 jsdom 中验证,但真实 CSS 布局、跨域 Cookie、浏览器历史行为则应使用端到端测试。
package.json 可以加入:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
运行:
npm test
预期结果类似:
✓ src/validation.test.ts (3 tests)
Test Files 1 passed
Tests 3 passed
具体输出格式会随 Vitest 版本和 Reporter 配置变化,不能把某个输出文本作为业务契约。
三、Testing Library:验证用户能看到和操作的界面
1. Testing Library 的核心模型
React Testing Library 的主要流程是:
render(<Component />)
screen.getByRole(...)
await user.click(...)
expect(screen.getByText(...)).toBeVisible()
它将 React 组件渲染到 DOM 中,再通过 DOM 查询和用户交互验证结果。
这与直接调用组件方法有根本区别。React 函数组件没有稳定的公开实例 API:
// 不推荐:测试实现细节
component.setEmail('alice@example.com')
component.submit()
用户不能调用 setEmail,因此这种测试即使通过,也不能说明真实交互成立。
2. 查询优先级
常用查询包括:
screen.getByRole('button', { name: '提交' })
screen.getByLabelText('邮箱')
screen.getByText('提交成功')
screen.getByTestId('complex-widget')
推荐优先使用:
getByRole:按可访问角色和名称查找;getByLabelText:按表单标签查找;getByPlaceholderText:只有没有合适标签时才考虑;getByText:查找用户可见文本;getByTestId:没有合适用户语义时的最后选择。
getByRole('button', { name: '提交' }) 中的 name 是可访问名称,可能来自:
<button>提交</button>
也可能来自:
<button aria-label="关闭对话框">
<CloseIcon />
</button>
如果测试无法通过 getByRole 找到按钮,问题可能不在测试,而在组件缺少正确的可访问语义。
三个查询族的区别必须明确:
getBy...:找不到时立即抛错,适合预期当前已经存在的元素;queryBy...:找不到时返回null,适合断言元素不存在;findBy...:异步等待元素出现,适合请求或状态更新后的界面。
错误示例:
expect(screen.getByText('提交成功')).not.toBeInTheDocument()
如果成功信息尚未出现,getByText 会先抛错,根本不会执行 not.toBeInTheDocument()。正确写法是:
expect(screen.queryByText('提交成功')).not.toBeInTheDocument()
异步出现则使用:
expect(await screen.findByText('提交成功')).toBeVisible()
3. fireEvent 和 userEvent
fireEvent 直接派发单个 DOM 事件:
fireEvent.change(input, { target: { value: 'a@example.com' } })
它有时适合验证低层事件边界,但一个真实用户输入字符通常会触发更多事件和浏览器行为。
userEvent 模拟更接近用户的交互序列:
const user = userEvent.setup()
await user.type(input, 'a@example.com')
await user.click(button)
userEvent 的操作是异步的,因为它会模拟逐步输入、焦点变化、键盘事件和点击过程。没有 await 可能造成断言早于状态更新执行:
// 不可靠
user.click(button)
expect(screen.getByText('已提交')).toBeVisible()
应写为:
await user.click(button)
expect(await screen.findByText('已提交')).toBeVisible()
这里的原则不是“所有测试都必须使用某个 API”,而是:当测试意图是模拟用户操作时,优先使用 userEvent;当测试意图是验证某个底层事件处理器时,才考虑 fireEvent。
四、一个完整的 React 表单行为测试
下面实现一个客户端表单。它覆盖受控组件、同步校验、异步提交、重复提交保护和服务端错误。
// src/NewsletterForm.tsx
import { FormEvent, useState } from 'react'
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
export function NewsletterForm() {
const [email, setEmail] = useState('')
const [fieldError, setFieldError] = useState('')
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [serverError, setServerError] = useState('')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!isValidEmail(email)) {
setFieldError('请输入有效的邮箱地址')
return
}
setFieldError('')
setServerError('')
setSubmitState('submitting')
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!response.ok) {
throw new Error('订阅请求失败')
}
setSubmitState('success')
} catch {
setSubmitState('error')
setServerError('暂时无法完成订阅,请稍后重试')
}
}
const isSubmitting = submitState === 'submitting'
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
aria-invalid={fieldError ? 'true' : 'false'}
aria-describedby={fieldError ? 'email-error' : undefined}
/>
{fieldError && (
<p id="email-error" role="alert">
{fieldError}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中…' : '订阅'}
</button>
{submitState === 'success' && (
<p role="status">订阅成功</p>
)}
{submitState === 'error' && (
<p role="alert">{serverError}</p>
)}
</form>
)
}
这个组件的状态机可以写成:
idle
├─ 输入非法并提交 → idle + fieldError
├─ 输入合法并提交 → submitting
│ ├─ 请求成功 → success
│ └─ 请求失败 → error
└─ 输入变化 → idle
其中 submitting 是一个重要中间状态。若只测试最终成功或失败,而不测试中间状态,就可能遗漏重复提交问题。
1. 测试初始界面和本地校验
// src/NewsletterForm.test.tsx
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NewsletterForm } from './NewsletterForm'
describe('NewsletterForm', () => {
it('显示邮箱输入框和可提交按钮', () => {
render(<NewsletterForm />)
expect(
screen.getByRole('textbox', { name: '邮箱' }),
).toBeVisible()
expect(
screen.getByRole('button', { name: '订阅' }),
).toBeEnabled()
})
it('提交非法邮箱时显示校验错误且不发起请求', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch')
render(<NewsletterForm />)
await user.type(
screen.getByRole('textbox', { name: '邮箱' }),
'invalid',
)
await user.click(screen.getByRole('button', { name: '订阅' }))
expect(
screen.getByRole('alert'),
).toHaveTextContent('请输入有效的邮箱地址')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
})
这项测试证明了两个因果关系:
- 非法输入导致字段错误;
- 字段错误发生在客户端,因此不会调用服务端接口。
如果产品要求浏览器原生校验,还可以测试 type="email" 和 noValidate 的取舍。但当前组件显式设置了 noValidate,意味着校验由 React 逻辑负责。测试应与真实产品决策一致,不能一边关闭原生校验,一边假设浏览器会显示原生提示。
2. 测试成功提交和提交中状态
it('合法邮箱提交成功后显示成功信息', async () => {
const user = userEvent.setup()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
render(<NewsletterForm />)
await user.type(
screen.getByRole('textbox', { name: '邮箱' }),
'alice@example.com',
)
await user.click(screen.getByRole('button', { name: '订阅' }))
expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
'/api/newsletter',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'alice@example.com' }),
}),
)
fetchSpy.mockRestore()
})
fetchSpy 只验证接口契约的一部分:
- URL;
- HTTP 方法;
- 请求体;
- 调用次数。
不应在组件测试中重新测试 fetch 的全部实现,也不应要求测试知道组件内部使用了哪个函数名。请求契约属于组件与 API 之间的边界。
为了明确测试 submitting 状态,可以让 Promise 暂不结束:
it('请求进行中禁用按钮并显示提交中', async () => {
const user = userEvent.setup()
let resolveRequest!: (response: Response) => void
const pendingRequest = new Promise<Response>((resolve) => {
resolveRequest = resolve
})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockReturnValue(pendingRequest)
render(<NewsletterForm />)
await user.type(
screen.getByRole('textbox', { name: '邮箱' }),
'alice@example.com',
)
await user.click(screen.getByRole('button', { name: '订阅' }))
expect(
screen.getByRole('button', { name: '提交中…' }),
).toBeDisabled()
resolveRequest(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
)
expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')
fetchSpy.mockRestore()
})
执行顺序是:
fetch返回尚未完成的 Promise;handleSubmit设置submitState = 'submitting';- React 重新渲染,按钮变为禁用状态;
- 测试手动完成 Promise;
response.ok为真,状态变为success;- 测试等待成功信息出现。
如果没有第 3 步,用户可能在网络请求期间连续点击按钮,产生多个请求。
3. 测试服务端失败和网络失败
it('服务端返回错误时显示可重试的错误信息', async () => {
const user = userEvent.setup()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ message: 'conflict' }), {
status: 409,
}),
)
render(<NewsletterForm />)
await user.type(
screen.getByRole('textbox', { name: '邮箱' }),
'alice@example.com',
)
await user.click(screen.getByRole('button', { name: '订阅' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'暂时无法完成订阅,请稍后重试',
)
fetchSpy.mockRestore()
})
it('网络异常时显示错误信息', async () => {
const user = userEvent.setup()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new TypeError('Failed to fetch'))
render(<NewsletterForm />)
await user.type(
screen.getByRole('textbox', { name: '邮箱' }),
'alice@example.com',
)
await user.click(screen.getByRole('button', { name: '订阅' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'暂时无法完成订阅,请稍后重试',
)
fetchSpy.mockRestore()
})
fetch 对 HTTP 4xx/5xx 默认不会自动 Reject。只有网络层失败时,它通常才会 Reject。因此组件必须显式检查:
if (!response.ok) {
throw new Error(...)
}
如果遗漏这一步,服务端返回 500 仍可能被当作成功处理。这个测试不是测试实现细节,而是在验证 HTTP 错误是否进入正确的业务状态。
五、异步测试:等待正确的条件,而不是等待固定时间
1. 为什么不能随意使用 setTimeout
以下写法不稳定:
await new Promise((resolve) => setTimeout(resolve, 100))
expect(screen.getByText('订阅成功')).toBeVisible()
原因是:
- 100 毫秒不代表请求和 React 更新一定完成;
- CI 机器可能更慢;
- 请求可能立即完成,测试却无意义地等待;
- 固定时间无法表达“等待什么”。
正确做法是等待可观察结果:
expect(await screen.findByRole('status')).toHaveTextContent('订阅成功')
findBy... 通常等价于对 getBy... 进行异步重试,直到元素出现或超时。它表达的是测试真正需要的条件:成功状态已经呈现。
如果要等待某个元素消失:
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: '提交中…' }),
)
如果等待的是某个断言最终成立:
await waitFor(() => {
expect(mockLogger).toHaveBeenCalledWith(
expect.objectContaining({ level: 'error' }),
)
})
但不要把 waitFor 当作万能包装器。若断言本身不需要异步重试,直接断言更清楚;若目标是元素出现或消失,应优先使用对应的 findBy 或 waitForElementToBeRemoved。
2. Fake Timer 的边界
Vitest 可以控制定时器:
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced search', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('在停止输入后触发搜索', async () => {
// 需要根据 userEvent 版本配置 advanceTimers,
// 否则 userEvent 自身的定时器可能无法推进。
})
})
Fake Timer 适合测试防抖、轮询和延迟提示,但会改变时间 API。Testing Library 的 userEvent 也可能使用定时器,因此如果没有正确配置,测试可能挂起。
对于网络请求,不应通过“快进 3 秒”模拟服务端完成。应该控制 Promise 或使用 MSW 模拟 HTTP 响应,因为网络完成和本地定时器不是同一件事。
六、Mock 的层次:不要让测试替代真实系统
1. 函数级 Mock
函数级 Mock 适合验证一个组件如何调用依赖:
const spy = vi.spyOn(module, 'sendRequest')
spy.mockResolvedValue({ ok: true })
优点是精确、快速;缺点是测试可能过度依赖模块结构。如果实现从 sendRequest 改为直接调用 fetch,测试会随实现变化。
2. HTTP 层 Mock
更接近真实应用的做法是在 HTTP 层拦截请求。MSW 可以让组件继续调用真实的 fetch,测试只替换请求响应。
示例:
// src/test/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer(
http.post('/api/newsletter', async () => {
return HttpResponse.json({ ok: true }, { status: 200 })
}),
)
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
cleanup()
server.resetHandlers()
})
afterAll(() => server.close())
在某个测试中覆盖响应:
import { http, HttpResponse } from 'msw'
import { server } from './test/server'
it('接口冲突时显示错误', async () => {
server.use(
http.post('/api/newsletter', () => {
return HttpResponse.json(
{ message: 'already subscribed' },
{ status: 409 },
)
}),
)
// 执行用户输入和提交……
})
onUnhandledRequest: 'error' 很重要。它能把测试中未声明的请求直接暴露出来,避免组件偷偷访问了错误 URL 却仍然“通过”。
MSW 测试的是 HTTP 契约,而不是具体使用 fetch、Axios 还是其他客户端库。它通常比直接 Spy fetch 更接近真实运行路径,但仍然没有验证真实服务端实现。
3. 什么时候不能 Mock
如果测试目标是:
- 服务端实际鉴权;
- 数据库事务;
- 服务端渲染;
- Cookie 和重定向;
- 真实路由;
- 生产构建后的静态资源;
- 反向代理和环境变量;
就不能只依赖组件级 Mock。这些问题应由服务端集成测试或端到端测试覆盖。
七、React 19 与客户端/服务端边界
1. 客户端组件和服务端组件不是同一种测试对象
React 19 支持服务端组件相关能力,但具体边界取决于所用框架和构建工具。React 本身提供组件模型;路由、服务器渲染、服务端函数、数据缓存等能力往往由框架实现。
典型的客户端组件包含:
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount((value) => value + 1)}>
当前计数:{count}
</button>
)
}
它可以在 jsdom 中渲染并通过用户点击测试:
it('点击后更新计数', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: /当前计数:0/ }))
expect(
screen.getByRole('button', { name: /当前计数:1/ }),
).toBeVisible()
})
服务端组件可能不能直接在普通 jsdom 测试中按客户端组件方式渲染,原因包括:
- 它可能依赖框架提供的服务端渲染管线;
- 它不能使用客户端事件处理器;
- 它可能在服务端读取数据库或调用服务端专用 API;
- 它的异步解析和客户端水合由框架控制。
因此边界应这样分配:
服务端纯逻辑/数据访问
↓
服务端集成测试,验证权限、查询、错误映射
↓
框架渲染和路由
↓
端到端测试,验证浏览器看到的完整结果
↓
客户端交互组件
↓
Testing Library + Vitest
不要为了让测试通过而把服务端代码伪装成客户端代码,也不要在客户端测试中复刻框架的服务端渲染实现。
2. React 19 表单能力的测试边界
React 19 引入了与表单 Action、useActionState、useFormStatus 等相关的能力,但这些 API 的使用方式还受框架、服务端函数和表单提交模式影响。测试时要先确定实际运行模型:
- 这是普通客户端
onSubmit; - 这是 React 表单 Action;
- 这是框架提供的服务端函数;
- 还是浏览器原生导航提交。
对于普通客户端表单,Testing Library 可以直接验证输入、提交和状态。对于依赖服务端函数的表单,应在框架支持的环境中验证服务端边界,并使用端到端测试确认浏览器提交和返回结果。
测试不能只验证“按钮文字变了”,还应验证状态和可交互性:
expect(screen.getByRole('button')).toBeDisabled()
expect(screen.getByRole('status')).toHaveTextContent('已完成')
如果表单使用 aria-live 或 role="status",测试同时验证了辅助技术能够感知状态变化。
八、错误边界、异常和日志:测试用户结果与诊断结果
1. 错误边界处理什么错误
React 错误边界主要处理渲染期间、生命周期和部分事件相关的子树错误。它不能自动捕获所有异步 Promise 拒绝,也不能替代 try/catch。
一个简单错误边界如下:
// src/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react'
type Props = {
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, info: ErrorInfo) => void
}
type State = {
hasError: boolean
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info)
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p role="alert">页面暂时无法显示</p>
}
return this.props.children
}
}
测试用户看到的降级界面:
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ErrorBoundary } from './ErrorBoundary'
function BrokenComponent(): JSX.Element {
throw new Error('render failed')
}
it('渲染错误时显示降级界面并记录诊断信息', () => {
const onError = vi.fn()
render(
<ErrorBoundary onError={onError}>
<BrokenComponent />
</ErrorBoundary>,
)
expect(screen.getByRole('alert')).toHaveTextContent(
'页面暂时无法显示',
)
expect(onError).toHaveBeenCalledTimes(1)
})
测试环境通常会在控制台输出 React 错误。可以在测试范围内临时 Spy console.error,但必须恢复,否则会掩盖其他测试真正的错误。
2. 错误边界不是异步请求错误处理器
以下错误发生在 fetch Promise 中:
fetch('/api/data').then(() => {
throw new Error('request callback failed')
})
它不会因为位于错误边界子树中,就自动被错误边界捕获。应使用组件自己的 try/catch、错误状态,或者由数据请求库提供的错误状态。
因此测试应区分:
- 渲染崩溃:错误边界测试;
- 请求失败:请求状态测试;
- 事件处理器异常:事件处理逻辑测试或全局错误处理测试;
- 未处理 Promise 拒绝:运行时监控和专门的异步错误测试。
3. 日志断言不能替代用户结果断言
不应只写:
expect(logger.error).toHaveBeenCalled()
还应验证用户结果:
expect(screen.getByRole('alert')).toHaveTextContent(
'页面暂时无法显示',
)
日志是诊断契约,界面是用户契约。两者都重要,但不能互相替代。
九、端到端测试:在真实浏览器中验证完整链路
1. 端到端测试的定义
端到端测试(End-to-End Test,E2E)从用户入口开始,在浏览器中执行完整流程。例如:
- 启动应用;
- 访问
/subscribe; - 填写邮箱;
- 点击订阅;
- 浏览器发起真实 HTTP 请求;
- 服务端校验并写入测试数据库;
- 页面显示成功;
- 刷新页面后仍符合预期。
组件测试无法验证所有这些边界,因为它通常不会启动真实应用服务器,也不会执行真实路由、Cookie 和数据库逻辑。
Playwright 是常见的 E2E 工具。安装:
npm install -D @playwright/test
npx playwright install
示例配置:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
webServer 会先启动开发服务器,测试等待 url 可访问后再执行。生产 CI 更适合使用确定的构建产物和启动命令,以减少开发服务器与生产行为不一致的问题。
2. 一个完整 E2E 示例
// e2e/newsletter.spec.ts
import { test, expect } from '@playwright/test'
test('用户可以成功订阅邮件', async ({ page }) => {
await page.goto('/subscribe')
await page.getByRole('textbox', { name: '邮箱' })
.fill('alice@example.com')
await page.getByRole('button', { name: '订阅' }).click()
await expect(
page.getByRole('status'),
).toHaveText('订阅成功')
})
执行:
npx playwright test
失败时可以查看 Trace:
npx playwright show-trace test-results/**/trace.zip
Trace 通常包含操作步骤、截图、网络和控制台信息,适合诊断“本地通过、CI 失败”这类问题。trace: 'retain-on-failure' 只保留失败轨迹,减少存储量,但具体保留策略可按 CI 成本和诊断要求调整。
3. E2E 中也应使用用户语义
以下定位方式很脆弱:
await page.locator('.form-wrapper > button:nth-child(2)').click()
它依赖 DOM 层级和 CSS 结构。改版布局后,即使用户行为没有变化,测试也会失败。
更稳定的是:
await page.getByRole('button', { name: '订阅' }).click()
但“稳定”不等于永远不变。如果按钮名称是产品契约的一部分,名称改变本来就应触发测试失败。
4. E2E 的数据隔离
E2E 测试最常见的非业务失败原因是数据互相污染。例如测试 A 创建了一个邮箱,测试 B 又使用同一个邮箱,结果 B 失败。
可选隔离策略包括:
- 每个测试使用唯一数据;
- 每个测试使用独立用户;
- 测试前重置数据库;
- 使用数据库事务并在测试后回滚;
- 为 CI 每个并行 Worker 分配独立数据库或命名空间。
唯一邮箱可以由测试生成:
const email = `e2e-${crypto.randomUUID()}@example.com`
如果服务端有唯一约束,测试还应明确预期:同一邮箱重复订阅是成功幂等、返回冲突,还是更新已有记录。E2E 不应依赖“数据库现在恰好是空的”。
十、组件测试和端到端测试如何分工
以“登录成功后进入账户页”为例:
组件测试适合验证
- 输入框是否有正确标签;
- 空密码是否显示校验错误;
- 请求中按钮是否禁用;
- 401 响应是否显示登录失败;
- 成功状态是否显示正确提示;
- 错误信息是否具有
role="alert"。
端到端测试适合验证
/login路由是否能打开;- 静态资源是否加载;
- 浏览器是否发送正确 Cookie;
- 服务端是否验证密码;
- 登录成功是否跳转
/account; - 刷新账户页后会话是否仍然存在;
- 未登录访问
/account是否重定向到/login。
不要用十几个 Mock 的组件测试代替一次完整的会话 E2E,也不要用几十个慢速 E2E 测试覆盖每一种输入校验分支。测试层次应与故障边界匹配。
十一、常见失败方式及诊断路径
1. 测试通过,但实际按钮不可访问
原因可能是:
<div onClick={submit}>提交</div>
它看起来像按钮,但没有按钮语义、键盘行为和默认表单行为。
更好的实现是:
<button type="submit">提交</button>
测试:
screen.getByRole('button', { name: '提交' })
如果查询失败,应先检查产品代码是否违反了可访问性契约,而不是立即改成 getByTestId。
2. 异步断言偶发失败
典型原因:
- 忘记
await user.click(); - 使用
getBy...查询尚未出现的元素; - 用固定
setTimeout代替条件等待; - Promise 没有被正确 Resolve 或 Reject;
- Mock 没有在测试后恢复;
- 测试间共享了服务端 Mock 状态。
诊断顺序可以是:
- 确认交互调用是否带
await; - 确认查询类型是否为
findBy...; - 确认请求是否真的发出;
- 确认 Mock 响应状态与组件处理逻辑一致;
- 确认测试后清理 DOM、Mock 和请求处理器;
- 检查是否存在未等待的 Promise。
3. React 的 act 警告
act 的意义是让 React 在断言前完成由测试操作引起的更新。Testing Library 通常会在 render 和 userEvent 中处理大部分 act 边界。
出现警告时,不要简单地全局关闭。先检查:
user.click(button) // 忘记 await
应改成:
await user.click(button)
还要检查组件是否在测试结束后仍有异步更新,例如未清理的定时器、订阅或请求回调。这类更新可能意味着生产代码也存在生命周期问题。
4. Strict Mode 导致副作用执行两次
开发模式下的 Strict Mode 可能故意重复某些生命周期路径,以帮助发现不安全副作用。测试环境是否启用 Strict Mode 取决于渲染方式和项目配置。
如果一个 Effect 在重复执行时发送两次请求,不能先把测试包裹层删除来“修复”失败。应先判断副作用是否幂等、是否缺少清理、是否依赖错误的初始化假设。
测试调用次数时,要明确测试目标:
- 如果目标是“用户提交一次只产生一次业务请求”,应控制组件行为并断言一次;
- 如果目标是 Effect 初始化,则要明确测试是否在 Strict Mode 下运行;
- 如果请求库本身会重试,则调用次数可能不等于用户操作次数。
5. 测试共享可变变量
以下写法容易相互污染:
let response = { status: 200 }
it('成功', () => {
response.status = 200
})
it('失败', () => {
response.status = 500
})
测试顺序或并行执行改变后,结果可能不稳定。每个测试应创建自己的数据,或在生命周期中显式重置。Vitest 的 Mock 恢复只能恢复函数,不会自动恢复任意业务对象。
十二、覆盖率的正确解释
覆盖率通常包括:
- 行覆盖率;
- 分支覆盖率;
- 函数覆盖率;
- 语句覆盖率。
分支覆盖率可以粗略表示:
例如:
if (response.ok) {
showSuccess()
} else {
showError()
}
至少需要成功和失败两条路径,才能覆盖这个分支。但 100% 覆盖率不代表功能正确:
const title = '订阅成功'
即使所有行都执行过,测试也可能没有验证标题是否真的出现在用户界面中。
反过来,某些代码很难通过单元测试覆盖:
- 浏览器特有 API;
- 真实 CSS 布局;
- 服务端运行时;
- 网络代理;
- 生产构建产物。
因此覆盖率应作为风险信号,而不是质量证明。更有价值的检查是:
- 关键业务分支是否被覆盖;
- 失败路径是否被覆盖;
- 是否覆盖重复提交和取消操作;
- 是否覆盖权限和数据隔离;
- 是否存在大量只覆盖实现细节的测试。
运行覆盖率通常需要额外安装 Provider,具体 Provider 与 Vitest 版本有关,应以项目当前版本文档为准:
npm run test:coverage
不要在没有确认版本兼容性的情况下固定某个 Provider 版本或覆盖率阈值。工具升级后应验证报告格式、Source Map 和 CI 上传流程。
十三、从提交到发布的测试流水线
一个实际流水线可以按速度和故障成本分层:
flowchart TD
A[提交代码] --> B[TypeScript 类型检查]
B --> C[Vitest 纯函数与组件测试]
C --> D[构建应用]
D --> E[启动测试环境]
E --> F[Playwright 关键 E2E]
F --> G[发布候选环境]
G --> H[冒烟测试与可观测性检查]
H --> I[发布]
关键路径是:
- 类型检查:发现类型契约错误,但不能替代运行时测试;
- Vitest:快速反馈状态、校验和组件行为;
- 构建:发现模块、环境变量和编译配置问题;
- E2E:验证真实浏览器和系统边界;
- 发布后诊断:确认错误边界、日志、Web Vitals 和请求失败信息仍可观测。
例如:
{
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "vite build",
"e2e": "playwright test",
"ci": "npm run typecheck && npm test && npm run build && npm run e2e"
}
}
如果 E2E 依赖数据库,ci 之前还需要创建隔离数据库、执行迁移并准备测试数据。不能只在本地手动执行一次,然后假设 CI 环境拥有相同状态。
十四、测试与可观测性的连接
测试不仅要验证“页面显示什么”,还可以验证发布诊断所需的最小契约。
例如错误日志需要带上稳定的错误标识:
type ErrorEvent = {
level: 'error'
code: string
route: string
}
function reportError(event: ErrorEvent) {
console.error(event)
}
错误边界测试可以断言:
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
componentStack: expect.any(String),
}),
)
但不要断言完整的 componentStack 字符串,因为它可能随 React、编译器和文件结构变化。应只断言必要的结构。
对于 Web Vitals 或性能指标,组件测试通常不能真实测量首屏绘制、布局偏移和交互延迟。可以测试“指标上报函数是否收到指定数据”,但真实指标应在浏览器环境和发布环境中验证。否则 Mock 出来的时间值只能证明 Mock 工作正常。
十五、测试设计的取舍
1. 测试越接近用户,通常越慢
组件测试需要 DOM,但不一定需要真实浏览器;E2E 需要启动应用和浏览器,还可能访问数据库。层次越高,覆盖边界越多,执行成本也越高。
因此常见分配是:
- 大量纯函数和组件测试;
- 少量覆盖关键用户旅程的 E2E;
- 对高风险业务增加服务端集成测试;
- 对浏览器兼容、布局和发布环境增加专门验证。
这不是机械比例,而是基于风险:
支付、登录、权限、数据删除等功能,即使 E2E 成本较高,也通常值得覆盖完整链路。
2. 测试内部细节会增加重构成本
以下测试强绑定实现:
expect(setIsSubmitting).toHaveBeenCalledWith(true)
expect(wrapper.find('.loading-class')).toHaveLength(1)
它们可能在 UI 行为没有改变时因为重命名状态变量或 CSS 类名而失败。
以下测试绑定外部契约:
expect(screen.getByRole('button', { name: '提交中…' }))
.toBeDisabled()
它仍然关心用户能否继续操作,而且验证了可见反馈和交互限制。
但是“不要测试实现细节”也不是绝对规则。纯函数模块的导出 API、序列化格式、缓存键和请求参数本身就是模块契约,可以直接测试。关键在于:被测试的细节是否对调用者或系统边界有意义。
十六、一个可执行的检查清单
在提交一组 React 测试前,可以逐项确认:
- 是否明确了测试运行环境是 Node、jsdom 还是真实浏览器;
- 是否用用户可观察的 DOM 契约验证组件;
- 表单控件是否有正确的
label、角色和可访问名称; - 用户输入和点击是否使用了
await userEvent; - 异步结果是否等待条件,而不是等待固定时间;
- 是否分别覆盖成功、校验失败、HTTP 失败和网络失败;
- 是否验证了提交中的禁用状态;
- Mock 是否在测试后恢复,HTTP Handler 是否重置;
- 是否区分了错误边界、请求异常和未处理 Promise;
- 是否在客户端测试与服务端测试之间划清边界;
- 是否有至少一条关键用户旅程通过真实浏览器验证;
- E2E 数据是否隔离,测试是否可以独立运行;
- 是否把覆盖率当作风险指标,而不是质量结论;
- CI 是否验证类型、测试、构建和关键 E2E;
- 测试失败时是否能通过日志、截图、Trace 和错误边界信息定位问题。
React 测试体系的核心并不是某个断言库或某个覆盖率数字,而是把产品行为拆成可验证的边界:纯逻辑验证输入输出,Testing Library 验证用户可见的组件状态,Vitest 管理快速且隔离的执行环境,端到端测试验证浏览器到服务端的完整路径。边界划分正确后,测试既能抵抗内部重构,也能在真实故障发生前暴露状态、网络、路由和发布配置问题。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 与 TypeScript:Props、事件、泛型组件、Ref 和联合类型
- 下一篇:React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
- 延伸:React 表单工程:受控组件、校验、异步提交、错误与性能
- 延伸:React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论