JavaScript单元测试与TDD

张玥 2026年9月17日 阅读时间 55分钟
JavaScript 单元测试 TDD Jest Vitest 测试驱动开发
JavaScript单元测试与TDD

很多人写 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 + 少量单元 → 慢、脆、难维护 ❌
E2E · 10%
集成测试 · 20%
单元测试 · 70%
越往下:越快 · 越便宜 · 越稳定
越往上:越真实 · 越昂贵 · 越易碎

对于大多数前端项目,单元测试是投入产出比最高的一层:它运行快、定位准、几乎不受环境影响。本文后续内容也主要聚焦在这一层。

测试框架与工具链选型

JavaScript 生态中的测试工具非常丰富。理解它们各自的定位,比记住 API 更重要。

/* 安装 Jest(最主流的一站式方案) */
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
零配置 · 内置断言与 Mock · 快照测试
Vitest
Vite 原生 · HMR · 与 Jest API 兼容
Mocha
灵活 · 需搭配断言库 · 生态成熟
Testing Library
面向用户行为 · DOM 测试标准

新项目推荐 Jest 或 Vitest,二选一即可

从第一个单元测试开始

先写一个最简单的被测函数,然后为它写测试。注意:测试文件与被测文件通常放在同一目录,命名以 .test.js 或 .spec.js 结尾。

// math.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');
  });
});
$ npm test
PASS ./math.test.js
  math 工具函数
    ✓ add 应返回两数之和 (3 ms)
    ✓ divide 除数为 0 时应抛错 (1 ms)
Tests: 2 passed, 2 total
describe 分组 · test 用例 · expect 断言

常用断言与匹配器大全

匹配器(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);
toBe
严格相等
toEqual
深比较
toContain
包含
toThrow
抛异常
toMatch
正则匹配
toHaveLength
长度
toBe 用于原始值,toEqual 用于对象/数组,混用是最常见的错误。

TDD 三步曲:红 · 绿 · 重构

TDD(Test-Driven Development,测试驱动开发)的核心是一个极简的循环:先写一个会失败的测试(红),再写最少的代码让它通过(绿),最后在不改变行为的前提下优化结构(重构)。

/* ===== 第 1 步:红(Red) ===== */
// 先写测试,此时函数还不存在
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);
}
// 所有测试仍然通过 ✅ 且实现更健壮
① RED 红
写一个失败的测试,明确"我要什么"
↓
② GREEN 绿
写最少代码让它通过,先别管优雅
↓
③ REFACTOR 重构
测试全绿的前提下,消除重复、改善命名
↻ 循环往复

TDD 的精髓在于"小步快跑"。每一步的跨度越小,你就越清楚失败的原因。很多初学者写 TDD 时会一次性写完 10 个测试,然后埋头实现——这其实违背了 TDD 的节奏。

TDD 实战:从零实现一个购物车

下面用 TDD 的方式,完整实现一个具备添加、删除、计算总价、应用折扣能力的购物车模块。注意观察每一步测试如何驱动设计。

/* 第 1 轮:空购物车总价为 0 */
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);
});
最终实现(测试驱动产出)
class Cart {
  constructor() { this.items = []; }
  add(item) { this.items.push(item); }
  remove(id) {
    this.items = this.items.filter(i => i.id !== id);
  }
  subtotal() {
    return this.items.reduce((s, i) => s + i.price * i.qty, 0);
  }
  total() {
    const s = this.subtotal();
    return s >= 1000 ? s - 100 : s;
  }
}
5 个测试全绿 接口自然浮现 无需提前设计类

注意一个细节:subtotal() 这个方法是在重构阶段被提取出来的,最初并不存在。这就是 TDD 的魔力——它不要求你一开始就设计完美,好的结构会在循环中自然生长出来。

测试替身:Mock / Stub / Spy

真实代码往往依赖网络、时间、随机数、第三方 SDK。这些依赖会让测试变得不稳定。测试替身(Test Double)就是用来"伪装"这些依赖的工具。

/* 1. jest.fn() —— 创建模拟函数 */
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);
Dummy 占位对象
只为填参数,从不被真正使用
Stub 桩
返回预设值,不关心调用过程
Spy 间谍
记录调用信息,可保留原实现
Mock 模拟对象
完全替代依赖,可校验交互

一个重要的原则:Mock 越多,测试越脆。如果一段代码需要 mock 五个依赖才能测试,那往往是设计出了问题——它承担了太多职责。优先考虑重构,而不是增加 mock。

异步代码的测试

Promise、async/await、回调、定时器——异步是 JavaScript 测试中最容易出错的领域。核心原则是:确保测试框架等待异步操作完成。

/* 1. async / await(推荐) */
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();
  });
});
❌ 常见错误
test('x', () => {
  fetchUser(1).then(u => {
    expect(u).toBeDefined();
  });
});
测试会在 Promise 完成前结束
✅ 正确写法
test('x', async () => {
  const u = await fetchUser(1);
  expect(u).toBeDefined();
});
框架会等待 Promise 解析

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 次');
});
查询优先级(Testing Library 推荐)
1. getByRole —— 最接近无障碍语义
2. getByLabelText —— 表单推荐
3. getByText —— 可见文本
4. getByTestId —— 最后手段
避免测试 class 名、内部 state 等实现细节

测试覆盖率与质量指标

覆盖率是衡量测试完整性的参考指标,但它不是目标本身。理解四种覆盖率的含义,比追求 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'
  ]
};
语句覆盖
每行代码都执行过
分支覆盖
if/else 两侧都走过
函数覆盖
每个函数都被调用
行覆盖
可执行行被执行
100% 覆盖率 ≠ 没有 Bug,它只说明"这些代码被执行过",不代表断言正确。

测试的组织与命名规范

好的测试代码同样需要良好的组织。命名清晰的测试,本身就是最好的文档。

/* 推荐的目录结构 */
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(() => { /* 只执行一次 */ });
AAA 模式
Arrange 准备数据与依赖
Act 执行被测行为
Assert 断言结果
一个测试只验证一件事,失败时能一眼定位原因

CI 集成与持续测试

测试只有跑起来才有价值。把测试接入 CI,让每次提交都自动验证,才能真正发挥防护网的作用。

# .github/workflows/test.yml
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
提交代码 / 发起 PR
↓
自动运行 Lint + 单元测试
↓
全绿 → 允许合并
失败 → 阻止合并

常见反模式与避坑指南

写了测试不等于测试有效。以下这些反模式,会让测试变成"看起来很努力"的负担。

/* ❌ 反模式 1:测试实现细节 */
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();
// 几乎永远不会失败,等于没测
✗ 测试私有方法
✗ 依赖执行顺序
✗ 断言模糊(toBeDefined)
✗ 过度 Mock 导致测了个寂寞
✗ 测试里有 console.log 残留
✓ 只测公开行为
✓ 每个用例独立可重跑

单元测试与 TDD 最佳实践

  • 测试行为,不测试实现:断言"输入什么得到什么",而非内部状态
  • 一个测试一个概念:失败时能立刻知道哪里坏了
  • 保持测试快速:单元测试套件应在数秒内跑完,否则没人愿意跑
  • 测试独立可重复:不依赖顺序、不依赖外部状态
  • 先写测试再写实现:TDD 让设计自然浮现,而非事后补测试
  • 小步快跑:每次只增加一个失败测试,只实现让它通过的最小代码
  • 重构有测试护航:红灯不重构,绿灯才重构
  • 覆盖率是参考不是目标:80% 的有效测试胜过 100% 的敷衍测试
  • 把测试纳入 CI:让防护网自动运行,而非依赖自觉
  • 测试代码也是代码:同样要命名清晰、消除重复、保持可读
/* TDD 的黄金循环 */
/* 红 → 绿 → 重构 → 红 → 绿 → 重构 ... */

/* 一个完整的测试用例模板 */
describe('模块名', () => {
  beforeEach(() => {
    // Arrange:重置环境
  });

  test('在什么场景下应得到什么结果', () => {
    // Act:调用被测方法
    const result = target(input);

    // Assert:验证结果
    expect(result).toEqual(expected);
  });
});

测试不是额外的工作量,而是把"调试时间"提前转化为"设计时间"。当你习惯 TDD 的节奏后,会发现写测试的那几分钟,往往帮你省下了后面几小时的排错。从今天开始,为你下一个函数写下第一个测试吧。