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');
});