JavaScript 测试
1. 测试驱动开发简介
测试驱动开发 (Test-Driven Development, TDD) 是一种软件开发流程,它的核心思想是先编写测试,再编写足以让测试通过的代码。这与传统的“先写代码,后写测试”的流程正好相反。
TDD 遵循一个简短的重复循环,称为“红-绿-重构” (Red-Green-Refactor):
- 红 (Red):为即将开发的新功能编写一个失败的测试。因为你还没有编写任何功能代码,所以这个测试理应失败(显示为红色)。
- 绿 (Green):编写最少量的功能代码,刚好让这个测试通过(显示为绿色)。在这个阶段,不追求代码的完美,只求能用。
- 重构 (Refactor):在测试保持通过(绿色)的前提下,清理和优化刚刚编写的功能代码,提高其质量和可读性。
完成一个循环后,再为下一个新功能重复这个过程。
2. Jest
Jest 简介
Jest 是一个由 Facebook 开发的、流行的 JavaScript 测试框架。它以“零配置”开箱即用而闻名,内置了测试运行器、断言库和 Mocking 功能。
安装与配置
- 安装 Jest:将其作为项目的开发依赖进行安装。
npm install --save-dev jest
- 配置
package.json:在scripts对象中添加一个test命令。
"scripts": {
"test": "jest"
}
现在,我们就可以通过 npm test 命令来运行所有测试了。Jest 会自动查找并运行以下文件:
__tests__/目录下的所有.js文件。- 所有以
.test.js或.spec.js结尾的文件。
Jest ES6 imports
通过下面的配置来让 Jest 能够使用 ES6 导入:
- 在
package.json中使用 module 支持:
"type": "module",
- 安装 babel-jest 相关依赖:
npm install --save-dev babel-jest @babel/core @babel/preset-env
- 创建
babel.config.js文件并配置 babel-jest:
export default {
presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};
- 在
package.json中配置 jest 使用 babel-jest:
"jest": {
"transform": {
"^.+\\.js$": "babel-jest"
}
}
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. 编写可测试的代码
紧耦合代码的问题
TDD 的一个巨大好处是它会“逼迫”我们写出更易于测试、设计更优良的代码。难以测试的代码通常是“紧耦合”的,即函数内部混合了太多职责,并直接依赖于外部模块(如 DOM、alert、prompt 等)。
// 难以测试的紧耦合函数
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
} // ...
}
要测试这个函数,你几乎需要一个真实的浏览器环境,这是单元测试应该极力避免的。
纯函数
解决方案是将核心逻辑抽离成纯函数。纯函数有两个关键特征:
- 相同的输入,永远有相同的输出:函数的结果只依赖于其输入参数。
- 无副作用 (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 编写测试,而无需关心 prompt 和 alert。
5. Mocking
Mocking 的引入
有时,我们无法将函数完全重构为纯函数,它可能必须依赖于某个外部模块(如 API 请求模块、数据库模块)。在这种情况下,为了隔离我们正在测试的单元,我们可以使用 Mocking。Mocking 会创建一个“假的”依赖项,这个假的依赖项在测试期间会取代真实的依赖项。
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");
});