Skip to content

第 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单元测试老牌生态最大,文档全
VitestVite 配套测试框架极快、API 与 Jest 兼容
React Testing Library (RTL)React 组件测试鼓励"以用户视角测试"
Vue Test UtilsVue 组件测试Vue 官方
Playwright跨浏览器 E2E微软出品,多浏览器、稳定
CypressE2E 老牌调试体验最佳

2. 为什么需要它

2.1 没有测试会怎样?

  • 改一个函数,怀疑改坏了别处 → 手动点击全站验证(半天没了)
  • 重构没人敢动,技术债越堆越高
  • 凌晨 2 点上线发现 bug,回滚也不知道改了哪
  • 新人改老代码,不知道哪些是"不能动的逻辑"

2.2 有测试的好处

  • 每次改完,几秒内自动验证所有功能 → 改代码才有底气
  • 重构有 safety net,敢大刀阔斧地优化
  • bug 修复后写一个测试 case → 永远不会再回归
  • 测试代码本身就是最好的"使用文档"

3. 怎么用(最小示例)

3.1 Vitest 五分钟上手

bash
pnpm add -D vitest
pnpm vitest    # 启动测试 watcher
ts
// 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 对比

维度JestVitest
出品方MetaVite 团队(vue 社区)
速度慢(用 Babel 编译)极快(用 esbuild + Vite)
ESM 支持实验性、配置麻烦原生支持
TS 支持需 ts-jest 或 babel-jest内置
APIdescribe/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 test
ts
// 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 / istanbul
ts
// 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.ts

4.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() 总返回 0
  • IntersectionObserver 需手动 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. 一句话总结

测试是写给未来的"代码意图说明书"——让你三个月后改代码时还有底气;测试金字塔的本质是 多写快测、少写慢测、关键写到端到端


9. 延伸阅读