主题
组件测试
什么是组件测试
组件测试是介于单元测试和 E2E 测试之间的一种测试方式,专注于验证 UI 组件在隔离环境中的渲染结果、用户交互行为和状态变化。它在模拟的 DOM 环境(如 jsdom 或 happy-dom)中运行,不需要真实浏览器,但能有效验证组件对外表现的正确性。
组件测试在测试金字塔中的位置:
/\
/ \
/ E2E \ Playwright / Cypress
/________\ 真实浏览器、完整流程
/ \
/ 组件测试 \ Testing Library + Vitest/Jest
/______________\ 模拟 DOM、用户视角
/ \
/ 单元测试 \ 纯函数、工具方法
/____________________\ 无 DOM 依赖组件测试的核心目标:
┌───────────────┐
Props ──────────▶ │ │ ──────────▶ 渲染输出(DOM)
│ Component │
User Events ───▶ │ │ ──────────▶ 回调触发(Events)
│ │
Context/Store ──▶ │ │ ──────────▶ 副作用(API Calls)
└───────────────┘我们关注的是组件的输入和输出,而不是内部实现细节。输入包括 Props、用户事件、Context 等,输出包括渲染的 DOM 结构、触发的回调、发起的网络请求等。
与单元测试的关键区别
| 维度 | 单元测试 | 组件测试 |
|---|---|---|
| 测试对象 | 纯函数、工具方法 | UI 组件 |
| 运行环境 | Node.js | jsdom / happy-dom |
| DOM 依赖 | 无 | 需要模拟 DOM |
| 关注点 | 输入 → 输出的逻辑正确性 | 用户视角的行为正确性 |
| 交互测试 | 不涉及 | 点击、输入、表单提交 |
| 断言方式 | toBe、toEqual | toBeInTheDocument、toHaveTextContent |
| 测试粒度 | 最小可测试单元 | 组件及其子组件的协作 |
Testing Library 的核心理念
"The more your tests resemble the way your software is used, the more confidence they can give you."
—— Kent C. Dodds
这句话是 Testing Library 整个生态的设计哲学。它意味着:
- 不要测试实现细节:不要断言 state 的值、不要检查组件实例的方法
- 以用户视角编写测试:用户看到什么、用户能操作什么,就测什么
- 通过可访问性查询元素:优先使用 Role、Label 而不是 CSS 选择器或 TestId
传统测试思维 vs Testing Library 思维:
传统思维(开发者视角) Testing Library 思维(用户视角)
───────────────────── ─────────────────────────────
wrapper.instance().state.count screen.getByText('Count: 0')
wrapper.find('.btn-submit') screen.getByRole('button', { name: '提交' })
wrapper.vm.$emit('click') await userEvent.click(submitButton)
wrapper.setProps({ visible: true }) render(<Modal visible={true} />)
expect(wrapper.vm.isOpen).toBe(true) expect(screen.getByRole('dialog')).toBeVisible()React Testing Library
React Testing Library(RTL)是 React 生态中最主流的组件测试方案,它是 Testing Library 家族在 React 领域的具体实现。RTL 构建在 @testing-library/dom 之上,提供了与 React 组件交互的 API。
安装与配置
核心依赖包:
bash
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event依赖关系图:
@testing-library/react
│
├── @testing-library/dom (核心 DOM 查询引擎)
│ └── @testing-library/user-event (用户交互模拟)
│
├── react-dom/test-utils (React 官方测试工具)
│
└── @testing-library/jest-dom (自定义 Jest 匹配器)
└── toBeInTheDocument()
└── toHaveTextContent()
└── toBeVisible()
└── toBeDisabled()
└── toHaveAttribute()
└── toHaveClass()
└── toHaveStyle()Vitest 配置示例(vitest.config.ts):
typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: true,
},
})全局 setup 文件(src/test/setup.ts):
typescript
import '@testing-library/jest-dom/vitest'render 与 screen
render 是 RTL 的核心 API,它将 React 组件渲染到模拟的 DOM 中。screen 对象提供了查询渲染结果的统一入口。
typescript
import { render, screen } from '@testing-library/react'
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
test('renders greeting', () => {
render(<Greeting name="World" />)
expect(screen.getByText('Hello, World!')).toBeInTheDocument()
})render 的返回值:
typescript
const {
container,
baseElement,
debug,
rerender,
unmount,
asFragment,
} = render(<MyComponent />)| 返回值 | 用途 |
|---|---|
container | 渲染组件的父 DOM 节点 |
debug() | 打印当前 DOM 结构到控制台 |
rerender(<C newProp />) | 用新 props 重新渲染组件 |
unmount() | 卸载组件,测试 cleanup 逻辑 |
asFragment() | 返回 DocumentFragment,用于快照测试 |
为什么推荐用 screen 而不是解构查询方法?
typescript
const { getByText } = render(<MyComponent />)
getByText('hello')
screen.getByText('hello')使用 screen 的好处:
- 无需在每个测试中解构,代码更简洁
- 当多次
render时不会产生命名冲突 screen.debug()随时可用,调试方便- 社区规范推荐,代码一致性更好
查询 API 体系
RTL 的查询 API 是其最核心的设计。三种查询变体与八种查询类型的组合构成了完整的查询矩阵。
三种查询变体
┌─────────────┬───────────────────┬──────────────┬──────────────────────┐
│ 变体 │ 无匹配时 │ 多个匹配时 │ 返回类型 │
├─────────────┼───────────────────┼──────────────┼──────────────────────┤
│ getBy... │ ❌ 抛出异常 │ ❌ 抛出异常 │ HTMLElement │
│ queryBy... │ ✅ 返回 null │ ❌ 抛出异常 │ HTMLElement | null │
│ findBy... │ ❌ 抛出异常(异步) │ ❌ 抛出异常 │ Promise<HTMLElement> │
├─────────────┼───────────────────┼──────────────┼──────────────────────┤
│ getAllBy │ ❌ 抛出异常 │ ✅ 返回数组 │ HTMLElement[] │
│ queryAllBy │ ✅ 返回 [] │ ✅ 返回数组 │ HTMLElement[] │
│ findAllBy │ ❌ 抛出异常(异步) │ ✅ 返回数组 │ Promise<Element[]> │
└─────────────┴───────────────────┴──────────────┴──────────────────────┘使用场景对比:
选择查询变体的决策树:
元素是否应该存在于 DOM 中?
/ \
是 否
/ \
是否异步出现? 用 queryBy...
/ \ 断言它不存在
是 否
/ \
用 findBy... 用 getBy...
(自带等待) (同步断言)查询类型与优先级
RTL 定义了明确的查询优先级,核心原则是:越接近用户感知的查询方式,优先级越高。
查询优先级(从高到低):
优先级 1:可访问性查询(所有用户都能感知)
├── getByRole ← 最推荐!基于 ARIA Role
├── getByLabelText ← 表单元素首选
└── getByPlaceholderText ← 表单备选
优先级 2:语义化查询(视觉用户能感知)
├── getByText ← 非交互元素首选
└── getByDisplayValue ← 已填充的表单元素
优先级 3:Test ID(用户无法感知)
└── getByTestId ← 最后手段各查询类型的详细用法:
getByRole — 最推荐的查询方式
typescript
screen.getByRole('button', { name: '提交' })
screen.getByRole('heading', { level: 2 })
screen.getByRole('checkbox', { checked: true })
screen.getByRole('textbox', { name: '用户名' })
screen.getByRole('combobox', { name: '城市选择' })
screen.getByRole('dialog')
screen.getByRole('navigation')
screen.getByRole('alert')常见 HTML 元素与 ARIA Role 的映射:
| HTML 元素 | 隐含 Role | 查询方式 |
|---|---|---|
<button> | button | getByRole('button') |
<a href> | link | getByRole('link') |
<input type="text"> | textbox | getByRole('textbox') |
<input type="checkbox"> | checkbox | getByRole('checkbox') |
<input type="radio"> | radio | getByRole('radio') |
<select> | combobox | getByRole('combobox') |
<textarea> | textbox | getByRole('textbox') |
<h1> ~ <h6> | heading | getByRole('heading', { level: N }) |
<ul> / <ol> | list | getByRole('list') |
<li> | listitem | getByRole('listitem') |
<nav> | navigation | getByRole('navigation') |
<img> | img | getByRole('img') |
getByLabelText — 表单元素首选
typescript
screen.getByLabelText('邮箱地址')
screen.getByLabelText('密码')对应的 HTML 结构:
html
<label for="email">邮箱地址</label>
<input id="email" type="email" />
<label>
密码
<input type="password" />
</label>getByText — 非交互元素首选
typescript
screen.getByText('欢迎使用')
screen.getByText(/欢迎/)
screen.getByText((content, element) => {
return element?.tagName === 'SPAN' && content.startsWith('价格')
})getByTestId — 最后的手段
typescript
screen.getByTestId('custom-chart')html
<div data-testid="custom-chart">
<canvas></canvas>
</div>用户交互:userEvent
@testing-library/user-event 提供了比 fireEvent 更真实的用户交互模拟。它会模拟完整的事件链路,而不是仅仅触发单个事件。
fireEvent vs userEvent 的事件链路差异:
fireEvent.click(button):
click ──▶ 完毕
userEvent.click(button):
pointerover ──▶ pointerenter ──▶ mouseover ──▶ mouseenter
──▶ pointermove ──▶ mousemove ──▶ pointerdown ──▶ mousedown
──▶ focus ──▶ pointerup ──▶ mouseup ──▶ click ──▶ 完毕使用方式:
typescript
import userEvent from '@testing-library/user-event'
test('form interaction', async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByLabelText('用户名'), 'alice')
await user.type(screen.getByLabelText('密码'), 'password123')
await user.click(screen.getByRole('button', { name: '登录' }))
})常用交互 API:
| API | 用途 | 示例 |
|---|---|---|
user.click(element) | 点击 | await user.click(button) |
user.dblClick(element) | 双击 | await user.dblClick(card) |
user.type(element, text) | 逐字输入 | await user.type(input, 'hello') |
user.clear(element) | 清空输入框 | await user.clear(input) |
user.selectOptions(select, values) | 下拉选择 | await user.selectOptions(select, ['opt1']) |
user.upload(input, file) | 文件上传 | await user.upload(fileInput, file) |
user.tab() | Tab 键切换焦点 | await user.tab() |
user.keyboard('{Enter}') | 键盘输入 | await user.keyboard('{Escape}') |
user.hover(element) | 鼠标悬浮 | await user.hover(tooltip) |
user.unhover(element) | 鼠标移出 | await user.unhover(tooltip) |
注意:推荐使用 userEvent.setup() 创建实例,而不是直接调用 userEvent.click()。setup 模式确保所有交互共享同一个输入状态(如键盘修饰键、剪贴板等)。
异步测试
组件经常涉及异步操作:数据加载、定时器、动画等。RTL 提供了 waitFor 和 findBy* 来处理异步场景。
typescript
import { render, screen, waitFor } from '@testing-library/react'
test('loads user data', async () => {
render(<UserProfile userId={1} />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})
test('loads user data (findBy)', async () => {
render(<UserProfile userId={1} />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
const userName = await screen.findByText('Alice')
expect(userName).toBeInTheDocument()
})waitFor vs findBy* 的选择:
┌────────────┬──────────────────────────────┬────────────────────────────┐
│ │ waitFor │ findBy* │
├────────────┼──────────────────────────────┼────────────────────────────┤
│ 功能 │ 等待回调函数不再抛出异常 │ 等待元素出现在 DOM 中 │
│ 灵活度 │ 高,可以放任意断言 │ 只能等待单个元素出现 │
│ 底层实现 │ 轮询执行回调 │ 内部就是 waitFor + getBy │
│ 适合场景 │ 复杂条件等待、多个断言 │ 简单地等待元素出现 │
│ 超时默认 │ 1000ms │ 1000ms │
└────────────┴──────────────────────────────┴────────────────────────────┘waitFor 的高级配置:
typescript
await waitFor(
() => {
expect(screen.getByText('数据加载完成')).toBeInTheDocument()
},
{
timeout: 3000,
interval: 100,
onTimeout: (error) => {
console.log(screen.debug())
return error
},
}
)测试场景实战
测试组件渲染
验证组件在不同 props 下是否正确渲染:
tsx
function ProductCard({ name, price, inStock }: ProductCardProps) {
return (
<div>
<h2>{name}</h2>
<span>{`¥${price.toFixed(2)}`}</span>
{inStock ? (
<span>有货</span>
) : (
<span>暂时缺货</span>
)}
</div>
)
}typescript
describe('ProductCard', () => {
it('renders product information correctly', () => {
render(<ProductCard name="TypeScript 入门" price={59.9} inStock={true} />)
expect(screen.getByRole('heading', { name: 'TypeScript 入门' })).toBeInTheDocument()
expect(screen.getByText('¥59.90')).toBeInTheDocument()
expect(screen.getByText('有货')).toBeInTheDocument()
})
it('shows out of stock when not available', () => {
render(<ProductCard name="TypeScript 入门" price={59.9} inStock={false} />)
expect(screen.getByText('暂时缺货')).toBeInTheDocument()
expect(screen.queryByText('有货')).not.toBeInTheDocument()
})
})测试用户交互
tsx
function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<span>{`当前计数: ${count}`}</span>
<button onClick={() => setCount(c => c + 1)}>增加</button>
<button onClick={() => setCount(c => c - 1)}>减少</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
)
}typescript
describe('Counter', () => {
it('increments count on click', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(screen.getByText('当前计数: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '增加' }))
expect(screen.getByText('当前计数: 1')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '增加' }))
expect(screen.getByText('当前计数: 2')).toBeInTheDocument()
})
it('decrements count on click', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '减少' }))
expect(screen.getByText('当前计数: -1')).toBeInTheDocument()
})
it('resets count to zero', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '增加' }))
await user.click(screen.getByRole('button', { name: '增加' }))
await user.click(screen.getByRole('button', { name: '重置' }))
expect(screen.getByText('当前计数: 0')).toBeInTheDocument()
})
})测试表单交互
tsx
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) {
setError('请填写所有字段')
return
}
if (password.length < 6) {
setError('密码长度不能少于 6 位')
return
}
setError('')
onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit">登录</button>
</form>
)
}typescript
describe('LoginForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
await user.type(screen.getByLabelText('密码'), 'password123')
await user.click(screen.getByRole('button', { name: '登录' }))
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@test.com',
password: 'password123',
})
})
it('shows error when fields are empty', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: '登录' }))
expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段')
})
it('shows error for short password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
await user.type(screen.getByLabelText('密码'), '123')
await user.click(screen.getByRole('button', { name: '登录' }))
expect(screen.getByRole('alert')).toHaveTextContent('密码长度不能少于 6 位')
})
it('clears error on successful submit after failed attempt', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: '登录' }))
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
await user.type(screen.getByLabelText('密码'), 'password123')
await user.click(screen.getByRole('button', { name: '登录' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})测试条件渲染
tsx
function UserStatus({ user }: { user: User | null }) {
if (!user) {
return <div>请先登录</div>
}
return (
<div>
<span>{`欢迎回来, ${user.name}`}</span>
{user.role === 'admin' && <button>管理后台</button>}
{user.notifications > 0 && (
<span>{`${user.notifications} 条新通知`}</span>
)}
</div>
)
}typescript
describe('UserStatus', () => {
it('shows login prompt when no user', () => {
render(<UserStatus user={null} />)
expect(screen.getByText('请先登录')).toBeInTheDocument()
})
it('shows welcome message for logged in user', () => {
const user = { name: 'Alice', role: 'user', notifications: 0 }
render(<UserStatus user={user} />)
expect(screen.getByText('欢迎回来, Alice')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '管理后台' })).not.toBeInTheDocument()
})
it('shows admin button for admin users', () => {
const admin = { name: 'Bob', role: 'admin', notifications: 0 }
render(<UserStatus user={admin} />)
expect(screen.getByRole('button', { name: '管理后台' })).toBeInTheDocument()
})
it('shows notification count when present', () => {
const user = { name: 'Alice', role: 'user', notifications: 5 }
render(<UserStatus user={user} />)
expect(screen.getByText('5 条新通知')).toBeInTheDocument()
})
})测试 API 调用
Mock 网络请求是组件测试中最常见的场景之一。有两种主流方案:直接 Mock 请求函数和使用 MSW(Mock Service Worker)。
方案一:直接 Mock 模块
tsx
function UserList() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchUsers()
.then(data => setUsers(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [])
if (loading) return <div>加载中...</div>
if (error) return <div role="alert">{error}</div>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}typescript
import { fetchUsers } from '../api/users'
vi.mock('../api/users')
const mockedFetchUsers = vi.mocked(fetchUsers)
describe('UserList', () => {
it('displays loading state initially', () => {
mockedFetchUsers.mockReturnValue(new Promise(() => {}))
render(<UserList />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
})
it('displays users after loading', async () => {
mockedFetchUsers.mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
render(<UserList />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
it('displays error on fetch failure', async () => {
mockedFetchUsers.mockRejectedValue(new Error('网络错误'))
render(<UserList />)
expect(await screen.findByRole('alert')).toHaveTextContent('网络错误')
})
})方案二:MSW(Mock Service Worker)
MSW 在网络层面拦截请求,不需要 Mock 任何模块,更加真实:
typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('UserList with MSW', () => {
it('displays users from API', async () => {
render(<UserList />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
it('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserList />)
expect(await screen.findByRole('alert')).toBeInTheDocument()
})
})两种方案对比:
┌───────────────┬──────────────────────┬──────────────────────┐
│ │ 直接 Mock 模块 │ MSW │
├───────────────┼──────────────────────┼──────────────────────┤
│ 拦截层级 │ 模块级别 │ 网络级别 │
│ 真实度 │ 低,跳过了 HTTP 层 │ 高,请求真实发出 │
│ 与实现的耦合 │ 高,需知道具体模块 │ 低,只关心 API 契约 │
│ 配置复杂度 │ 低 │ 中 │
│ 可复用性 │ 低 │ 高,可跨测试文件复用 │
│ 适合场景 │ 简单组件、快速测试 │ 集成测试、复杂 API │
└───────────────┴──────────────────────┴──────────────────────┘测试自定义 Hook
使用 renderHook 可以在隔离环境中测试自定义 Hook,无需创建宿主组件。
typescript
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}typescript
import { renderHook, act } from '@testing-library/react'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})测试带异步逻辑的 Hook:
typescript
function useAsync<T>(asyncFn: () => Promise<T>) {
const [state, setState] = useState<{
data: T | null
loading: boolean
error: Error | null
}>({ data: null, loading: true, error: null })
useEffect(() => {
asyncFn()
.then(data => setState({ data, loading: false, error: null }))
.catch(error => setState({ data: null, loading: false, error }))
}, [asyncFn])
return state
}typescript
describe('useAsync', () => {
it('handles successful async operation', async () => {
const asyncFn = vi.fn().mockResolvedValue({ name: 'Alice' })
const { result } = renderHook(() => useAsync(asyncFn))
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.data).toEqual({ name: 'Alice' })
expect(result.current.error).toBeNull()
})
it('handles async failure', async () => {
const asyncFn = vi.fn().mockRejectedValue(new Error('Failed'))
const { result } = renderHook(() => useAsync(asyncFn))
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.data).toBeNull()
expect(result.current.error?.message).toBe('Failed')
})
})测试 Context Provider
当组件依赖 Context 时,需要在测试中提供对应的 Provider:
typescript
const ThemeContext = createContext<{
theme: 'light' | 'dark'
toggleTheme: () => void
}>({ theme: 'light', toggleTheme: () => {} })
function ThemeToggle() {
const { theme, toggleTheme } = useContext(ThemeContext)
return (
<button onClick={toggleTheme}>
{theme === 'light' ? '切换到暗色模式' : '切换到亮色模式'}
</button>
)
}typescript
describe('ThemeToggle', () => {
it('displays current theme', () => {
render(
<ThemeContext.Provider value={{ theme: 'light', toggleTheme: vi.fn() }}>
<ThemeToggle />
</ThemeContext.Provider>
)
expect(screen.getByRole('button')).toHaveTextContent('切换到暗色模式')
})
it('calls toggleTheme on click', async () => {
const user = userEvent.setup()
const toggleTheme = vi.fn()
render(
<ThemeContext.Provider value={{ theme: 'light', toggleTheme }}>
<ThemeToggle />
</ThemeContext.Provider>
)
await user.click(screen.getByRole('button'))
expect(toggleTheme).toHaveBeenCalledTimes(1)
})
})封装自定义 render 来简化 Provider 的设置:
typescript
function renderWithProviders(
ui: React.ReactElement,
{
theme = 'light',
...options
}: { theme?: 'light' | 'dark' } & RenderOptions = {}
) {
const toggleTheme = vi.fn()
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
return {
...render(ui, { wrapper: Wrapper, ...options }),
toggleTheme,
}
}
test('uses custom render', () => {
renderWithProviders(<ThemeToggle />, { theme: 'dark' })
expect(screen.getByRole('button')).toHaveTextContent('切换到亮色模式')
})测试路由组件
使用 MemoryRouter 在测试中提供路由环境:
typescript
import { MemoryRouter, Route, Routes } from 'react-router-dom'
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}typescript
describe('App Routing', () => {
it('renders home page on /', () => {
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
)
expect(screen.getByText('首页')).toBeInTheDocument()
})
it('renders about page on /about', () => {
render(
<MemoryRouter initialEntries={['/about']}>
<App />
</MemoryRouter>
)
expect(screen.getByText('关于我们')).toBeInTheDocument()
})
it('renders user detail with params', () => {
render(
<MemoryRouter initialEntries={['/users/42']}>
<App />
</MemoryRouter>
)
expect(screen.getByText('用户 ID: 42')).toBeInTheDocument()
})
it('renders 404 for unknown routes', () => {
render(
<MemoryRouter initialEntries={['/unknown']}>
<App />
</MemoryRouter>
)
expect(screen.getByText('页面不存在')).toBeInTheDocument()
})
it('navigates between pages', async () => {
const user = userEvent.setup()
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
)
await user.click(screen.getByRole('link', { name: '关于' }))
expect(screen.getByText('关于我们')).toBeInTheDocument()
})
})Vue Testing Library 与 Vue Test Utils
@testing-library/vue
Vue Testing Library 遵循与 React Testing Library 相同的理念,提供面向用户的测试 API:
bash
npm install -D @testing-library/vue @testing-library/jest-dom @testing-library/user-eventvue
<template>
<div>
<h1>{{ title }}</h1>
<p>{{ `计数: ${count}` }}</p>
<button @click="count++">增加</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ title: string }>()
const count = ref(0)
</script>typescript
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.vue'
describe('Counter.vue', () => {
it('renders title from props', () => {
render(Counter, { props: { title: '我的计数器' } })
expect(screen.getByRole('heading')).toHaveTextContent('我的计数器')
})
it('increments count on button click', async () => {
const user = userEvent.setup()
render(Counter, { props: { title: '计数器' } })
expect(screen.getByText('计数: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '增加' }))
expect(screen.getByText('计数: 1')).toBeInTheDocument()
})
})Vue Test Utils
Vue Test Utils 是 Vue 官方的测试工具库,提供了更偏向开发者视角的 API:
typescript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter.vue', () => {
it('renders title', () => {
const wrapper = mount(Counter, { props: { title: '我的计数器' } })
expect(wrapper.find('h1').text()).toBe('我的计数器')
})
it('increments on click', async () => {
const wrapper = mount(Counter, { props: { title: '计数器' } })
await wrapper.find('button').trigger('click')
expect(wrapper.find('p').text()).toBe('计数: 1')
})
})React Testing Library vs Vue Testing Library vs Vue Test Utils
| 维度 | React Testing Library | Vue Testing Library | Vue Test Utils |
|---|---|---|---|
| 测试理念 | 用户视角 | 用户视角 | 开发者视角 |
| 查询方式 | screen.getByRole | screen.getByRole | wrapper.find('.class') |
| 交互方式 | userEvent.click | userEvent.click | wrapper.trigger('click') |
| 组件实例访问 | ❌ 不支持 | ❌ 不支持 | ✅ wrapper.vm |
| DOM 访问 | 通过语义化查询 | 通过语义化查询 | wrapper.find / CSS 选择器 |
| 快照测试 | asFragment() | 支持 | wrapper.html() |
| 官方维护 | Testing Library 社区 | Testing Library 社区 | Vue 官方 |
| 学习曲线 | 中等 | 中等 | 较低 |
| 适合场景 | 注重行为测试 | 注重行为测试 | 需要访问内部状态 |
选择 Vue 测试工具的决策树:
需要测试 Vue 组件
│
▼
是否需要访问组件内部状态/方法?
/ \
是 否
│ │
▼ ▼
Vue Test Utils Vue Testing Library
(wrapper.vm) (screen.getByRole)Storybook 与视觉测试
Storybook 核心概念
Storybook 是一个 UI 组件开发环境,允许你在隔离状态下开发和浏览组件。它与组件测试有着密切的互补关系。
Storybook 的架构:
┌─────────────────────────────────────────────┐
│ Storybook UI │
│ ┌──────────┐ ┌──────────────────────────┐ │
│ │ Sidebar │ │ Preview Panel │ │
│ │ │ │ │ │
│ │ ● Button │ │ ┌────────────────────┐ │ │
│ │ ├ Primary │ │ │ Your Component │ │ │
│ │ ├ Secondary│ │ │ (isolated) │ │ │
│ │ └ Disabled│ │ └────────────────────┘ │ │
│ │ ● Input │ │ │ │
│ │ ● Modal │ │ ┌────────────────────┐ │ │
│ │ │ │ │ Controls / Docs │ │ │
│ └──────────┘ │ └────────────────────┘ │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────┘Story 是 Storybook 的基本单位,代表一个组件在特定状态下的渲染。CSF(Component Story Format) 是编写 Story 的标准格式:
typescript
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
},
size: {
control: 'radio',
options: ['sm', 'md', 'lg'],
},
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
args: {
variant: 'primary',
children: '主要按钮',
},
}
export const Secondary: Story = {
args: {
variant: 'secondary',
children: '次要按钮',
},
}
export const Disabled: Story = {
args: {
variant: 'primary',
children: '禁用按钮',
disabled: true,
},
}
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', gap: '8px' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
</div>
),
}交互测试(Play Function)
Storybook 的 Play Function 允许在 Story 中编写交互测试,这些测试直接在浏览器中执行:
typescript
import { within, userEvent, expect } from '@storybook/test'
export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText('邮箱'), 'alice@test.com')
await userEvent.type(canvas.getByLabelText('密码'), 'password123')
await userEvent.click(canvas.getByRole('button', { name: '登录' }))
await expect(canvas.getByText('登录成功')).toBeInTheDocument()
},
}
export const ValidationError: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: '登录' }))
await expect(canvas.getByText('请填写所有字段')).toBeInTheDocument()
},
}Storybook 交互测试与 RTL 组件测试的关系:
┌───────────────────────────────────┐
│ Storybook Play Fn │
│ (浏览器环境,可视化交互) │
│ │
│ 使用相同的 Testing Library API: │
│ - within() │
│ - userEvent │
│ - expect() │
└──────────┬────────────────────────┘
│ 共享
│ 测试逻辑
┌──────────▼────────────────────────┐
│ RTL 组件测试 │
│ (Node.js 环境,CI 执行) │
│ │
│ 使用相同的 Testing Library API: │
│ - screen / render │
│ - userEvent │
│ - expect() │
└───────────────────────────────────┘视觉回归测试:Chromatic
Chromatic 是 Storybook 团队提供的视觉回归测试平台。它自动对每个 Story 截图,并在 PR 中对比变化:
Chromatic 工作流程:
Push / PR
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Build │────▶│ Capture │────▶│ Compare │
│ Storybook │ │ Screenshots │ │ Snapshots │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
┌──────▼───────┐
│ 有变化? │
└──────┬───────┘
/ \
是 否
│ │
┌──────▼───┐ ┌──▼──────┐
│ 人工审核 │ │ 自动通过 │
│ Accept / │ └─────────┘
│ Deny │
└──────────┘配置 Chromatic(在 CI 中):
yaml
name: Chromatic
on: push
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}Storybook 与组件测试工具链的互补
组件质量保障的完整工具链:
┌─────────────────────────────────────────────────┐
│ 开发阶段 │
│ │
│ Storybook ──▶ 隔离开发、可视化调试、文档生成 │
│ │ │
│ └──▶ Play Function ──▶ 交互测试 │
└──────────┬──────────────────────────────────────┘
│
┌──────────▼──────────────────────────────────────┐
│ 测试阶段 │
│ │
│ RTL/VTL ──▶ 行为测试(CI 中运行) │
│ │ │
│ └──▶ 覆盖率 ──▶ Istanbul / v8 │
│ │
│ Chromatic ──▶ 视觉回归测试 │
└──────────┬──────────────────────────────────────┘
│
┌──────────▼──────────────────────────────────────┐
│ 集成阶段 │
│ │
│ Playwright ──▶ E2E 测试(真实浏览器) │
└─────────────────────────────────────────────────┘组件测试最佳实践
测试文件组织
推荐的项目结构:
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx ← 测试文件与组件同级
│ │ ├── Button.stories.tsx ← Storybook Story
│ │ └── index.ts
│ ├── LoginForm/
│ │ ├── LoginForm.tsx
│ │ ├── LoginForm.test.tsx
│ │ ├── LoginForm.stories.tsx
│ │ └── index.ts
│ └── ...
├── hooks/
│ ├── useAuth.ts
│ ├── useAuth.test.ts
│ └── ...
└── test/
├── setup.ts ← 全局 setup
├── test-utils.tsx ← 自定义 render 等工具
└── mocks/
├── handlers.ts ← MSW handlers
└── server.ts ← MSW server编写可维护测试的原则
1. 每个测试只验证一个行为
typescript
it('submits form and shows success message', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('姓名'), '张三')
await user.type(screen.getByLabelText('邮箱'), 'zhangsan@test.com')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('提交成功')).toBeInTheDocument()
})2. 避免测试实现细节
typescript
it('updates internal state on click', () => {
const { result } = renderHook(() => useState(0))
act(() => result.current[1](1))
expect(result.current[0]).toBe(1)
})
it('shows updated count after click', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '增加' }))
expect(screen.getByText('当前计数: 1')).toBeInTheDocument()
})3. 使用有意义的测试描述
typescript
describe('LoginForm', () => {
describe('validation', () => {
it('shows error when email is empty', async () => { })
it('shows error when password is too short', async () => { })
it('shows error for invalid email format', async () => { })
})
describe('submission', () => {
it('calls onSubmit with form data on valid submit', async () => { })
it('disables submit button while loading', async () => { })
it('shows success message after successful submit', async () => { })
})
describe('accessibility', () => {
it('focuses first invalid field on submit', async () => { })
it('announces errors to screen readers', async () => { })
})
})4. 使用 Page Object 模式封装复杂交互
typescript
function setupLoginForm() {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
return {
emailInput: () => screen.getByLabelText('邮箱'),
passwordInput: () => screen.getByLabelText('密码'),
submitButton: () => screen.getByRole('button', { name: '登录' }),
errorMessage: () => screen.queryByRole('alert'),
fillEmail: (email: string) => user.type(screen.getByLabelText('邮箱'), email),
fillPassword: (pwd: string) => user.type(screen.getByLabelText('密码'), pwd),
submit: () => user.click(screen.getByRole('button', { name: '登录' })),
onSubmit,
}
}
it('submits valid form', async () => {
const form = setupLoginForm()
await form.fillEmail('alice@test.com')
await form.fillPassword('password123')
await form.submit()
expect(form.onSubmit).toHaveBeenCalled()
})
it('shows validation error', async () => {
const form = setupLoginForm()
await form.submit()
expect(form.errorMessage()).toHaveTextContent('请填写所有字段')
})常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
container.querySelector('.btn') | 依赖 CSS 类名,易碎 | screen.getByRole('button') |
wrapper.instance().setState() | 测试实现细节 | 通过用户交互触发状态变化 |
| 快照测试滥用 | 变更频繁时快照毫无意义 | 只对稳定的输出做快照 |
sleep(1000) 等待异步 | 不可靠、浪费时间 | waitFor / findBy* |
一个 it 里测所有情况 | 难以定位失败原因 | 每个 it 只测一个行为 |
| 不清理副作用 | 测试互相影响 | afterEach(cleanup) |
| Mock 过多 | 失去集成价值 | 只 Mock 外部边界 |
面试高频问题
1. 为什么推荐 getByRole 而不是 getByTestId?
回答思路:
getByRole 基于 ARIA Role 查询,这与屏幕阅读器等辅助技术感知元素的方式一致。使用 getByRole 有三个好处:
- 测试更贴近用户视角:用户通过元素的角色(按钮、链接、文本框)来识别 UI,而不是
data-testid - 驱动更好的可访问性:如果你无法通过 Role 找到元素,说明该元素的可访问性存在问题
- 测试更稳健:HTML 结构和 CSS 类名可能变化,但元素的语义角色通常是稳定的
getByTestId 是最后的手段,只在元素没有可辨识的文本、Role 或标签时使用(如自定义图表、Canvas 等)。
2. fireEvent 和 userEvent 有什么区别?
回答思路:
fireEvent 直接派发单个 DOM 事件,而 userEvent 模拟完整的用户交互链路。
举例说明:当用户在输入框中输入文字时,实际会触发 focus → keydown → keypress → input → keyup 一系列事件。userEvent.type() 会模拟这个完整链路,而 fireEvent.change() 只触发 change 事件。
userEvent 还会正确处理:
- 点击被禁用的按钮时不会触发 click 事件
- 输入时会更新光标位置
- 键盘修饰键(Shift、Ctrl)的状态跟踪
因此在组件测试中应始终优先使用 userEvent。
3. 如何测试一个包含 useEffect 和 API 调用的组件?
回答思路:
核心思路是 Mock 网络请求,然后验证组件在不同阶段的渲染状态。有两种方案:
直接 Mock:用 vi.mock() 替换 API 模块,简单直接但与实现耦合。
MSW:在网络层拦截请求,更真实,推荐用于集成测试。
测试流程通常覆盖三个阶段:
- 加载态(loading):验证加载指示器是否显示
- 成功态(success):使用
findBy*等待异步数据渲染 - 错误态(error):Mock 失败响应,验证错误提示
关键点是不要 Mock useEffect 本身,让副作用正常触发,只控制外部依赖的返回值。
4. act() 警告是什么原因?如何解决?
回答思路:
act() 是 React 提供的测试工具函数,确保在断言之前所有状态更新和副作用都已执行完毕。当测试中出现 "not wrapped in act(...)" 警告时,意味着某些状态更新发生在 React 的预期控制流之外。
常见原因:
- 异步操作(
setTimeout、fetch)在测试完成后才触发状态更新 - 没有等待异步渲染完成就进行断言
解决方案:
- 使用
waitFor或findBy*等待异步更新 - 在
renderHook中使用act()包裹状态更新操作 - 确保在测试结束前所有 Promise 都已 resolve
RTL 的 render、userEvent 已经内部封装了 act(),大多数场景不需要手动调用。
5. 组件测试中如何处理定时器?
回答思路:
使用 Vitest/Jest 提供的 Fake Timer 来控制时间流逝:
typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows toast then hides after 3 seconds', async () => {
render(<Toast message="保存成功" duration={3000} />)
expect(screen.getByText('保存成功')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(3000)
})
expect(screen.queryByText('保存成功')).not.toBeInTheDocument()
})注意 Fake Timer 与 userEvent 的兼容问题:userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 可以让 userEvent 正确配合 Fake Timer 工作。
6. 什么时候应该用快照测试?什么时候不应该用?
回答思路:
快照测试适合用于:
- 稳定不变的输出:如格式化函数的输出、静态配置对象
- 纯展示组件:没有交互逻辑,只根据 props 渲染的组件
- 防止意外变更:作为安全网捕获非预期的 UI 变化
不适合用于:
- 频繁变动的组件:快照会频繁失败,开发者倾向于盲目更新
- 大型组件树:快照过大,审查 diff 毫无意义
- 替代行为测试:快照不能验证交互是否正确
最佳实践是用 toMatchInlineSnapshot() 做小范围快照,配合行为测试覆盖交互逻辑。
7. 如何在组件测试中测试无障碍性(a11y)?
回答思路:
三个层次的无障碍测试:
RTL 查询本身就是 a11y 测试:如果你能用
getByRole找到元素,说明基本的 ARIA 语义是正确的使用 jest-axe 做自动化检测:
typescript
import { axe } from 'jest-axe'
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})- Storybook + a11y addon:在开发阶段实时检测每个 Story 的无障碍问题
自动化工具只能发现约 30%~50% 的 a11y 问题,键盘导航和屏幕阅读器体验仍需人工测试。
8. React Testing Library 的 cleanup 机制是什么?
回答思路:
cleanup 函数会卸载通过 render 挂载的组件,清除 DOM 残留。在使用 Vitest 或 Jest 时,RTL 会自动在每个测试之后调用 cleanup(通过 afterEach 钩子)。
这意味着:
- 每个
it块都在全新的 DOM 环境中运行 - 不需要手动调用
cleanup() - 前一个测试渲染的组件不会影响后一个测试
如果使用了其他测试运行器且未自动 cleanup,则需手动添加:
typescript
import { cleanup } from '@testing-library/react'
afterEach(cleanup)延伸阅读
官方文档
推荐文章
- Common mistakes with React Testing Library — Kent C. Dodds
- Testing Implementation Details — Kent C. Dodds
- How to know what to test — Kent C. Dodds
- Write tests. Not too many. Mostly integration. — Kent C. Dodds
- Chromatic Visual Testing
工具生态
| 工具 | 定位 | 链接 |
|---|---|---|
| Vitest | 测试运行器(Vite 生态) | vitest.dev |
| Jest | 测试运行器(通用) | jestjs.io |
| Testing Library | 组件测试工具(框架无关) | testing-library.com |
| MSW | API Mock(网络层拦截) | mswjs.io |
| Storybook | 组件开发环境 | storybook.js.org |
| Chromatic | 视觉回归测试 | chromatic.com |
| Playwright | E2E 测试(组件测试模式) | playwright.dev |
| jest-axe | 无障碍自动化测试 | GitHub |