React: 单元测试(Vitest + React Testing Library)
最后更新:2026-08-26
Tom 在重构一个旧组件时,"不小心"改了内部状态逻辑,导致依赖该组件的三个页面出现了 UI 异常。因为没有单元测试,这个问题直到 QA 测试时才被发现,浪费了整个团队的迭代时间。Tom 决定给项目引入 Vitest + React Testing Library,用自动化测试确保每次改动都不会破坏已有功能。
1. 你将学到
- Vitest 的安装配置(jsdom 环境、setupFiles)
- render / screen / userEvent 三大测试 API 的配合使用
- 组件 Props 验证和事件回调测试
- 异步组件加载状态测试(findBy / waitFor)
- Mock 外部依赖(API 请求、模块)
2. 概念图解
下面的图展示了单元测试在 React 组件开发中的位置和关系:
flowchart LR
A[编写组件] --> B[编写测试]
B --> C{运行测试}
C -->|通过| D[提交代码]
C -->|失败| E[定位 Bug]
E --> F{错误类型}
F -->|渲染问题| G[getByText / getByRole]
F -->|交互问题| H[userEvent.click]
F -->|异步问题| I[findByText / waitFor]
F -->|外部依赖| J[vi.mock / vi.fn]
G --> A
H --> A
I --> A
J --> A
style B fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. 一个真实场景
Tom 的团队有一个 UserCard 组件,负责展示用户信息。它接受 user 对象、onFollow 回调,并根据 isFollowing 状态切换按钮样式。
在一次重构中,Tom 修改了内部状态的初始值,导致 isFollowing 默认变成 true——所有用户卡片默认显示"已关注"。这个 Bug 在产品经理验收时才被发现。
如果当时有测试,这样的回归问题在 npm test 阶段的几秒内就能被发现。Tom 决定为所有核心组件补上单元测试。
(1) 环境配置 —— 搭建测试基础设施
首先安装所有依赖:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
配置 Vitest(在 vite.config.ts 中添加 test 字段):
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// 使用 jsdom 模拟浏览器环境
environment: 'jsdom',
// 全局注册 describe / test / expect,无需手动导入
globals: true,
// 测试启动前运行的设置文件
setupFiles: './src/test/setup.ts',
},
})
创建 setup 文件:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// 这行让 toBeInTheDocument()、toHaveTextContent() 等断言可用
在 package.json 中添加测试脚本:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
(2) 三大核心 API
React Testing Library 的测试哲学:不测试实现细节,只测试用户能看到和交互的东西。
| API | 作用 | 特点 |
|---|---|---|
render(component) |
将组件渲染到虚拟 DOM | 返回容器引用和辅助方法 |
screen |
全局查找元素的入口 | 提供 getBy / findBy / queryBy 三类方法 |
userEvent |
模拟用户操作 | 比 fireEvent 更接近真实行为 |
核心原则: 用
getByRole优先(语义化),getByText其次,getByTestId最后。
▶ 示例 1:测试 Counter 组件的渲染和交互
先编写一个简单的 Counter 组件:
// Counter.tsx
import { useState } from 'react'
interface CounterProps {
initialCount?: number
step?: number
label?: string
}
export function Counter({
initialCount = 0,
step = 1,
label = '计数',
}: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>
{label}:{count}
</p>
<button onClick={() => setCount(c => c + step)}>+{step}</button>
<button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
-{step}
</button>
{count >= 10 && (
<p role="alert">已达到最大值提醒</p>
)}
</div>
)
}
编写测试:
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter 组件', () => {
// 测试 1:初始渲染
test('显示初始计数值', () => {
render(<Counter initialCount={5} />)
// getByText — 通过文本内容查找
expect(screen.getByText('计数:5')).toBeInTheDocument()
})
// 测试 2:点击增加按钮
test('点击 +1 按钮后计数增加', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={1} />)
const incrementBtn = screen.getByRole('button', { name: '+1' })
await user.click(incrementBtn)
expect(screen.getByText('计数:1')).toBeInTheDocument()
})
// 测试 3:计数为 0 时减按钮禁用
test('计数为 0 时减按钮禁用', () => {
render(<Counter initialCount={0} />)
const decrementBtn = screen.getByRole('button', { name: '-1' })
expect(decrementBtn).toBeDisabled()
})
// 测试 4:达到阈值时显示提醒
test('计数达到 10 时显示提醒', async () => {
const user = userEvent.setup()
render(<Counter initialCount={9} step={1} />)
// 初始时没有提醒
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// 点击加 1
await user.click(screen.getByRole('button', { name: '+1' }))
// 现在有了提醒
expect(screen.getByRole('alert')).toHaveTextContent('已达到最大值提醒')
})
// 测试 5:自定义 label 和 step
test('支持自定义 label 和 step', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={5} label="步数" />)
expect(screen.getByText('步数:0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+5' }))
expect(screen.getByText('步数:5')).toBeInTheDocument()
})
})
这段测试展示了四个模式:
getByText— 查找包含指定文本的元素(最简单直接)getByRole— 通过 ARIA 角色查找,带name选项精确匹配按钮文字queryByRole— 查找可能不存在的元素,返回null而不是抛错toBeDisabled()/toHaveTextContent()— jest-dom 提供的语义化断言
(3) 测试 Props 和事件回调
组件通常通过 Props 接收数据和回调函数。测试的目标是验证回调是否被正确调用,以及参数是否正确。
▶ 示例 2:测试 TodoItem 组件的 Props 和事件
// TodoItem.tsx
interface Todo {
id: number
text: string
completed: boolean
}
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
background: todo.completed ? '#f6ffed' : '#fff',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`标记 ${todo.text}`}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`删除 ${todo.text}`}
style={{
border: 'none',
background: '#ff4d4f',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
}}
>
删除
</button>
</div>
)
}
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'
describe('TodoItem 组件', () => {
const mockTodo = {
id: 42,
text: '学习 React 测试',
completed: false,
}
test('渲染待办事项文本', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
expect(screen.getByText('学习 React 测试')).toBeInTheDocument()
})
test('已完成事项显示删除线', () => {
render(
<TodoItem
todo={{ ...mockTodo, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('学习 React 测试')
expect(text).toHaveStyle('text-decoration: line-through')
})
test('点击复选框触发 onToggle', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={onToggle}
onDelete={vi.fn()}
/>
)
await user.click(screen.getByRole('checkbox'))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(42) // 验证参数是 todo.id
})
test('点击删除按钮触发 onDelete', async () => {
const onDelete = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: /删除/ }))
expect(onDelete).toHaveBeenCalledWith(42)
})
test('未完成事项没有删除线', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('学习 React 测试')
// 注意:内联样式的 text-decoration 为 'none',而不是无此属性
expect(text).not.toHaveStyle('text-decoration: line-through')
})
})
Mock 函数 vi.fn() 的关键用法:
| API | 作用 |
|---|---|
vi.fn() |
创建一个空的 Mock 函数 |
toHaveBeenCalledTimes(n) |
验证调用了 n 次 |
toHaveBeenCalledWith(...) |
验证调用时的参数 |
vi.fn().mockResolvedValue(x) |
Mock 异步成功返回 |
vi.fn().mockRejectedValue(e) |
Mock 异步失败返回 |
(4) 测试异步组件
很多组件在加载时先显示"加载中...",数据到达后显示内容。需要用 findBy(异步等待)来测试这类场景。
▶ 示例 3:测试异步数据加载组件
// UserProfile.tsx
interface UserProfileProps {
userId: number
}
interface UserData {
id: number
name: string
email: string
}
// 模拟 API 调用
async function fetchUser(id: number): Promise<UserData> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('加载失败')
return res.json()
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<UserData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUser(userId)
if (!cancelled) setUser(data)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '未知错误')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [userId])
if (loading) return <div aria-label="加载中">加载中...</div>
if (error) return <div role="alert">错误:{error}</div>
if (!user) return <div>无数据</div>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
// Mock 掉 fetchUser 模块
vi.mock('./UserProfile', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// 重写 fetchUser 的实现
fetchUser: vi.fn(),
}
})
// 更好的做法:单独 mock API 模块
// vi.mock('../api', () => ({
// fetchUser: vi.fn()
// }))
describe('UserProfile 异步组件', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('加载时显示加载中', () => {
// 让 fetch 一直 pending
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // 永不 resolve
)
render(<UserProfile userId={1} />)
expect(screen.getByLabelText('加载中')).toBeInTheDocument()
})
test('加载成功后显示用户信息', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
// Mock fetch 返回成功数据
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
render(<UserProfile userId={1} />)
// findByText — 异步等待元素出现(默认超时 1000ms)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
test('加载失败时显示错误信息', async () => {
// Mock fetch 返回 500
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response)
render(<UserProfile userId={1} />)
// 等待错误提示出现
expect(await screen.findByRole('alert')).toHaveTextContent('错误:')
})
test('组件卸载时不会更新状态(防止内存泄漏)', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
let resolvePromise!: (value: any) => void
vi.spyOn(global, 'fetch').mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
})
)
const { unmount } = render(<UserProfile userId={1} />)
// 在 fetch 完成前卸载组件
unmount()
// 此时 resolve,但组件已卸载,应该不会 setState
resolvePromise({
ok: true,
json: async () => mockUser,
} as Response)
// 没有抛错 = 测试通过
})
})
异步测试的三个核心方法对比:
| 方法 | 同步/异步 | 元素不存在时 | 默认超时 | 使用场景 |
|---|---|---|---|---|
getByText |
同步 | 立即抛错 | - | 元素一定存在 |
queryByText |
同步 | 返回 null |
- | 验证元素不存在 |
findByText |
异步(Promise) | 超时后抛错 | 1000ms | 等待异步渲染 |
(5) Mock 外部依赖的最佳实践
真实项目中组件常依赖 API、路由、状态管理。Mock 这些依赖是测试的关键。
方式一:Mock 全局 fetch
// 在每个测试前替换全局 fetch
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks() // 恢复原始 fetch
})
方式二:Mock 模块
// api.ts — 真实模块
export async function fetchUsers() {
const res = await fetch('/api/users')
return res.json()
}
// 测试中 Mock
vi.mock('../api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Mock User' },
])
}))
方式三:Mock React Router
import { MemoryRouter } from 'react-router-dom'
test('在路由上下文中渲染', () => {
render(
<MemoryRouter initialEntries={['/users/1']}>
<UserDetailPage />
</MemoryRouter>
)
})
▶ 示例 4:测试自定义 Hook
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
})
▶ 示例 5:集成测试——表单提交流程
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) { setError('Invalid email'); return }
if (password.length < 6) { setError('Password too short'); return }
setError('')
await onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
)
}
describe('LoginForm integration', () => {
test('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.type(screen.getByTestId('email'), 'invalid')
await userEvent.type(screen.getByTestId('password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
})
test('calls onSubmit with valid data', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
await userEvent.type(screen.getByTestId('password'), 'secure123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
})
})
})
❓ 常见问题
getBy(找不到就抛错);元素可能不存在用 queryBy(返回 null);元素是异步渲染的用 findBy(返回 Promise,等待超时后抛错)。每种方法都有对应的 Role/Text/TestId 变体,如 getByRole、findByText、queryByTestId。userEvent 和 fireEvent 应该用哪个?userEvent。fireEvent 是底层 API,直接触发 DOM 事件。userEvent 在 fireEvent 之上模拟了完整的用户操作序列(如 click 会包含 mousedown → mouseup → click),更接近浏览器真实行为。只有 userEvent 不支持的操作才退回到 fireEvent。vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>) 替换即可。beforeEach 中要清理测试环境吗?afterEach(() => { vi.clearAllMocks() })。Vitest 本身会卸载每次 render 的组件,但 Mock 状态需要手动清理。如果使用了全局 fetch Mock,用 vi.restoreAllMocks() 恢复原始实现。--coverage 参数可以生成 Istanbul 覆盖率报告。📖 小节
- Vitest + React Testing Library 是 React 单元测试的标准组合
- render/screen/userEvent 三大 API 覆盖了组件渲染、查找和交互的全流程
- getBy(同步存在)、queryBy(同步可能不存在)、findBy(异步等待)三种查询方式各司其职
- vi.fn() 创建 Mock 函数验证回调调用,vi.spyOn() Mock 全局函数
- 测试异步组件时,用 findBy 或 waitFor 等待异步渲染的 UI
- 好的测试不关注实现细节,只验证用户可感知的行为
📝 作业
- 为一个
Button组件写完整的测试:验证点击触发onClick、disabled时不可点击、显示正确的文本、自定义 className 生效。至少覆盖 4 个测试用例。 - 为一个
UserProfile组件写测试:测试加载状态显示 loading、成功状态显示用户信息(异步加强)、失败状态显示错误信息。用vi.spyOnMock fetch API。 - 为一个
TodoApp组件写集成测试(包含 TodoList + TodoItem + AddTodo 表单):测试添加新待办、标记完成、删除待办三个功能,验证列表 UI 正确更新。