Jest 实战:JavaScript/TypeScript 单元测试与 TDD

FreeGuideOnline 最新 2026-06-12

Jest 实战:JavaScript/TypeScript 单元测试与 TDD

为什么需要单元测试

单元测试能保障代码质量,减少回归缺陷,让重构更有信心。Jest 是 Facebook 开源的测试框架,零配置、内置断言库、支持快照和模拟,是目前最流行的 JavaScript 测试工具。结合测试驱动开发(TDD),可在编写实现代码前先定义预期行为,强制思考设计,产出高内聚、低耦合的模块。

环境准备与安装

确保本地已安装 Node.js(推荐 v16+),然后通过 npm 或 yarn 初始化项目:

mkdir jest-tdd-demo && cd jest-tdd-demo
npm init -y
npm install --save-dev jest

如果使用 TypeScript,额外安装类型声明和 ts-jest:

npm install --save-dev typescript @types/jest ts-jest

在项目根目录创建 jest.config.js,对于 TypeScript 项目配置预设:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

package.json 中添加测试脚本:

"scripts": {
  "test": "jest"
}

运行 npm test,Jest 会自动寻找 __tests__ 目录或文件名包含 .test.js / .spec.js 的文件。

编写第一个测试

创建 math.js,实现待测的加法函数:

function add(a, b) {
  return a + b;
}
module.exports = { add };

同级目录新建 math.test.js

const { add } = require('./math');

test('adds 1 + 2 to equal 3', () => {
  expect(add(1, 2)).toBe(3);
});

test(name, fn) 定义一个测试用例,expect(value) 配合匹配器 toBe 断言结果。执行 npm test,Jest 输出绿色对勾表示通过。

常用匹配器

Jest 提供丰富的匹配器,覆盖大部分断言场景:

  • toBe:严格相等(===),适用于基本类型。
  • toEqual:递归检查对象或数组的每个字段。
  • not:取反,例如 expect(result).not.toBeNull()
  • toBeNull / toBeUndefined / toBeDefined:判断 null 或 undefined。
  • toBeTruthy / toBeFalsy:检查布尔上下文转换。
  • toContain:数组或可迭代对象是否包含某个元素。
  • toMatch:字符串匹配正则。
  • toThrow:检查函数是否抛出错误。

示例:

test('object assignment', () => {
  const data = { one: 1 };
  data['two'] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

test('null values', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).toBeFalsy();
});

测试异步代码

Jest 原生支持 Promise 和 async/await。确保在测试中返回 Promise,或使用 async/await,否则 Jest 会过早结束测试。

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('peanut butter'), 100);
  });
}

test('the data is peanut butter', () => {
  return fetchData().then((data) => {
    expect(data).toBe('peanut butter');
  });
});

test('using async/await', async () => {
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

test('the fetch fails with an error', async () => {
  expect.assertions(1); // 确保至少执行一次断言
  try {
    await fetchDataThatRejects();
  } catch (error) {
    expect(error.message).toMatch('error');
  }
});

对于回调风格的异步 API,可使用 done 回调参数,但更推荐使用 Promise 包装。

Mock 函数与模块

模拟(Mock)函数用于隔离被测单元,观察调用情况、参数和返回值,也可替换模块实现。

手动模拟函数

test('mock function called', () => {
  const mockCallback = jest.fn((x) => 42 + x);
  [0, 1].forEach(mockCallback);

  expect(mockCallback.mock.calls.length).toBe(2);
  expect(mockCallback.mock.calls[0][0]).toBe(0);
  expect(mockCallback.mock.results[0].value).toBe(42);
});

jest.fn() 创建模拟函数,通过 .mock 属性检查调用记录。

模块模拟

假设 api.js 依赖网络请求,测试时不希望真实调用。在测试文件内使用 jest.mock() 自动模拟整个模块。

const axios = require('axios');
jest.mock('axios');

test('should fetch users', async () => {
  const users = [{ name: 'Bob' }];
  axios.get.mockResolvedValue({ data: users });

  const result = await fetchUsers();
  expect(result).toEqual(users);
  expect(axios.get).toHaveBeenCalledWith('/users');
});

Jest 会将所有导出替换为模拟实现,并可控制返回值。

局部模拟与间谍

使用 jest.spyOn(object, methodName) 保留原始实现但监视调用,可用于部分模拟。

快照测试

快照测试捕获组件或数据结构的渲染输出,确保 UI 不会意外改变。多用于 React 组件或可序列化的对象。

test('renders correctly', () => {
  const tree = renderer.create(<MyComponent />).toJSON();
  expect(tree).toMatchSnapshot();
});

首次运行生成快照文件,后续与当前输出对比,不一致则提示更新快照(npm test -- -u)。快照应作为代码的一部分提交版本控制。

测试驱动开发(TDD)实践

TDD 遵循 Red-Green-Refactor 循环:

  1. Red:写一个会失败的测试。
  2. Green:编写最少代码让测试通过。
  3. Refactor:改进代码结构且保持测试通过。

这种节奏强制先思考接口设计,再实现逻辑,确保每一行代码都有对应的测试。

实战:开发简易字符串工具库

目标:实现一个 stringUtils 模块,包含 capitalizereverse 函数。全程采用 TDD。

1. 编写第一个测试 (Red) 创建 stringUtils.test.js

const { capitalize } = require('./stringUtils');

test('capitalize makes first letter uppercase', () => {
  expect(capitalize('hello')).toBe('Hello');
});

运行 npm test,测试失败(模块不存在)。

2. 让测试通过 (Green) 创建 stringUtils.js

function capitalize(str) {
  if (!str) return '';
  return str.charAt(0).toUpperCase() + str.slice(1);
}
module.exports = { capitalize };

测试通过。

3. 重构 目前代码简洁,暂不需要重构。但可再写一个测试覆盖空字符串等边界情况,然后优化实现。

4. 重复 TDD 追加新功能 编写 reverse 的测试(Red):

test('reverse reverses a string', () => {
  expect(reverse('abc')).toBe('cba');
});

扩大模块导出,添加空实现,通过测试后完善代码,再重构。

测试覆盖率与最佳实践

  • 使用 jest --coverage 生成覆盖率报告,关注分支和语句覆盖率。
  • 测试描述应采用“应该...”句式,提高可读性。
  • 使用 beforeEach / afterEach 设置和清理测试环境,避免测试间共享状态。
  • 避免测试实现细节(如内部私有方法),应测试公有接口的行为。
  • 复杂逻辑拆分为多个小测试,每个测试只验证一个行为。
  • 将模拟限制在最小范围,避免过度模拟导致测试失去价值。

集成到开发流程

  • 在 CI/CD 环境运行 npm test,设置覆盖率阈值做质量门禁。
  • 搭配 Git 预提交钩子(如 husky)自动运行相关测试。
  • 定期审查快照,确保更新是预期变更。

Jest + TDD 不仅提升代码健壮性,更重塑开发思维,让测试成为设计的助手,而非事后的负担。