主题
第 19 章 · 测试(Vitest / Jest / RTL / Playwright)
一句话开篇:本章你将学会用代码"自动验证代码"——单元测试、集成测试、E2E 测试一条龙。
0. 生活类比(先建立直觉)
0.1 测试金字塔 = 餐厅出品质检
一家餐厅出菜要经历三层把关:
E2E 测试 = 客人吃完打分
(慢、贵、最接近真实)
▲
╱ ╲
╱ ╲
╱ 集成测试 = 上桌前主厨试味
╱ (多模块协作)
╱──────────╲
╱ ╲
╱ 单元测试 = 切配工自己尝盐量
╱ (快、便宜、最多)
╱──────────────────╲底层最多、最快,越往上越少、越慢。这就是测试金字塔——70% 单元测试 + 20% 集成测试 + 10% E2E 测试。
0.2 单元测试 = 检查每一颗螺丝
把代码拆成最小单元(一个函数、一个组件),每个单独测试是否符合预期。"输入 1+1,期望输出 2"。
0.3 Mock = 替身演员
测试 下单 函数,但下单要调真实支付接口——这不行。Mock 就像找一个替身演员:
- "我假装是支付接口,你下单时调我,我马上回 success"
- 不需要真去刷卡、不会污染数据库
0.4 快照测试 = 出菜照片对比
第一次做出"宫保鸡丁"拍张照存档(snapshot)。下次再做,自动跟存档对比——颜色、摆盘、份量任何不一样都报警。
0.5 覆盖率 = 检查清单的完成度
100 项检查清单,你只检查了 60 项 → 覆盖率 60%。代码覆盖率类似:哪些代码被测试"碰过"了。
0.6 E2E 测试 = 完整就餐流程演练
雇一个"神秘客户"(Playwright):进店、点单、付款、用餐、评价——每一步都按真实操作走一遍,验证整个流程没问题。
1. 概念是什么
1.1 测试分层
| 类型 | 测试什么 | 速度 | 数量 | 工具 |
|---|---|---|---|---|
| 单元测试 | 单个函数/组件 | ⚡⚡⚡ 极快 | 最多 | Vitest / Jest |
| 集成测试 | 多模块协作 | ⚡⚡ 中 | 中 | Vitest + Testing Library |
| E2E 测试 | 完整用户流程 | ⚡ 慢 | 最少 | Playwright / Cypress |
1.2 主流工具
| 工具 | 定位 | 强项 |
|---|---|---|
| Jest | 单元测试老牌 | 生态最大,文档全 |
| Vitest | Vite 配套测试框架 | 极快、API 与 Jest 兼容 |
| React Testing Library (RTL) | React 组件测试 | 鼓励"以用户视角测试" |
| Vue Test Utils | Vue 组件测试 | Vue 官方 |
| Playwright | 跨浏览器 E2E | 微软出品,多浏览器、稳定 |
| Cypress | E2E 老牌 | 调试体验最佳 |
2. 为什么需要它
2.1 没有测试会怎样?
- 改一个函数,怀疑改坏了别处 → 手动点击全站验证(半天没了)
- 重构没人敢动,技术债越堆越高
- 凌晨 2 点上线发现 bug,回滚也不知道改了哪
- 新人改老代码,不知道哪些是"不能动的逻辑"
2.2 有测试的好处
- 每次改完,几秒内自动验证所有功能 → 改代码才有底气
- 重构有 safety net,敢大刀阔斧地优化
- bug 修复后写一个测试 case → 永远不会再回归
- 测试代码本身就是最好的"使用文档"
3. 怎么用(最小示例)
3.1 Vitest 五分钟上手
bash
pnpm add -D vitest
pnpm vitest # 启动测试 watcherts
// math.ts
export const add = (a: number, b: number) => a + b;
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add } from './math';
describe('add', () => {
it('正常相加', () => {
expect(add(1, 2)).toBe(3);
});
it('负数相加', () => {
expect(add(-1, 1)).toBe(0);
});
});bash
$ pnpm vitest
✓ math.test.ts (2 tests) 3ms
Test Files 1 passed (1)
Tests 2 passed (2)3.2 测试一个 React 组件
tsx
// Button.tsx
export function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
it('点击触发回调', () => {
const fn = vi.fn();
render(<Button onClick={fn}>提交</Button>);
fireEvent.click(screen.getByText('提交'));
expect(fn).toHaveBeenCalledTimes(1);
});4. 进阶要点
4.1 Vitest vs Jest 对比
| 维度 | Jest | Vitest |
|---|---|---|
| 出品方 | Meta | Vite 团队(vue 社区) |
| 速度 | 慢(用 Babel 编译) | 极快(用 esbuild + Vite) |
| ESM 支持 | 实验性、配置麻烦 | 原生支持 |
| TS 支持 | 需 ts-jest 或 babel-jest | 内置 |
| API | describe/it/expect | 几乎相同(drop-in 替换) |
| 配置 | 单独的 jest.config | 与 vite.config.ts 共用 |
| Snapshot | ✅ | ✅ |
| Watch 模式 | ✅ | ✅(更智能) |
选型建议(2026):新项目用 Vitest,老 Jest 项目可平滑迁移(API 一致)。
4.2 Vitest 核心 API
ts
import { describe, it, test, expect, beforeEach, afterEach, vi } from 'vitest';
describe('计算器', () => {
let calc;
beforeEach(() => { calc = new Calculator(); });
afterEach(() => { /* 清理 */ });
// it 和 test 等价
it('加法', () => {
expect(calc.add(1, 2)).toBe(3);
});
// 跳过
it.skip('暂不测', () => {});
// 只跑这一个
it.only('调试用', () => {});
// 异步
it('异步加载', async () => {
const data = await fetchData();
expect(data).toEqual({ ok: true });
});
// 参数化(一次写多个 case)
it.each([
[1, 1, 2],
[2, 3, 5],
[-1, 1, 0],
])('add(%i, %i) = %i', (a, b, expected) => {
expect(calc.add(a, b)).toBe(expected);
});
});4.3 expect 常用匹配器
ts
// 相等
expect(v).toBe(2); // ===
expect(obj).toEqual({ a: 1 }); // 深度相等
expect(obj).toStrictEqual({ a: 1 }); // 更严格
// 真假
expect(v).toBeTruthy();
expect(v).toBeFalsy();
expect(v).toBeNull();
expect(v).toBeUndefined();
expect(v).toBeDefined();
// 数字
expect(n).toBeGreaterThan(3);
expect(n).toBeLessThanOrEqual(10);
expect(n).toBeCloseTo(0.3, 2); // 浮点比较
// 字符串
expect(s).toMatch(/hello/);
expect(s).toContain('world');
// 数组
expect(arr).toContain(1);
expect(arr).toHaveLength(3);
// 对象
expect(obj).toHaveProperty('user.name', 'Alice');
// 异常
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('特定错误');
// Promise
await expect(promise).resolves.toBe('ok');
await expect(promise).rejects.toThrow();
// 函数调用
expect(mock).toHaveBeenCalled();
expect(mock).toHaveBeenCalledTimes(2);
expect(mock).toHaveBeenCalledWith(1, 'a');4.4 Mock 三种姿势
A. Mock 函数
ts
const fn = vi.fn(); // 空 mock
const fn = vi.fn(() => 42); // 带实现
const fn = vi.fn().mockReturnValue(42);
const fn = vi.fn().mockResolvedValue({ ok: true });
fn(1, 2);
expect(fn).toHaveBeenCalledWith(1, 2);
expect(fn.mock.calls).toEqual([[1, 2]]);B. Mock 模块(替换整个文件)
ts
// 测试 user.ts,它依赖 api.ts
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));
import { getUserName } from './user';
import { fetchUser } from './api';
it('返回用户名', async () => {
expect(await getUserName(1)).toBe('Alice');
expect(fetchUser).toHaveBeenCalledWith(1);
});C. Mock 时间
ts
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('防抖 300ms', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
});4.5 快照测试(Snapshot)
ts
it('Button 渲染输出', () => {
const { container } = render(<Button>提交</Button>);
expect(container.innerHTML).toMatchSnapshot();
});
// 第一次跑:自动生成 __snapshots__/xxx.snap
// 第二次跑:与 snap 对比,不一致就失败
// 故意更新:vitest --update适用场景:组件渲染输出、配置对象、序列化结构。
⚠️ 慎用场景:测试用例的真正业务断言不应该全靠 snapshot——容易"看不懂为啥失败、随手 update 通过了事"。
4.6 React Testing Library 哲学
核心原则:以用户视角测试,而不是测试实现细节。
| ❌ 旧式测试 | ✅ RTL 风格 |
|---|---|
| 找 class、id 选元素 | 找按文本、role、label |
| 测内部 state | 测渲染输出 |
| Mock 子组件 | 测组件协作 |
| 调用组件方法 | 模拟用户点击 |
常用查询方法:
ts
// getBy* 找不到抛错(用于断言一定存在)
// queryBy* 找不到返回 null(用于断言不存在)
// findBy* 异步等待(返回 Promise)
screen.getByText('提交');
screen.getByRole('button', { name: '提交' });
screen.getByLabelText('用户名');
screen.getByPlaceholderText('请输入');
screen.getByTestId('submit-btn'); // 实在没法语义化时的兜底
// 用户事件(推荐用 userEvent 而非 fireEvent)
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'hello');4.7 Playwright E2E 简介
bash
pnpm create playwright
pnpm playwright testts
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('用户登录', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.getByLabel('用户名').fill('alice');
await page.getByLabel('密码').fill('123456');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByText('欢迎回来')).toBeVisible();
});Playwright 强项:
- 跨浏览器(Chromium / Firefox / WebKit)
- 自动等待(不需要 sleep)
- 录制脚本(
playwright codegen) - 截图、视频、追踪(trace viewer)
- 并行执行
4.8 覆盖率(Coverage)
四大指标:
| 指标 | 含义 |
|---|---|
| Statements | 语句被执行的比例 |
| Branches | 分支(if/else、三元)被覆盖的比例 |
| Functions | 函数被调用的比例 |
| Lines | 代码行被执行的比例 |
bash
pnpm vitest --coverage
# 默认用 v8 provider,也可装 c8 / istanbults
// vitest.config.ts
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
branches: 70,
functions: 80,
statements: 80,
},
exclude: ['**/*.test.ts', '**/types.ts'],
},
}⚠️ 覆盖率不是越高越好:
- 80% 是大多数项目的实用目标
- 100% 覆盖率往往伴随大量低价值测试(如测 getter)
- 覆盖率高 ≠ 质量高:可能只是"代码跑过了",没断言任何事
4.9 测试文件组织
src/
├── utils/
│ ├── format.ts
│ └── format.test.ts ← 同目录(推荐)
└── components/
├── Button.tsx
└── Button.test.tsx
tests/ ← 集成测试
└── api/
└── user.test.ts
e2e/ ← E2E 测试
└── login.spec.ts4.10 测试金字塔实战比例
▲
╱ ╲ E2E (10%)
╱ ╲ 关键用户旅程:登录→下单→支付
╱─────╲
╱ ╲ 集成 (20%)
╱ ╲ 多组件协作、API 集成
╱───────────╲
╱ ╲
╱ 单元 (70%) ╲ 纯函数、工具方法、组件本地行为
╱─────────────────╲反模式(冰淇淋甜筒):E2E 占 70%,单元占 10%——慢、脆、定位难。
5. 实战案例
详见 examples/ 目录:
vitest-basic.test.ts— Vitest 基础示例react-testing-library.test.jsx— React 组件测试playwright.spec.ts— Playwright E2E 示例
6. ⚠️ 易踩的坑
坑 1:测试实现细节而非行为
tsx
// ❌ 测试 useState 的 setter 被调用
expect(setCount).toHaveBeenCalled();
// ✅ 测试用户看到的结果
expect(screen.getByText('点击了 1 次')).toBeInTheDocument();重构组件实现(state → reducer)时前者会崩,后者依然通过——这才是稳定测试。
坑 2:异步测试忘了 await
ts
// ❌ 测试可能在断言前就结束
it('异步', () => {
fetchData().then(d => expect(d).toBe(1));
});
// ✅
it('异步', async () => {
const d = await fetchData();
expect(d).toBe(1);
});坑 3:测试间共享状态
ts
let counter = 0;
it('A', () => { counter++; expect(counter).toBe(1); });
it('B', () => { counter++; expect(counter).toBe(1); }); // 失败✅ 用 beforeEach 重置状态,避免测试顺序依赖。
坑 4:Mock 太多导致测试无意义
ts
vi.mock('./moduleA');
vi.mock('./moduleB');
vi.mock('./moduleC');
// 真实代码全被 mock,测的是 mock 自己原则:只 mock 外部边界(API、文件、时间),内部模块尽量真实。
坑 5:snapshot 滥用
ts
expect(component).toMatchSnapshot();
// 任何样式调整都让 snap 失败 → 团队开始"--update 一把梭"
// snap 失去断言价值✅ 关键路径用显式 expect,snap 仅用于"长期不变的稳定输出"。
坑 6:E2E 测试 sleep
ts
// ❌ 脆弱
await page.click('#submit');
await sleep(1000);
expect(...).toBe(...);
// ✅ Playwright 自动等待,或显式等条件
await page.click('#submit');
await expect(page.getByText('成功')).toBeVisible();坑 7:覆盖率追求 100%
为了凑数写空测试 → 增加维护成本 → 重构时一片红。80% 通常足够,关键模块 90%+。
坑 8:JSDOM vs 真实浏览器差异
Vitest/Jest 默认环境是 JSDOM(模拟 DOM)。某些 API 不一致:
getBoundingClientRect()总返回 0IntersectionObserver需手动 mock- 动画/canvas/WebGL 不可用
✅ DOM 行为强相关的测试用 Playwright 跑真实浏览器。
坑 9:测试运行慢
- 拆分
vitest --shard多进程 - 标记
it.concurrent让独立测试并行 - 避免测试中调真实网络/数据库
- CI 用
--coverage但本地vitest watch不带
坑 10:测试文件被打进 bundle
确保 vite.config 的 build.exclude 排除测试文件,或用 *.test.ts 命名约定+构建工具自动忽略。
7. 最佳实践 Checklist
- [ ] 新项目用 Vitest,老 Jest 项目可平滑迁移
- [ ] 单元测试与源文件同目录共存(如
Button.tsx+Button.test.tsx) - [ ] React 组件测试用 Testing Library,专注用户行为
- [ ] 测试遵循 AAA 模式:Arrange / Act / Assert
- [ ] 用
userEvent而非fireEvent(更接近真实用户) - [ ] Mock 只在边界做(外部 API、时间、文件系统)
- [ ] 异步测试一定
await - [ ] 使用
beforeEach重置状态 - [ ] 覆盖率目标 80%,关键模块 90%+
- [ ] 关键用户旅程写 E2E(登录 / 下单 / 支付)
- [ ] CI 中分别跑 unit / e2e(e2e 用真实浏览器)
- [ ] 测试名要描述业务语义而非实现:"点击提交后显示成功提示"
- [ ] 失败时第一时间能看出错的是什么(避免巨大 snapshot diff)
- [ ] 不要为了凑覆盖率写空测试
8. 一句话总结
测试是写给未来的"代码意图说明书"——让你三个月后改代码时还有底气;测试金字塔的本质是 多写快测、少写慢测、关键写到端到端。