很多人写 JavaScript 的习惯是"先写功能,跑起来能看就行",直到某天改了一行代码,某个角落的页面突然崩了,才意识到测试的价值。单元测试不是给代码增加负担,而是给未来的自己买一份保险。而 TDD(测试驱动开发)则更进一步——它不只是一种测试技术,更是一种设计方法:先写测试,再写实现,让测试反过来驱动出更清晰、更解耦的代码结构。本文将系统讲解 JavaScript 单元测试的完整知识体系:从测试金字塔、断言与匹配器、Jest/Vitest 工具链,到 TDD 的红-绿-重构循环、测试替身、异步测试、覆盖率治理与 CI 集成,并配有大量可直接运行的示例。55 分钟,带你从"不会写测试"到"离不开测试"。
为什么需要单元测试
在讨论"怎么写"之前,先要回答"为什么要写"。单元测试的价值并不只是"发现 Bug",它至少带来四层收益:
// 改了一行代码
function calcTotal(items) {
return items.reduce((s, i) => s + i.price, 0);
}
// 结果:手动点开 20 个页面验证
// 耗时 2 小时,仍然心里没底 😰
/* 有测试时,重构是这样的 */
test('计算订单总价', () => {
expect(calcTotal([{ price: 10 }, { price: 20 }])).toBe(30);
});
// 改完代码,运行 1 秒,绿灯通过 ✅
其中最容易被忽视的是最后一条:可测试性与设计质量高度正相关。当一个函数依赖了全局变量、隐式时间、网络请求、随机数,你会发现它根本无法测试——这恰恰说明它的耦合度太高了。写测试的过程,其实是在不断追问"这个模块的边界在哪里"。
测试金字塔与测试分层
测试并不是只有一种。按照粒度从细到粗,业界通常用"测试金字塔"来描述不同层次的测试配比。
// 1. 单元测试 Unit Test
// 粒度:单个函数 / 类 / 模块
// 速度:毫秒级,数量最多(约 70%)
// 2. 集成测试 Integration Test
// 粒度:多个模块协作 / API 层
// 速度:百毫秒级(约 20%)
// 3. 端到端测试 E2E Test
// 粒度:真实浏览器完整流程
// 速度:秒级甚至分钟级(约 10%)
/* 反模式:冰淇淋甜筒 */
// 大量 E2E + 少量单元 → 慢、脆、难维护 ❌
对于大多数前端项目,单元测试是投入产出比最高的一层:它运行快、定位准、几乎不受环境影响。本文后续内容也主要聚焦在这一层。
测试框架与工具链选型
JavaScript 生态中的测试工具非常丰富。理解它们各自的定位,比记住 API 更重要。
npm install --save-dev jest
/* 在 package.json 中配置脚本 */
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
}
}
/* 使用 Vitest(Vite 项目首选,速度极快) */
npm install --save-dev vitest
"test": "vitest"
/* 传统组合:Mocha + Chai + Sinon */
npm install --save-dev mocha chai sinon
新项目推荐 Jest 或 Vitest,二选一即可
从第一个单元测试开始
先写一个最简单的被测函数,然后为它写测试。注意:测试文件与被测文件通常放在同一目录,命名以 .test.js 或 .spec.js 结尾。
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为 0');
}
return a / b;
}
// math.test.js —— 测试文件
import { add, divide } from './math';
describe('math 工具函数', () => {
test('add 应返回两数之和', () => {
expect(add(2, 3)).toBe(5);
});
test('divide 除数为 0 时应抛错', () => {
expect(() => divide(1, 0)).toThrow('除数不能为 0');
});
});
常用断言与匹配器大全
匹配器(Matcher)决定了"什么样的结果算通过"。掌握常用匹配器,能让你的断言既准确又可读。
expect(2 + 2).toBe(4); // 严格相等 ===
expect({ a: 1 }).toEqual({ a: 1 }); // 深比较
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点
/* 真值 / 空值 */
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(0).toBeFalsy();
expect('x').toBeTruthy();
/* 数字 / 字符串 */
expect(10).toBeGreaterThan(5);
expect('hello world').toMatch(/world/);
/* 数组 / 集合 */
expect([1, 2, 3]).toContain(2);
expect([1, 2]).toHaveLength(2);
/* 异常 */
expect(() => fn()).toThrow();
/* 否定:加 .not */
expect(1).not.toBe(2);
严格相等
深比较
包含
抛异常
正则匹配
长度
TDD 三步曲:红 · 绿 · 重构
TDD(Test-Driven Development,测试驱动开发)的核心是一个极简的循环:先写一个会失败的测试(红),再写最少的代码让它通过(绿),最后在不改变行为的前提下优化结构(重构)。
// 先写测试,此时函数还不存在
test('邮箱校验:合法邮箱返回 true', () => {
expect(isValidEmail('a@b.com')).toBe(true);
});
// 运行 → 报错 isValidEmail is not defined ❌
/* ===== 第 2 步:绿(Green) ===== */
// 用最直白的方式让测试通过
function isValidEmail(email) {
return email.includes('@');
}
// 运行 → 通过 ✅
/* ===== 第 3 步:重构(Refactor) ===== */
// 补充边界测试,再改进实现
test('邮箱校验:缺少 @ 返回 false', () => {
expect(isValidEmail('abc.com')).toBe(false);
});
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// 所有测试仍然通过 ✅ 且实现更健壮
TDD 的精髓在于"小步快跑"。每一步的跨度越小,你就越清楚失败的原因。很多初学者写 TDD 时会一次性写完 10 个测试,然后埋头实现——这其实违背了 TDD 的节奏。
TDD 实战:从零实现一个购物车
下面用 TDD 的方式,完整实现一个具备添加、删除、计算总价、应用折扣能力的购物车模块。注意观察每一步测试如何驱动设计。
test('新购物车总价应为 0', () => {
const cart = new Cart();
expect(cart.total()).toBe(0);
});
/* 第 2 轮:添加商品后总价正确 */
test('添加两件商品后总价应为 3000', () => {
const cart = new Cart();
cart.add({ name: '键盘', price: 1000, qty: 1 });
cart.add({ name: '显示器', price: 2000, qty: 1 });
expect(cart.total()).toBe(3000);
});
/* 第 3 轮:数量参与计算 */
test('数量为 2 时总价翻倍', () => {
const cart = new Cart();
cart.add({ name: '鼠标', price: 100, qty: 2 });
expect(cart.total()).toBe(200);
});
/* 第 4 轮:移除商品 */
test('移除商品后总价减少', () => {
const cart = new Cart();
cart.add({ id: 1, price: 500, qty: 1 });
cart.remove(1);
expect(cart.total()).toBe(0);
});
/* 第 5 轮:折扣 */
test('满 1000 减 100 的折扣应正确应用', () => {
const cart = new Cart();
cart.add({ price: 600, qty: 2 });
expect(cart.total()).toBe(1100);
});
注意一个细节:subtotal() 这个方法是在重构阶段被提取出来的,最初并不存在。这就是 TDD 的魔力——它不要求你一开始就设计完美,好的结构会在循环中自然生长出来。
测试替身:Mock / Stub / Spy
真实代码往往依赖网络、时间、随机数、第三方 SDK。这些依赖会让测试变得不稳定。测试替身(Test Double)就是用来"伪装"这些依赖的工具。
const fn = jest.fn();
fn('hello');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith('hello');
/* 2. 模拟返回值 */
const getUser = jest.fn().mockResolvedValue({
id: 1, name: '张玥'
});
/* 3. 监视对象方法 */
const spy = jest.spyOn(console, 'log');
doSomething();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
/* 4. 模块级 Mock */
jest.mock('./api');
import { fetchUser } from './api';
fetchUser.mockResolvedValue({ name: 'mock' });
/* 5. 模拟定时器 */
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
一个重要的原则:Mock 越多,测试越脆。如果一段代码需要 mock 五个依赖才能测试,那往往是设计出了问题——它承担了太多职责。优先考虑重构,而不是增加 mock。
异步代码的测试
Promise、async/await、回调、定时器——异步是 JavaScript 测试中最容易出错的领域。核心原则是:确保测试框架等待异步操作完成。
test('获取用户信息', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('张玥');
});
/* 2. 断言 Promise 被拒绝 */
test('无效 ID 应抛出错误', async () => {
await expect(fetchUser(-1)).rejects.toThrow('用户不存在');
});
/* 3. 成功场景用 resolves */
test('成功返回用户', async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});
/* 4. 测试定时器 */
jest.useFakeTimers();
test('延迟 1 秒后执行回调', () => {
const cb = jest.fn();
delayedRun(cb, 1000);
jest.advanceTimersByTime(1000);
expect(cb).toHaveBeenCalledTimes(1);
});
/* 5. 回调风格(注意 done) */
test('回调应被调用', (done) => {
loadData((data) => {
expect(data).toBeDefined();
done();
});
});
fetchUser(1).then(u => {
expect(u).toBeDefined();
});
});
const u = await fetchUser(1);
expect(u).toBeDefined();
});
DOM 与组件测试
前端代码离不开 DOM。推荐使用 Testing Library 系列,它的核心理念是:测试用户能看到、能操作的东西,而不是实现细节。
function createCounter(container) {
let count = 0;
const btn = document.createElement('button');
btn.textContent = '点击 0 次';
btn.addEventListener('click', () => {
count++;
btn.textContent = `点击 ${count} 次`;
});
container.appendChild(btn);
return btn;
}
/* 测试:使用 @testing-library/dom */
import { getByRole, fireEvent } from '@testing-library/dom';
test('点击按钮后文案更新', () => {
const container = document.createElement('div');
createCounter(container);
const btn = getByRole(container, 'button');
expect(btn.textContent).toBe('点击 0 次');
fireEvent.click(btn);
expect(btn.textContent).toBe('点击 1 次');
});
测试覆盖率与质量指标
覆盖率是衡量测试完整性的参考指标,但它不是目标本身。理解四种覆盖率的含义,比追求 100% 更重要。
npx jest --coverage
/* jest.config.js 中配置阈值 */
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
collectCoverageFrom: [
'src/**/*.{js,ts}',
'!src/**/*.test.js'
]
};
测试的组织与命名规范
好的测试代码同样需要良好的组织。命名清晰的测试,本身就是最好的文档。
src/
utils/
format.js
format.test.js // 就近放置 ✅
components/
Button.js
Button.test.js
__tests__/ // 或集中放置
integration.test.js
/* 测试命名三要素 */
// 被测单元 + 场景 + 期望结果
describe('formatPrice', () => {
test('传入整数时返回带两位小数的字符串', () => {});
test('传入负数时保留负号', () => {});
test('传入非数字时抛出 TypeError', () => {});
});
/* 生命周期钩子 */
beforeEach(() => { /* 每个用例前 */ });
afterEach(() => { /* 每个用例后清理 */ });
beforeAll(() => { /* 只执行一次 */ });
CI 集成与持续测试
测试只有跑起来才有价值。把测试接入 CI,让每次提交都自动验证,才能真正发挥防护网的作用。
name: Test
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
常见反模式与避坑指南
写了测试不等于测试有效。以下这些反模式,会让测试变成"看起来很努力"的负担。
expect(obj._internalCache).toBe(null);
// 一旦内部实现调整,测试就碎
/* ❌ 反模式 2:一个测试断言太多 */
test('所有功能', () => {
expect(add(1, 2)).toBe(3);
expect(sub(5, 2)).toBe(3);
expect(mul(2, 3)).toBe(6);
});
// 第一个失败后面就不执行了,信息损失
/* ❌ 反模式 3:测试之间互相依赖 */
let sharedState;
test('A 写入', () => { sharedState = 1; });
test('B 读取', () => { expect(sharedState).toBe(1); });
// 单独运行 B 必然失败
/* ❌ 反模式 4:断言过于宽松 */
expect(result).toBeDefined();
// 几乎永远不会失败,等于没测
单元测试与 TDD 最佳实践
- 测试行为,不测试实现:断言"输入什么得到什么",而非内部状态
- 一个测试一个概念:失败时能立刻知道哪里坏了
- 保持测试快速:单元测试套件应在数秒内跑完,否则没人愿意跑
- 测试独立可重复:不依赖顺序、不依赖外部状态
- 先写测试再写实现:TDD 让设计自然浮现,而非事后补测试
- 小步快跑:每次只增加一个失败测试,只实现让它通过的最小代码
- 重构有测试护航:红灯不重构,绿灯才重构
- 覆盖率是参考不是目标:80% 的有效测试胜过 100% 的敷衍测试
- 把测试纳入 CI:让防护网自动运行,而非依赖自觉
- 测试代码也是代码:同样要命名清晰、消除重复、保持可读
/* 红 → 绿 → 重构 → 红 → 绿 → 重构 ... */
/* 一个完整的测试用例模板 */
describe('模块名', () => {
beforeEach(() => {
// Arrange:重置环境
});
test('在什么场景下应得到什么结果', () => {
// Act:调用被测方法
const result = target(input);
// Assert:验证结果
expect(result).toEqual(expected);
});
});
测试不是额外的工作量,而是把"调试时间"提前转化为"设计时间"。当你习惯 TDD 的节奏后,会发现写测试的那几分钟,往往帮你省下了后面几小时的排错。从今天开始,为你下一个函数写下第一个测试吧。