JavaScript 测试策略与最佳实践
JavaScript 测试策略与最佳实践
测试是保证代码质量的重要手段。它的本质是:用可执行的代码去验证另一段代码的行为是否符合预期。JavaScript 生态有非常丰富的测试工具和框架,从最小的单元测试到完整的端到端测试,构成了一整套质量防护网。
可以把测试想象成给代码买的"保险":写测试时要花一点成本,但一旦未来有人(包括未来的你自己)改坏了逻辑,测试会立刻报警,而不是等到线上用户投诉才发现。
为什么测试很重要
没有测试的代码是"信仰驱动开发"。 每次改动都要靠人肉回归、靠祈祷不出 bug。随着代码规模增长,这种方式会迅速崩溃。
真实数据可以说明问题:
| 阶段 | 发现一个 bug 的平均修复成本 | 说明 |
| --- | --- | --- |
| 编码阶段(本地测试拦截) | 1x | 改几行代码即可 |
| 代码评审 / CI 阶段 | 5x | 需要重新走流程 |
| 测试 / QA 阶段 | 10x | 需要提 bug、复现、回归 |
| 生产环境 | 30x~100x | 影响用户、可能需要热修复、回滚、公关 |
这就是著名的"缺陷放大效应":bug 发现得越晚,修复成本呈指数级上升。测试的核心价值就是把 bug 尽可能拦截在最左侧(编码阶段),这也是"测试左移(Shift Left)"理念的由来。
测试类型
不同层级的测试解决不同的问题,业界常用"测试金字塔"来描述它们的合理比例。
/\
/E2E\ 少量(约 10%):慢、贵、脆弱,但最接近真实用户
/------\
/ 集成 \ 适量(约 20%):验证模块协作
/----------\
/ 单元测试 \ 大量(约 70%):快、稳、便宜
/--------------\单元测试(Unit Test):
集成测试(Integration Test):
端到端测试(E2E Test):
性能测试:
安全测试:
主流测试工具
测试框架:
断言库:
组件与 E2E 工具:
模拟(Mock)工具:
断言与 Matcher 速查
// 相等性
expect(2 + 2).toBe(4); // 严格相等(Object.is)
expect({ a: 1 }).toEqual({ a: 1 }); // 深度相等
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 部分匹配
// 真值判断
expect(value).toBeTruthy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 数值
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数比较必用
expect(10).toBeGreaterThan(5);
// 字符串 / 数组 / 对象
expect('hello world').toContain('world');
expect(['a', 'b']).toContain('a');
expect([1, 2, 3]).toHaveLength(3);
expect({ name: 'Alice' }).toHaveProperty('name');
// 异常
expect(() => { throw new Error('boom'); }).toThrow('boom');
// 取反
expect(1 + 1).not.toBe(3);单元测试实战
// math.js
export function sum(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new Error('除数不能为 0');
return a / b;
}// math.test.js
import { sum, divide } from './math';
describe('sum 函数', () => {
test('两个正数相加', () => {
expect(sum(1, 2)).toBe(3);
});
test('处理负数', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('处理零', () => {
expect(sum(0, 0)).toBe(0);
});
});
describe('divide 函数', () => {
test('正常除法', () => {
expect(divide(10, 2)).toBe(5);
});
// 边界与异常测试往往比正常路径更重要
test('除数为 0 时抛出错误', () => {
expect(() => divide(10, 0)).toThrow('除数不能为 0');
});
});AAA 模式:好测试的骨架
一个清晰的测试应遵循 AAA(Arrange-Act-Assert)三段式:
test('购物车计算总价', () => {
// Arrange:准备数据和环境
const cart = new Cart();
cart.add({ name: '书', price: 50, qty: 2 });
cart.add({ name: '笔', price: 10, qty: 3 });
// Act:执行被测行为
const total = cart.getTotal();
// Assert:验证结果
expect(total).toBe(130);
});异步代码测试
异步是 JavaScript 测试最容易出错的地方。核心原则:一定要 return 或 await 异步断言,否则测试会在断言完成前就"通过"。
// 被测代码
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('用户不存在');
return res.json();
}
// 正确写法:async/await
test('成功获取用户', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
// 测试异步异常
test('用户不存在时抛错', async () => {
await expect(fetchUser(999)).rejects.toThrow('用户不存在');
});
// resolves / rejects 匹配器
test('Promise 解析出正确值', async () => {
await expect(Promise.resolve('ok')).resolves.toBe('ok');
});Mock 与依赖隔离
单元测试要"隔离",就必须把网络、时间、随机数等不确定因素 mock 掉。
// 1. 模拟一个函数并断言调用
test('回调被正确调用', () => {
const callback = jest.fn();
[1, 2, 3].forEach(callback);
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(2, 1, [1, 2, 3]);
});
// 2. 模拟整个模块
jest.mock('./api');
import { getData } from './api';
test('使用 mock 的 API', async () => {
getData.mockResolvedValue({ id: 1, name: 'Mock' });
const result = await getData();
expect(result.name).toBe('Mock');
});
// 3. 模拟定时器(避免真的等待)
test('防抖函数只触发一次', () => {
jest.useFakeTimers();
const fn = jest.fn();
const debounced = debounce(fn, 1000);
debounced();
debounced();
debounced();
jest.advanceTimersByTime(1000); // 快进时间
expect(fn).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});用 MSW 模拟网络请求
MSW 在网络层拦截请求,比 mock fetch 更真实,且前端联调和测试可复用同一套 handler。
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('从 API 获取用户', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});React 组件测试
React Testing Library 的哲学是"测试用户看到和操作的东西,而非组件内部状态"。
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
import Counter from './Counter';
test('Button 渲染文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByText('点击我')).toBeInTheDocument();
});
test('Button 点击时触发 onClick', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>点击我</Button>);
fireEvent.click(screen.getByText('点击我'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
// 用 userEvent 模拟更真实的用户交互
test('计数器点击后数字增加', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /增加/i });
await user.click(button);
await user.click(button);
expect(screen.getByText('当前计数:2')).toBeInTheDocument();
});端到端测试实战(Playwright)
import { test, expect } from '@playwright/test';
test('用户登录流程', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('input[name="password"]', 'secret123');
await page.click('button[type="submit"]');
// Playwright 自动等待元素出现,无需手动 sleep
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByText('欢迎,Alice')).toBeVisible();
});测试覆盖率
覆盖率衡量"测试执行到了多少代码",但高覆盖率 ≠ 高质量。它只能告诉你哪些代码没被测到,无法保证被测到的代码断言充分。
| 指标 | 含义 | 说明 |
| --- | --- | --- |
| 语句覆盖率 | 执行了多少条语句 | 最基础的指标 |
| 分支覆盖率 | if/else、三元等分支覆盖情况 | 最能反映测试充分度 |
| 函数覆盖率 | 调用了多少个函数 | 发现"从未被测的函数" |
| 行覆盖率 | 执行了多少行 | 与语句覆盖率接近 |
经验值:核心业务逻辑建议 80% 以上分支覆盖率;工具函数可追求接近 100%;UI 展示层可适当放宽。盲目追求 100% 会导致大量低价值测试,反而拖慢迭代。
# Jest 生成覆盖率报告
jest --coverage
# Vitest 生成覆盖率报告
vitest run --coverage真实案例:一次线上事故的复盘
某电商结算模块曾发生"优惠券叠加后总价为负"的线上事故,原因是没有针对"优惠金额大于订单金额"这个边界写测试。复盘后补充的测试:
describe('优惠券计算边界', () => {
test('优惠金额不应使总价为负', () => {
const order = { amount: 30 };
const coupon = { discount: 50 };
const finalPrice = applyCoupon(order, coupon);
expect(finalPrice).toBeGreaterThanOrEqual(0);
expect(finalPrice).toBe(0); // 封底为 0,而非 -20
});
});教训:正常路径谁都会写,真正拦住事故的往往是边界与异常测试。
测试生命周期钩子
几乎所有框架都提供四个生命周期钩子,用来做准备和清理。理解它们的执行顺序是写出干净、隔离测试的前提。
describe('用户服务', () => {
let db;
beforeAll(async () => {
// 整个 describe 只运行一次:建立数据库连接等昂贵操作
db = await connectTestDB();
});
beforeEach(async () => {
// 每个 test 前运行:重置数据,保证测试隔离
await db.reset();
await db.seed({ users: [{ id: 1, name: 'Alice' }] });
});
afterEach(() => {
// 每个 test 后运行:清理副作用,如恢复 mock
jest.restoreAllMocks();
});
afterAll(async () => {
// 整个 describe 结束后运行一次:关闭连接、释放资源
await db.close();
});
test('查询用户', async () => {
const user = await getUser(db, 1);
expect(user.name).toBe('Alice');
});
});
// 执行顺序:beforeAll → (beforeEach → test → afterEach) × N → afterAll核心原则:优先用 `beforeEach` 重置状态而非 `beforeAll`。 共享状态是 flaky test 的头号来源——一个测试的残留数据会污染下一个,且测试的通过与否会依赖执行顺序。
参数化测试:一次写法,多组数据
对同一逻辑用多组输入验证时,别复制粘贴,用参数化(table-driven)测试,报告更清晰、维护更省心。
// Jest / Vitest 的 test.each
describe('isEmail 校验', () => {
test.each([
['alice@example.com', true],
['bad-email', false],
['a@b.co', true],
['@nodomain.com', false],
['', false],
])('isEmail(%s) 应返回 %s', (input, expected) => {
expect(isEmail(input)).toBe(expected);
});
});
// 也支持对象数组 + 模板字符串,可读性更好
test.each([
{ a: 1, b: 2, sum: 3 },
{ a: -1, b: 1, sum: 0 },
{ a: 0, b: 0, sum: 0 },
])('sum($a, $b) = $sum', ({ a, b, sum }) => {
expect(add(a, b)).toBe(sum);
});快照测试:用与慎用
快照测试把某次运行的输出序列化存到文件,之后每次运行都和存档比对,适合防止 UI 结构或复杂对象意外变化。
test('用户卡片渲染结构', () => {
const { container } = render(<UserCard user={{ name: 'Alice', role: 'admin' }} />);
expect(container).toMatchSnapshot();
});
// 内联快照:把快照直接写进测试文件,小对象更直观
test('格式化配置', () => {
expect(formatConfig({ port: 8080 })).toMatchInlineSnapshot(`
"port=8080"
`);
});快照测试的陷阱: 快照过大时没人真正 review,一句 `--updateSnapshot` 就盲目更新,等于没测。最佳实践是——只对小而稳定的输出快照,大组件优先用针对性断言。
测试替身(Test Doubles)五种类型
"mock"是个笼统说法,实际上测试替身有五种,用途各异。
| 类型 | 作用 | 典型场景 |
| --- | --- | --- |
| Dummy | 只为填参数,从不使用 | 占位一个必填参数 |
| Stub | 提供预设返回值 | 让依赖返回固定数据 |
| Spy | 记录调用信息,可保留真实实现 | 断言"某函数被调用过" |
| Mock | 预设期望并验证交互 | 断言调用次数与参数 |
| Fake | 有简化的真实实现 | 内存数据库替代真库 |
// Spy:监视真实方法是否被调用,但不改变其行为
const spy = jest.spyOn(console, 'log');
logMessage('hello');
expect(spy).toHaveBeenCalledWith('[INFO] hello');
spy.mockRestore();
// Stub:替换实现返回固定值
const getConfig = jest.fn().mockReturnValue({ theme: 'dark' });
// Mock:设定期望并断言交互
const mailer = { send: jest.fn() };
notifyUser(mailer, user);
expect(mailer.send).toHaveBeenCalledTimes(1);
expect(mailer.send).toHaveBeenCalledWith('alice@example.com', expect.any(String));测试可访问性与语义查询
Testing Library 提供一组按优先级排列的查询方法,优先用"用户能感知的"方式定位元素,这样测试既贴近真实使用,又顺带验证了可访问性。
// 查询优先级(从高到低):
// 1. getByRole —— 最推荐,贴近辅助技术
screen.getByRole('button', { name: /提交/i });
screen.getByRole('heading', { level: 1 });
// 2. getByLabelText —— 表单元素首选
screen.getByLabelText('用户名');
// 3. getByPlaceholderText / getByText
screen.getByText('欢迎回来');
// 4. getByTestId —— 兜底,前面都不行时才用
screen.getByTestId('custom-widget');
// getBy 找不到会抛错;queryBy 找不到返回 null(用于断言"不存在")
expect(screen.queryByText('错误提示')).not.toBeInTheDocument();
// findBy 返回 Promise,用于等待异步出现的元素
expect(await screen.findByText('加载完成')).toBeInTheDocument();处理定时器、日期与随机数
不确定因素是 flaky test 的根源,必须全部"钉死"。
// 1. 假定时器
test('轮询每 5 秒触发一次', () => {
jest.useFakeTimers();
const cb = jest.fn();
startPolling(cb, 5000);
jest.advanceTimersByTime(15000); // 快进 15 秒
expect(cb).toHaveBeenCalledTimes(3);
jest.useRealTimers();
});
// 2. 固定系统时间
test('生成的时间戳固定', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
expect(createRecord().createdAt).toBe('2026-01-01T00:00:00.000Z');
jest.useRealTimers();
});
// 3. 固定随机数
test('抽奖结果可预测', () => {
jest.spyOn(Math, 'random').mockReturnValue(0.42);
expect(drawLottery()).toBe('三等奖');
});集成测试实战:API 层
集成测试验证多个模块协作。以 Express 接口为例,用 supertest 直接对应用发请求,配合内存数据库,无需真实网络。
import request from 'supertest';
import { app } from './app';
import { db } from './db';
describe('POST /api/users', () => {
beforeEach(() => db.reset());
test('成功创建用户返回 201', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Bob', email: 'bob@example.com' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'Bob' });
// 验证真的写进了库
const saved = await db.users.findByEmail('bob@example.com');
expect(saved).not.toBeNull();
});
test('缺少邮箱返回 400', async () => {
const res = await request(app).post('/api/users').send({ name: 'Bob' });
expect(res.status).toBe(400);
});
});TDD 完整循环:红-绿-重构
测试驱动开发是"先写测试,再写实现"。看一个完整循环——实现一个 FizzBuzz。
// 第 1 步 红:先写一个必然失败的测试
test('3 的倍数返回 Fizz', () => {
expect(fizzbuzz(3)).toBe('Fizz'); // fizzbuzz 还不存在,测试报红
});
// 第 2 步 绿:写最简实现让它通过
function fizzbuzz(n) {
if (n % 3 === 0) return 'Fizz';
return String(n);
}
// 第 3 步 加测试逼出更多逻辑
test('5 的倍数返回 Buzz', () => expect(fizzbuzz(5)).toBe('Buzz'));
test('15 的倍数返回 FizzBuzz', () => expect(fizzbuzz(15)).toBe('FizzBuzz'));
// 第 4 步 重构:在测试保护下放心整理代码
function fizzbuzz(n) {
let out = '';
if (n % 3 === 0) out += 'Fizz';
if (n % 5 === 0) out += 'Buzz';
return out || String(n);
}TDD 的价值不在测试本身,而在于它逼你先想清楚"这段代码到底该做什么",产出的代码天然可测、接口更清晰。研究表明 TDD 团队缺陷密度通常降低 40% 到 80%,代价是初期开发时间增加约 15% 到 35%。
在 CI 中运行测试
# GitHub Actions 示例:每次 PR 自动跑测试并卡覆盖率
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage --ci配合覆盖率阈值配置,覆盖率下降会直接让 CI 失败:
// jest.config.js
export default {
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 85, statements: 85 },
},
};工具对比
| 维度 | Jest | Vitest | Mocha |
| --- | --- | --- | --- |
| 配置成本 | 低(零配置) | 极低(复用 Vite 配置) | 高(需自行组装) |
| 启动速度 | 中等 | 快(esbuild) | 中等 |
| ESM 支持 | 需额外配置 | 原生 | 需配置 |
| 内置断言/mock | 有 | 有 | 无(需 Chai/Sinon) |
| 快照测试 | 支持 | 支持 | 需插件 |
| 适用场景 | React / Next 等 | Vite 生态 | 高度自定义需求 |
常见坑
最佳实践
测试设计:
测试维护:
测试与开发流程:
性能考虑:
总结
| 要点 | 核心结论 |
| --- | --- |
| 为什么测试 | bug 越晚发现成本越高(可达 100x),测试把缺陷拦在最左侧 |
| 测试金字塔 | 单元测试为主(70%),集成适量(20%),E2E 少量(10%) |
| 框架选择 | React 项目用 Jest,Vite 项目用 Vitest,E2E 用 Playwright |
| 好测试特征 | 独立、快速、可读、测行为不测实现、覆盖边界 |
| 覆盖率 | 参考而非目标,核心逻辑 80%+ 分支覆盖,别盲目追 100% |
| 最大陷阱 | 异步忘 await、共享状态、过度 mock、flaky test |
一句话:测试不是为了追求覆盖率数字,而是为了让你敢于随时重构和发布。