主题
测试
说明
共 10 题,难度 ⭐ ~ ⭐⭐⭐,覆盖单元测试、集成测试、E2E 测试、TDD、Mock、覆盖率、组件测试、Hooks 测试等前端测试体系。
1. 单元测试、集成测试、E2E 测试的区别?测试金字塔? ⭐⭐
理解不同测试层级的定位和价值。
考察点:测试分层
测试金字塔
/\
/ \
/ E2E \ 少量(慢、贵、模拟真实用户)
/________\
/ \
/ 集成测试 \ 适量(模块间协作)
/______________\
/ \
/ 单元测试 \ 大量(快、便宜、隔离)
/____________________\三种测试对比
| 维度 | 单元测试 | 集成测试 | E2E 测试 |
|---|---|---|---|
| 测试对象 | 单个函数/组件 | 多个模块协作 | 完整用户流程 |
| 运行速度 | ⚡ 毫秒级 | 🔶 秒级 | 🐌 分钟级 |
| 隔离程度 | 完全隔离(Mock 外部依赖) | 部分集成 | 无隔离(真实环境) |
| 发现的问题 | 逻辑错误 | 接口不匹配、集成问题 | 用户体验问题 |
| 维护成本 | 低 | 中 | 高 |
| 置信度 | 低(不测集成) | 中 | 高(最接近真实) |
| 工具 | Vitest / Jest | Vitest + MSW | Playwright / Cypress |
各层测试示例
typescript
// 单元测试: 测试纯函数
describe('formatPrice', () => {
it('格式化整数价格', () => {
expect(formatPrice(1000)).toBe('¥10.00')
})
it('处理 0 值', () => {
expect(formatPrice(0)).toBe('¥0.00')
})
})
// 集成测试: 测试组件 + Hook + API 交互
describe('UserProfile', () => {
it('加载并显示用户信息', async () => {
server.use(
http.get('/api/user/1', () => {
return HttpResponse.json({ name: 'Alice', email: 'alice@test.com' })
})
)
render(<UserProfile userId={1} />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@test.com')).toBeInTheDocument()
})
})
// E2E 测试: 测试完整登录流程
test('用户登录流程', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'alice@test.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('.welcome')).toContainText('Welcome, Alice')
})推荐比例
单元测试 : 集成测试 : E2E = 70% : 20% : 10%
但实际项目中:
- 重业务逻辑 → 多写单元测试(工具函数、状态管理)
- 重交互体验 → 多写集成测试(组件渲染 + 用户交互)
- 重关键路径 → 多写 E2E(登录、支付、核心流程)追问延伸
- 测试奖杯(Testing Trophy)模型和测试金字塔有什么区别?(Kent C. Dodds 提出,更重视集成测试)
- 什么是契约测试(Contract Testing)?前后端如何用 Pact 做契约测试?
- 如何决定一个功能该写什么级别的测试?
2. Vitest 和 Jest 的区别?为什么 Vitest 越来越流行? ⭐⭐
对比主流测试框架。
考察点:测试工具
Vitest vs Jest
| 维度 | Jest | Vitest |
|---|---|---|
| 构建工具 | 自带转换器 | 复用 Vite 配置 |
| 速度 | 较慢(每个文件独立 VM) | 更快(ESM + HMR) |
| ESM 支持 | 🟡 实验性 | ✅ 原生支持 |
| TypeScript | 需要 ts-jest / babel | ✅ 开箱即用 |
| 配置 | jest.config.js(独立) | vite.config.ts(复用) |
| 兼容性 | API 成熟、生态最大 | 兼容 Jest API |
| 热更新 | ❌ | ✅ watch 模式极快 |
| UI 界面 | ❌ | ✅ vitest --ui |
Vitest 配置
typescript
// vitest.config.ts (或直接在 vite.config.ts 中)
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
css: true,
},
})typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})Vitest 独有特性
typescript
// ① 内联快照
it('生成问候语', () => {
expect(greet('Alice')).toMatchInlineSnapshot(`"Hello, Alice!"`)
// 快照直接写在测试文件中 → 更直观
})
// ② 类型测试
import { assertType, expectTypeOf } from 'vitest'
test('类型检查', () => {
expectTypeOf(formatPrice).toBeFunction()
expectTypeOf(formatPrice).parameter(0).toBeNumber()
expectTypeOf(formatPrice).returns.toBeString()
})
// ③ 并发执行
describe.concurrent('并发测试', () => {
it.concurrent('测试 A', async () => { /* ... */ })
it.concurrent('测试 B', async () => { /* ... */ })
// A 和 B 并行执行 → 速度更快
})
// ④ 基准测试
import { bench, describe } from 'vitest'
describe('排序性能', () => {
bench('Array.sort', () => {
[3, 1, 2].sort()
})
bench('自定义排序', () => {
customSort([3, 1, 2])
})
})追问延伸
- 从 Jest 迁移到 Vitest 需要改什么?(大部分代码不用改,改配置即可)
jsdom和happy-dom环境有什么区别?- Vitest 的
--pool=forks和--pool=threads有什么区别?
3. 什么是 TDD?如何在实际项目中实践? ⭐⭐
理解测试驱动开发的流程和价值。
考察点:开发方法论
TDD 三步法
TDD = Test-Driven Development(测试驱动开发)
红 → 绿 → 重构:
① 🔴 Red: 先写一个失败的测试
→ 明确"这个功能该做什么"
② 🟢 Green: 写最少的代码让测试通过
→ 不要过度设计,只实现测试要求的功能
③ 🔵 Refactor: 在测试保护下重构代码
→ 优化代码质量,测试确保行为不变
循环: Red → Green → Refactor → Red → ...TDD 实战:实现 Todo List
typescript
// ① Red: 先写测试
describe('TodoList', () => {
it('初始状态为空列表', () => {
const todoList = new TodoList()
expect(todoList.getAll()).toEqual([])
})
})
// 运行 → ❌ TodoList is not defined
// ② Green: 最小实现
class TodoList {
private todos: Todo[] = []
getAll() { return [...this.todos] }
}
// 运行 → ✅ 通过
// ① Red: 添加下一个测试
it('添加一条 todo', () => {
const todoList = new TodoList()
todoList.add('学习 TDD')
expect(todoList.getAll()).toEqual([
{ id: expect.any(String), text: '学习 TDD', completed: false }
])
})
// 运行 → ❌ add is not a function
// ② Green
add(text: string) {
this.todos.push({ id: crypto.randomUUID(), text, completed: false })
}
// 运行 → ✅
// 继续: 完成功能、切换状态、删除...
it('切换 todo 完成状态', () => {
const todoList = new TodoList()
todoList.add('学习 TDD')
const [todo] = todoList.getAll()
todoList.toggle(todo.id)
expect(todoList.getAll()[0].completed).toBe(true)
})
// ③ Refactor: 提取接口、优化命名
interface Todo {
id: string
text: string
completed: boolean
}TDD 的适用场景
✅ 适合 TDD:
- 纯函数 / 工具库(明确输入输出)
- 状态管理逻辑(Redux reducer / Zustand actions)
- API 接口(请求 → 响应的映射)
- 算法题
🟡 可选 TDD:
- React 组件(先写 UI 再补测试也可以)
- 复杂交互(有时先原型再测试更高效)
❌ 不太适合 TDD:
- 探索性开发(需求不明确)
- 纯 UI 样式(视觉测试更合适)
- 第三方 API 集成(需要先了解 API 行为)追问延伸
- TDD 和 BDD(行为驱动开发)有什么区别?
- TDD 在敏捷开发中的角色?
- 如何说服团队采用 TDD?投入产出比如何衡量?
4. Mock 和 Stub 的区别?如何 Mock API 请求? ⭐⭐
掌握测试中的 Mock 技术。
考察点:Mock
Mock vs Stub vs Spy
Stub (桩):
→ 替换函数,返回预设值
→ 不关心调用方式,只控制返回值
Mock (模拟):
→ 替换函数,返回预设值 + 验证调用
→ 关心被调用了几次、用什么参数
Spy (间谍):
→ 不替换函数,保留原始行为
→ 只记录调用信息typescript
// Stub
const getUser = vi.fn().mockReturnValue({ name: 'Alice' })
// Mock
const sendEmail = vi.fn()
await registerUser({ email: 'alice@test.com' })
expect(sendEmail).toHaveBeenCalledWith('alice@test.com', expect.any(String))
expect(sendEmail).toHaveBeenCalledTimes(1)
// Spy
const consoleSpy = vi.spyOn(console, 'log')
doSomething()
expect(consoleSpy).toHaveBeenCalledWith('done')
consoleSpy.mockRestore()Mock 模块
typescript
// 模拟整个模块
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Alice' }),
fetchPosts: vi.fn().mockResolvedValue([]),
}))
// 部分模拟(保留其他导出)
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
formatDate: vi.fn().mockReturnValue('2024-01-01'),
}
})
// 模拟第三方库
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: { name: 'Alice' } }),
post: vi.fn().mockResolvedValue({ data: { id: 1 } }),
}
}))MSW(Mock Service Worker)— 推荐
typescript
// MSW: 在网络层拦截请求 → 不需要 Mock fetch/axios
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
http.get('/api/users/:id', ({ params }) => {
if (params.id === '999') {
return HttpResponse.json({ message: 'Not found' }, { status: 404 })
}
return HttpResponse.json({ id: params.id, name: 'Alice' })
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// 测试中可以临时覆盖
it('处理服务器错误', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ message: 'Server Error' }, { status: 500 })
})
)
render(<UserList />)
expect(await screen.findByText('加载失败')).toBeInTheDocument()
})MSW 的优势
vs 直接 Mock fetch/axios:
✅ 不侵入应用代码(拦截在网络层)
✅ 测试代码和生产代码走相同的 fetch/axios 路径
✅ 可以在浏览器和 Node.js 中使用(开发 + 测试)
✅ 支持 REST 和 GraphQL
✅ handler 可复用(测试 + Storybook + 开发环境)追问延伸
vi.fn()和vi.spyOn()应该在什么时候用哪个?- 如何 Mock 定时器?(
vi.useFakeTimers()+vi.advanceTimersByTime()) - MSW 在 Storybook 中怎么用?(
msw-storybook-addon)
5. 如何测试 React 组件?Testing Library 的核心原则? ⭐⭐⭐
掌握 React 组件的测试方法。
考察点:组件测试
Testing Library 核心原则
"The more your tests resemble the way your software is used,
the more confidence they can give you."
测试越像用户使用软件的方式 → 给你的信心越大
核心思想:
❌ 不要测试实现细节(state 的值、生命周期、内部方法)
✅ 测试用户看到什么、做了什么(渲染结果、交互行为)查询优先级
Testing Library 推荐的查询优先级:
① getByRole ← 最推荐(语义化,可访问性友好)
② getByLabelText ← 表单元素
③ getByPlaceholderText
④ getByText ← 按文本查找
⑤ getByDisplayValue
⑥ getByAltText ← 图片
⑦ getByTitle
⑧ getByTestId ← 最后手段(添加 data-testid)
// ✅ 好的查询
screen.getByRole('button', { name: '提交' })
screen.getByRole('heading', { level: 1 })
screen.getByLabelText('邮箱')
// ❌ 不好的查询
container.querySelector('.submit-btn')
screen.getByTestId('submit-button')完整组件测试示例
typescript
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('')
const handleSubmit = async (e) => {
e.preventDefault()
if (!email || !password) {
setError('请填写所有字段')
return
}
await 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 && <p role="alert">{error}</p>}
<button type="submit">登录</button>
</form>
)
}
// 测试
describe('LoginForm', () => {
const user = userEvent.setup()
it('提交表单调用 onSubmit', async () => {
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
await user.type(screen.getByLabelText('密码'), 'password123')
await user.click(screen.getByRole('button', { name: '登录' }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@test.com',
password: 'password123',
})
})
it('空表单提交显示错误', async () => {
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: '登录' }))
expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段')
})
it('初始状态没有错误提示', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})异步组件测试
typescript
it('加载用户列表', async () => {
// MSW 已配置 /api/users 返回数据
render(<UserList />)
// 等待加载状态消失
expect(screen.getByText('加载中...')).toBeInTheDocument()
// 等待数据出现
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
// 验证加载状态消失
expect(screen.queryByText('加载中...')).not.toBeInTheDocument()
})追问延伸
userEvent和fireEvent的区别?为什么推荐userEvent?- 如何测试 Portal 组件?(
render自动挂载到 document.body) screen.getByRole的 ARIA Role 有哪些?怎么查?
6. 如何测试自定义 Hooks? ⭐⭐
测试 React 自定义 Hooks。
考察点:Hooks 测试
renderHook
typescript
import { renderHook, act } from '@testing-library/react'
// 被测试的 Hook
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 }
}
// 测试
describe('useCounter', () => {
it('初始值为 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('支持自定义初始值', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increment 增加计数', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('reset 重置到初始值', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.increment()
})
expect(result.current.count).toBe(7)
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(5)
})
})测试异步 Hook
typescript
function useUser(userId: string) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { setUser(data); setLoading(false) })
.catch(err => { setError(err); setLoading(false) })
}, [userId])
return { user, loading, error }
}
// 测试(配合 MSW)
describe('useUser', () => {
it('加载用户数据', async () => {
const { result } = renderHook(() => useUser('1'))
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeNull()
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user).toEqual({ id: '1', name: 'Alice' })
expect(result.current.error).toBeNull()
})
it('userId 变化时重新请求', async () => {
const { result, rerender } = renderHook(
({ userId }) => useUser(userId),
{ initialProps: { userId: '1' } }
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.user.name).toBe('Alice')
rerender({ userId: '2' })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.user.name).toBe('Bob')
})
})测试需要 Context 的 Hook
typescript
function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
// 测试: 用 wrapper 提供 Context
it('从 ThemeContext 获取主题', () => {
const wrapper = ({ children }) => (
<ThemeProvider value={{ theme: 'dark', toggle: vi.fn() }}>
{children}
</ThemeProvider>
)
const { result } = renderHook(() => useTheme(), { wrapper })
expect(result.current.theme).toBe('dark')
})
it('没有 Provider 时抛出错误', () => {
expect(() => {
renderHook(() => useTheme())
}).toThrow('useTheme must be used within ThemeProvider')
})追问延伸
act()是什么?为什么需要包裹状态更新?(确保 React 完成所有渲染和副作用)- 如何测试
useEffect的清理函数? - Hooks 测试和直接渲染使用该 Hook 的组件测试,哪种更好?
7. 测试覆盖率该追求多少?覆盖率高就代表质量好吗? ⭐⭐
正确理解测试覆盖率的意义。
考察点:测试质量
覆盖率类型
四种覆盖率指标:
① 语句覆盖 (Statement Coverage):
代码中有多少语句被执行过
→ 最基本的指标
② 分支覆盖 (Branch Coverage):
if/else/switch 的每个分支是否都被执行
→ 比语句覆盖更严格
③ 函数覆盖 (Function Coverage):
有多少函数被调用过
④ 行覆盖 (Line Coverage):
有多少行代码被执行过覆盖率的陷阱
javascript
// 100% 覆盖率但测试毫无价值:
function add(a, b) {
return a + b
}
test('调用 add', () => {
add(1, 2) // ← 100% 覆盖!但没有 expect 断言
// 不检查返回值 → 即使 add 返回 "hello" 也会通过
})
// 高覆盖率 ≠ 高质量
// → 覆盖率衡量的是"哪些代码被执行了"
// → 不衡量"执行的结果是否正确"
// 真正有价值的测试:
test('add 返回两数之和', () => {
expect(add(1, 2)).toBe(3)
expect(add(-1, 1)).toBe(0)
expect(add(0, 0)).toBe(0)
})推荐的覆盖率策略
通用建议:
总体覆盖率 → 80% 以上(不追求 100%)
关键业务逻辑 → 90%+ (支付、认证、核心算法)
工具函数 → 95%+ (纯函数,容易测试)
UI 组件 → 70%+ (关注交互行为,不测样式)
配置代码 → 可以跳过
Vitest 覆盖率配置:
// vitest.config.ts
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
exclude: [
'src/**/*.stories.tsx',
'src/**/*.d.ts',
'src/test/',
],
}
}
CI 中强制覆盖率:
→ 覆盖率低于阈值 → CI 失败 → 不允许合并比覆盖率更重要的指标
① 变异测试 (Mutation Testing):
工具自动修改源代码(如 + 改成 -)
→ 如果测试仍然通过 → 说明测试不够好
→ 工具: Stryker
② 测试的可读性:
好的测试 = 好的文档
→ 别人读测试就能理解功能规格
③ 测试的稳定性:
Flaky Test(偶尔通过偶尔失败)比没有测试更糟
→ 浪费 CI 时间、降低团队信任度追问延伸
- 如何处理 Flaky Test?(隔离环境、固定时间、重试机制)
- 变异测试具体怎么做?(Stryker 修改操作符/条件/返回值)
- 如何在团队中推行测试文化?从哪里开始?
8. E2E 测试用 Playwright 还是 Cypress?如何编写稳定的 E2E 测试? ⭐⭐
对比 E2E 测试工具并掌握最佳实践。
考察点:E2E 测试
Playwright vs Cypress
| 维度 | Playwright | Cypress |
|---|---|---|
| 开发者 | Microsoft | Cypress.io |
| 浏览器 | Chromium + Firefox + WebKit | Chrome + Firefox + Edge |
| 多标签页 | ✅ | ❌ |
| iframe | ✅ | 🟡 有限支持 |
| 并行执行 | ✅ 内置 | 需要付费 Dashboard |
| 速度 | 更快 | 较慢 |
| API 风格 | async/await | 链式调用 |
| 调试体验 | 好(Trace Viewer) | 极好(Time Travel) |
| 上手难度 | 中 | 简单 |
Playwright 示例
typescript
import { test, expect } from '@playwright/test'
test.describe('购物车', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: '登录' }).click()
await page.getByLabel('邮箱').fill('test@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/dashboard')
})
test('添加商品到购物车', async ({ page }) => {
await page.goto('/products')
await page.getByText('MacBook Pro').click()
await page.getByRole('button', { name: '加入购物车' }).click()
await expect(page.getByTestId('cart-count')).toHaveText('1')
await page.getByRole('link', { name: '购物车' }).click()
await expect(page.getByText('MacBook Pro')).toBeVisible()
})
test('修改商品数量', async ({ page }) => {
await page.goto('/cart')
await page.getByLabel('数量').fill('3')
await page.getByLabel('数量').press('Enter')
await expect(page.getByTestId('total-price')).toContainText('¥29,997')
})
})E2E 最佳实践
typescript
// ① 使用 data-testid 而非 CSS 选择器
// ❌ page.locator('.btn-primary.submit-form') → 样式变了就挂
// ✅ page.getByRole('button', { name: '提交' }) → 语义化、稳定
// ② 等待而非 sleep
// ❌ await page.waitForTimeout(3000)
// ✅ await expect(page.getByText('加载完成')).toBeVisible()
// ③ 独立的测试数据
test.beforeEach(async ({ request }) => {
await request.post('/api/test/reset')
await request.post('/api/test/seed', { data: { users: testUsers } })
})
// ④ Page Object 模式(复杂项目)
class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.getByLabel('邮箱').fill(email)
await this.page.getByLabel('密码').fill(password)
await this.page.getByRole('button', { name: '登录' }).click()
}
async expectLoginSuccess() {
await expect(this.page).toHaveURL('/dashboard')
}
}
test('登录', async ({ page }) => {
const loginPage = new LoginPage(page)
await page.goto('/login')
await loginPage.login('test@example.com', 'password')
await loginPage.expectLoginSuccess()
})Playwright 配置
typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
})追问延伸
- Playwright 的
codegen怎么用?(自动生成测试代码) - 如何在 CI 中运行 E2E 测试?(Docker / GitHub Actions + webServer)
- 视觉回归测试(Visual Regression)怎么做?(Playwright 截图对比 / Percy)
9. 如何测试异步代码?定时器 / Promise / API 请求? ⭐⭐
掌握异步测试的各种模式。
考察点:异步测试
测试 Promise
typescript
// 被测函数
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('User not found')
return res.json()
}
// ① async/await(推荐)
it('获取用户信息', async () => {
const user = await fetchUser('1')
expect(user.name).toBe('Alice')
})
// ② 测试异步错误
it('用户不存在时抛出错误', async () => {
await expect(fetchUser('999')).rejects.toThrow('User not found')
})
// ③ resolves / rejects 匹配器
it('返回用户对象', async () => {
await expect(fetchUser('1')).resolves.toEqual(
expect.objectContaining({ name: 'Alice' })
)
})测试定时器
typescript
// 被测函数
function debounce(fn: Function, delay: number) {
let timer: number
return function(...args: any[]) {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('延迟执行', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
})
it('多次调用只执行最后一次', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn('a')
debouncedFn('b')
debouncedFn('c')
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
})测试 setInterval
typescript
function createPoller(fn: () => Promise<void>, interval: number) {
let timer: number | null = null
return {
start() { timer = setInterval(fn, interval) },
stop() { if (timer) clearInterval(timer) },
}
}
it('按间隔轮询', async () => {
vi.useFakeTimers()
const fn = vi.fn().mockResolvedValue(undefined)
const poller = createPoller(fn, 1000)
poller.start()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(fn).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(3000)
expect(fn).toHaveBeenCalledTimes(4)
poller.stop()
vi.advanceTimersByTime(5000)
expect(fn).toHaveBeenCalledTimes(4)
vi.useRealTimers()
})测试 waitFor
typescript
import { waitFor } from '@testing-library/react'
it('等待异步状态更新', async () => {
render(<AsyncComponent />)
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('等待元素消失', async () => {
render(<AsyncComponent />)
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})追问延伸
vi.runAllTimers()和vi.advanceTimersByTime()的区别?- 如何测试
requestAnimationFrame? - 异步测试超时了怎么调试?(增加 timeout、检查 Promise 是否正确 resolve)
10. 如何在 CI 中集成测试?测试策略设计? ⭐⭐
设计适合团队的测试策略和 CI 集成。
考察点:测试工程化
CI 中的测试流水线
yaml
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: pnpm build
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/测试策略
按项目阶段:
新项目 / MVP:
→ 只写关键路径的 E2E 测试
→ 核心业务逻辑的单元测试
→ 不追求覆盖率
成长期项目:
→ 新代码必须有测试(PR 卡覆盖率)
→ 增加集成测试(组件 + API)
→ 关键流程 E2E
成熟项目:
→ 完善的测试金字塔
→ 视觉回归测试
→ 性能基准测试
→ 变异测试该测什么,不该测什么
✅ 该测:
- 业务逻辑函数(输入输出明确)
- 用户交互流程(表单提交、导航)
- 边界情况(空值、超长输入、并发)
- 错误处理路径(网络失败、权限不足)
- 回归 Bug(每个 Bug 修复都加一个测试)
❌ 不该测:
- 第三方库的内部实现
- 纯样式(用视觉测试)
- 实现细节(内部 state、私有方法)
- 一次性脚本
- 常量配置文件前端测试完整工具链
单元 + 集成测试:
框架: Vitest
断言: expect (Vitest 内置)
DOM: @testing-library/react
交互: @testing-library/user-event
Mock: MSW (API) + vi.mock (模块)
E2E 测试:
框架: Playwright
模式: Page Object Model
报告: HTML Reporter
辅助工具:
覆盖率: @vitest/coverage-v8
视觉回归: Chromatic / Percy
可访问性: jest-axe
类型测试: expectTypeOf (Vitest)
CI 集成:
GitHub Actions / GitLab CI
→ PR 触发: lint + typecheck + unit test + e2e test
→ 合并触发: build + deploy + smoke test测试命名规范
typescript
// 推荐: 描述行为而非实现
describe('购物车', () => {
it('添加商品后数量增加 1', () => {})
it('删除唯一商品后显示空购物车提示', () => {})
it('超过库存数量时显示库存不足提示', () => {})
it('未登录时跳转到登录页', () => {})
})
// 格式: "当 [条件] 时,应该 [行为]"
// 或: "[操作] 后 [预期结果]"追问延伸
- 如何衡量测试的 ROI?什么测试性价比最高?
- 如何处理测试代码的重复?测试也需要 DRY 吗?(适度 DRY,但测试的可读性优先)
- Snapshot 测试的优缺点?什么时候该用?(API 响应、组件输出固化;但容易成为"批准"测试)