Dark Dwarf Blog background

JavaScript 测试

JavaScript 测试

1. 测试驱动开发简介

测试驱动开发 (Test-Driven Development, TDD) 是一种软件开发流程,它的核心思想是先编写测试,再编写足以让测试通过的代码。这与传统的“先写代码,后写测试”的流程正好相反。

TDD 遵循一个简短的重复循环,称为“红-绿-重构” (Red-Green-Refactor):

  1. 红 (Red):为即将开发的新功能编写一个失败的测试。因为你还没有编写任何功能代码,所以这个测试理应失败(显示为红色)。
  2. 绿 (Green):编写最少量的功能代码,刚好让这个测试通过(显示为绿色)。在这个阶段,不追求代码的完美,只求能用。
  3. 重构 (Refactor):在测试保持通过(绿色)的前提下,清理和优化刚刚编写的功能代码,提高其质量和可读性。

完成一个循环后,再为下一个新功能重复这个过程。

2. Jest

a.a. Jest 简介

Jest 是一个由 Facebook 开发的、流行的 JavaScript 测试框架。它以“零配置”开箱即用而闻名,内置了测试运行器、断言库和 Mocking 功能。

b.b. 安装与配置

  1. 安装 Jest:将其作为项目的开发依赖进行安装。
npm install --save-dev jest
  1. 配置 package.json:在 scripts 对象中添加一个 test 命令。
"scripts": {
    "test": "jest"
}

现在,我们就可以通过 npm test 命令来运行所有测试了。Jest 会自动查找并运行以下文件:

  • __tests__/ 目录下的所有 .js 文件。
  • 所有以 .test.js.spec.js 结尾的文件。

c.c. Jest ES6 imports

通过下面的配置来让 Jest 能够使用 ES6 导入:

  1. package.json 中使用 module 支持:
"type": "module",
  1. 安装 babel-jest 相关依赖:
npm install --save-dev babel-jest @babel/core @babel/preset-env
  1. 创建 babel.config.js 文件并配置 babel-jest:
export default {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};
  1. package.json 中配置 jest 使用 babel-jest:
"jest": {
  "transform": {
    "^.+\\.js$": "babel-jest"
  }
}

c.c. Jest 文件编写

一个典型的 Jest 测试文件结构如下:

// sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;

// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  // 断言:期望 sum(1, 2) 的结果是 3
  expect(sum(1, 2)).toBe(3);
});
  • test(name, fn):定义了一个测试用例。第一个参数是测试的描述,第二个参数是包含测试逻辑的函数。
  • expect(value):包裹你想要测试的值,返回一个“期望对象”。
  • .toBe(value):一个“匹配器”(Matcher),用于断言 expect() 中的值是否与给定的值完全相等(使用 ===)。

3. Jest 匹配器

匹配器 (Matchers) 是 Jest 中用于进行各种断言的函数。常用匹配器如下:

  • 精确相等

    • .toBe(value):使用 Object.is (类似于 ===) 检查原始类型的值。
    • .toEqual(value):递归地检查对象或数组的所有字段是否相等(深度相等)。
  • 真假判断

    • .toBeTruthy() / .toBeFalsy():检查值在布尔上下文中是真还是假。
    • .toBeNull() / .toBeUndefined() / .toBeDefined()
  • 数字比较

    • .toBeGreaterThan(number) / .toBeLessThan(number)
  • 字符串与集合

    • .toMatch(regexp):检查字符串是否匹配正则表达式。
    • .toContain(item):检查数组或可迭代对象是否包含某个元素。
  • 异常

    • .toThrow():检查函数在调用时是否抛出错误。
// a.test.js
test('matcher examples', () => {
  const user = {name: 'Alice', age: 30};
  const fruits = ['apple', 'banana', 'orange'];

  expect(user).toEqual({name: 'Alice', age: 30}); // Pass
  expect(fruits).toContain('banana'); // Pass
  expect('Christoph').toMatch(/stop/); // Pass
});

4. 编写可测试的代码

a.a. 紧耦合代码的问题

TDD 的一个巨大好处是它会“逼迫”我们写出更易于测试、设计更优良的代码。难以测试的代码通常是“紧耦合”的,即函数内部混合了太多职责,并直接依赖于外部模块(如 DOM、alertprompt 等)。

// 难以测试的紧耦合函数
function guessingGame() {
  const magicNumber = 22;
  const guess = prompt('Guess a number!'); // 依赖浏览器 API
  if (guess > magicNumber) {
    alert('Too big!'); // 依赖浏览器 API
  } else if (guess == magicNumber) {
    alert('You did it!'); // 依赖浏览器 API
  } // ...
}

要测试这个函数,你几乎需要一个真实的浏览器环境,这是单元测试应该极力避免的。

b.b. 纯函数

解决方案是将核心逻辑抽离成纯函数。纯函数有两个关键特征:

  1. 相同的输入,永远有相同的输出:函数的结果只依赖于其输入参数。
  2. 无副作用 (Side Effects):函数不会修改其作用域之外的任何状态,例如修改全局变量、修改 DOM、打印日志、发起网络请求等。

纯函数是测试的理想对象,因为它们完全可预测。

// 抽离出的纯函数,非常容易测试
function evaluateGuess(magicNumber, guess) {
  if (guess > magicNumber) return 'Too big!';
  if (guess < magicNumber) return 'Too small!';
  if (guess == magicNumber) return 'You did it!';
  return 'Invalid input';
}

// 不纯的函数,负责与外部世界交互
function guessingGame() {
  const magicNumber = 22;
  const guess = prompt('Guess a number!');
  const message = evaluateGuess(magicNumber, guess);
  alert(message);
}

现在,我们只需要为 evaluateGuess 编写测试,而无需关心 promptalert

5. Mocking

a.a. Mocking 的引入

有时,我们无法将函数完全重构为纯函数,它可能必须依赖于某个外部模块(如 API 请求模块、数据库模块)。在这种情况下,为了隔离我们正在测试的单元,我们可以使用 Mocking。Mocking 会创建一个“假的”依赖项,这个假的依赖项在测试期间会取代真实的依赖项。

b.b. jest.fn()

Jest 提供了 jest.fn() 来创建最基础的 Mock 函数。

// a.test.js
test('mock function example', () => {
  const mockCallback = jest.fn();
  
  // 将 mock 函数作为回调传入
  [1, 2].forEach(mockCallback);

  // 断言:mock 函数被调用了 2 次
  expect(mockCallback.mock.calls.length).toBe(2);

  // 断言:第一次调用时的第一个参数是 1
  expect(mockCallback.mock.calls[0][0]).toBe(1);
});

我们还可以控制 Mock 函数的返回值:

const myMock = jest.fn();
myMock.mockReturnValue(true); // 让这个 mock 函数总是返回 true

console.log(myMock()); // true

6. Setup 与 Teardown

有时我们需要在测试之前进行一些准备工作(Setup),或在测试之后进行一些清理工作(Teardown)。Jest 提供了四个辅助函数:

  • beforeEach(fn)afterEach(fn):在每个测试用例运行前后执行。
  • beforeAll(fn)afterAll(fn):在当前文件中所有测试用例运行前后只执行一次
// a.test.js
let database;

beforeAll(() => {
  // 连接数据库(只执行一次)
  database = initializeDatabase();
});

afterAll(() => {
  // 关闭数据库连接(只执行一次)
  database.close();
});

test('user is Alice', () => {
  expect(database.getUser('Alice')).toBe('Alice');
});