JavaScript测试框架Jest的使用与原理
一、Jest 基础使用
1.1 安装 Jest
在开始使用 Jest 之前,首先需要在项目中安装它。如果项目使用的是 npm,在项目根目录下执行以下命令:
npm install --save-dev jest
若是使用 yarn,则执行:
yarn add --dev jest
1.2 第一个 Jest 测试
假设我们有一个简单的 JavaScript 函数,用于计算两个数的和。在 src
目录下创建 add.js
文件,内容如下:
function add(a, b) {
return a + b;
}
module.exports = add;
接下来,在 __tests__
目录(Jest 默认约定的测试目录)下创建 add.test.js
文件,编写测试代码:
const add = require('../src/add');
test('adds 1 + 2 to equal 3', () => {
expect(add(1, 2)).toBe(3);
});
在上述代码中:
require('../src/add')
引入了要测试的add
函数。test
是 Jest 提供的全局函数,用于定义一个测试用例。它接受两个参数,第一个参数是测试用例的描述,第二个参数是测试逻辑。expect
用于创建一个期望,toBe
是 Jest 提供的断言方法,用于判断实际结果是否等于预期结果。
在 package.json
文件中,添加测试脚本:
{
"scripts": {
"test": "jest"
}
}
然后执行 npm test
或 yarn test
,就可以运行测试用例,Jest 会输出测试结果。
1.3 断言
Jest 提供了丰富的断言方法,常用的有:
toBe
:用于比较基本数据类型的值是否相等,它使用的是严格相等(===
)。例如:
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
toEqual
:用于比较对象或数组是否相等。它会递归地比较对象或数组的每一个属性和元素。例如:
test('objects are deep equal', () => {
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
expect(obj1).toEqual(obj2);
});
toBeTruthy
和toBeFalsy
:用于判断值是否为真值或假值。例如:
test('true is truthy', () => {
expect(true).toBeTruthy();
});
test('false is falsy', () => {
expect(false).toBeFalsy();
});
toBeNull
和toBeUndefined
:分别用于判断值是否为null
和undefined
。例如:
test('null is null', () => {
expect(null).toBeNull();
});
test('undefined is undefined', () => {
expect(undefined).toBeUndefined();
});
toBeGreaterThan
和toBeLessThan
:用于比较数字大小。例如:
test('2 is less than 3', () => {
expect(2).toBeLessThan(3);
});
test('3 is greater than 2', () => {
expect(3).toBeGreaterThan(2);
});
toMatch
:用于字符串匹配正则表达式。例如:
test('string contains "hello"', () => {
const str = 'hello world';
expect(str).toMatch(/hello/);
});
toContain
:用于数组是否包含某个元素。例如:
test('array contains "apple"', () => {
const fruits = ['apple', 'banana', 'cherry'];
expect(fruits).toContain('apple');
});
toThrow
:用于测试函数是否抛出特定错误。例如:
function throwError() {
throw new Error('This is an error');
}
test('function throws error', () => {
expect(throwError).toThrow('This is an error');
});
1.4 测试异步代码
在 JavaScript 开发中,异步操作非常常见,Jest 提供了几种方式来测试异步代码。
- 使用回调函数:假设我们有一个异步函数
fetchData
,它接受一个回调函数,在数据获取完成后调用回调。
function fetchData(callback) {
setTimeout(() => {
callback('Data fetched');
}, 100);
}
测试代码如下:
test('fetch data asynchronously', done => {
fetchData(data => {
expect(data).toBe('Data fetched');
done();
});
});
在上述测试中,done
是 Jest 提供的一个函数,用于告诉 Jest 异步操作已经完成。如果不调用 done
,Jest 会认为测试超时。
- 使用 Promise:如果异步函数返回一个 Promise,测试代码可以这样写:
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Data fetched');
}, 100);
});
}
test('fetch data asynchronously with Promise', () => {
return fetchData().then(data => {
expect(data).toBe('Data fetched');
});
}
也可以使用 async/await
语法,使代码更简洁:
test('fetch data asynchronously with async/await', async () => {
const data = await fetchData();
expect(data).toBe('Data fetched');
});
- 测试拒绝的 Promise:如果异步函数返回的 Promise 可能会被拒绝,使用
rejects
断言。例如:
function fetchData() {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Data fetch failed'));
}, 100);
});
}
test('fetch data fails asynchronously', () => {
return expect(fetchData()).rejects.toThrow('Data fetch failed');
});
二、Jest 配置
2.1 Jest 配置文件
Jest 的配置可以通过 jest.config.js
文件进行。在项目根目录下创建该文件,以下是一个基本的配置示例:
module.exports = {
preset: 'ts-jest', // 如果项目使用 TypeScript,指定预设为 ts-jest
testEnvironment: 'jsdom', // 设置测试环境,jsdom 模拟浏览器环境
testMatch: ['**/__tests__/**/*.test.js', '**/?(*.)+(spec|test).js'], // 匹配测试文件的模式
moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx'], // 模块文件扩展名
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], // 忽略哪些路径的文件进行覆盖率计算
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'], // 从哪些文件收集覆盖率信息
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'] // 在测试环境加载后执行的文件
};
2.2 预设(Presets)
预设是一组 Jest 配置的集合,可以快速应用常用的配置。例如,@jest/preset - react
是用于 React 项目的预设,它包含了对 JSX 的支持、测试环境设置等。
要使用预设,在 jest.config.js
中指定 preset
字段:
module.exports = {
preset: '@jest/preset - react'
};
2.3 测试环境(Test Environments)
Jest 支持多种测试环境,如 node
(Node.js 环境)、jsdom
(模拟浏览器环境)。
- 使用
jsdom
:jsdom
是一个流行的 JavaScript 实现的 DOM 标准,在前端项目中经常使用。要使用jsdom
环境,在jest.config.js
中设置:
module.exports = {
testEnvironment: 'jsdom'
};
在 jsdom
环境中,你可以使用一些 DOM 相关的操作,例如:
test('jsdom can create elements', () => {
const div = document.createElement('div');
expect(div.tagName).toBe('DIV');
});
- 使用
node
:如果项目是基于 Node.js 的后端项目,使用node
环境更合适。在jest.config.js
中设置:
module.exports = {
testEnvironment: 'node'
};
在 node
环境中,你可以使用 Node.js 的全局变量和模块,例如 process
。
2.4 测试匹配模式(Test Match Patterns)
testMatch
配置项用于指定 Jest 应该查找哪些文件作为测试文件。默认情况下,Jest 会查找 __tests__
目录下的 .test.js
和 .spec.js
文件。
如果你的测试文件有不同的命名约定或位置,可以修改 testMatch
。例如,如果你想在 tests
目录下查找所有以 .test.ts
结尾的文件:
module.exports = {
testMatch: ['**/tests/**/*.test.ts']
};
2.5 模块文件扩展名(Module File Extensions)
moduleFileExtensions
配置项告诉 Jest 在解析模块时应该尝试哪些文件扩展名。默认值为 ['js', 'json']
。
如果项目使用了其他文件扩展名,如 jsx
、ts
、tsx
等,需要添加到这个数组中。例如:
module.exports = {
moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx']
};
2.6 覆盖率(Coverage)
Jest 可以生成代码覆盖率报告,帮助你了解代码中有多少部分被测试覆盖。
- 基本配置:在
jest.config.js
中,可以通过collectCoverage
开启覆盖率收集,通过coveragePathIgnorePatterns
忽略某些路径的文件,通过collectCoverageFrom
指定从哪些文件收集覆盖率信息。例如:
module.exports = {
collectCoverage: true,
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts']
};
- 覆盖率报告类型:Jest 支持多种覆盖率报告类型,如
text
、html
、lcov
等。可以通过coverageReporters
配置项指定。例如,要生成 HTML 和文本格式的覆盖率报告:
module.exports = {
coverageReporters: ['html', 'text']
};
执行测试后,会在项目根目录下生成 coverage
目录,其中包含 HTML 格式的覆盖率报告,在终端会输出文本格式的覆盖率信息。
三、Jest 高级特性
3.1 模拟(Mocks)
在测试中,经常需要模拟一些外部依赖,以隔离被测试的代码。Jest 提供了强大的模拟功能。
- 手动模拟:假设我们有一个模块
utils.js
,其中有一个函数fetchDataFromAPI
用于从 API 获取数据。
// utils.js
function fetchDataFromAPI() {
// 实际的 API 调用逻辑
return 'Real data from API';
}
module.exports = {
fetchDataFromAPI
};
在测试中,我们可以手动创建一个模拟模块来替换真实的 utils.js
。在 __mocks__
目录(与被模拟模块同级)下创建 utils.js
文件:
// __mocks__/utils.js
function fetchDataFromAPI() {
return 'Mocked data';
}
module.exports = {
fetchDataFromAPI
};
在测试文件中,使用 jest.mock
来启用模拟:
const { fetchDataFromAPI } = require('../utils');
jest.mock('../utils');
test('uses mocked function', () => {
const result = fetchDataFromAPI();
expect(result).toBe('Mocked data');
});
- 自动模拟:Jest 也可以自动生成模拟,它会保留原始模块的所有导出,但会将函数替换为 Jest 模拟函数。例如:
const { fetchDataFromAPI } = require('../utils');
jest.mock('../utils');
test('uses auto - mocked function', () => {
const result = fetchDataFromAPI();
expect(fetchDataFromAPI).toHaveBeenCalled();
expect(result).toBe(undefined); // 自动模拟函数默认返回 undefined
});
- 模拟函数的断言:对于模拟函数,可以使用一些断言方法,如
toHaveBeenCalled
(判断函数是否被调用)、toHaveBeenCalledTimes
(判断函数被调用的次数)、toHaveBeenCalledWith
(判断函数是否以特定参数被调用)等。例如:
const { add } = require('../mathUtils');
jest.mock('../mathUtils');
test('add function is called correctly', () => {
add(2, 3);
expect(add).toHaveBeenCalled();
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith(2, 3);
});
3.2 快照测试(Snapshot Testing)
快照测试是一种用于验证 UI 组件或其他数据结构输出的方法。Jest 会在第一次运行测试时生成一个快照文件,后续运行测试时会将新的输出与快照文件进行比较。
假设我们有一个简单的 React 组件 Button.js
:
import React from'react';
const Button = ({ text }) => {
return <button>{text}</button>;
};
export default Button;
测试文件 Button.test.js
:
import React from'react';
import { render } from '@testing-library/react';
import Button from './Button';
test('Button snapshot', () => {
const { container } = render(<Button text="Click me" />);
expect(container).toMatchSnapshot();
});
第一次运行测试时,Jest 会在 __snapshots__
目录下生成一个 Button.test.js.snap
文件,内容如下:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button snapshot 1`] = `
<button>
Click me
</button>
`;
后续运行测试时,如果 Button
组件的输出发生变化,Jest 会提示快照不匹配,并显示新旧快照的差异,方便开发者确认是否是预期的变化。如果是预期变化,可以更新快照,Jest 会重新生成快照文件。
3.3 测试钩子(Test Hooks)
Jest 提供了一些测试钩子函数,用于在测试前后执行一些操作。
- beforeEach 和 afterEach:
beforeEach
会在每个测试用例执行前执行,afterEach
会在每个测试用例执行后执行。例如:
let counter = 0;
beforeEach(() => {
counter++;
});
afterEach(() => {
counter--;
});
test('test 1', () => {
expect(counter).toBe(1);
});
test('test 2', () => {
expect(counter).toBe(1);
});
在上述代码中,beforeEach
每次测试前将 counter
加 1,afterEach
每次测试后将 counter
减 1,确保每个测试用例执行时 counter
的初始值为 1。
- beforeAll 和 afterAll:
beforeAll
会在所有测试用例执行前执行一次,afterAll
会在所有测试用例执行后执行一次。例如:
let data;
beforeAll(() => {
data = { message: 'Initialized data' };
});
afterAll(() => {
data = null;
});
test('test data exists', () => {
expect(data).not.toBeNull();
});
在上述代码中,beforeAll
在所有测试用例前初始化 data
,afterAll
在所有测试用例后将 data
置为 null
。
四、Jest 原理剖析
4.1 测试运行流程
Jest 的测试运行流程主要包括以下几个步骤:
- 初始化:Jest 首先读取配置文件
jest.config.js
,确定测试环境、测试文件匹配模式、模块解析规则等配置信息。然后,它会初始化测试框架,包括加载预设、设置全局变量等。 - 收集测试文件:根据
testMatch
配置项,Jest 在项目目录中查找符合条件的测试文件。它会递归地遍历目录,将所有匹配的文件收集起来。 - 加载测试文件:Jest 使用自己的模块加载器来加载测试文件。在加载过程中,会处理模块的依赖关系,确保所有依赖的模块都被正确加载。如果项目使用了 Babel 或 TypeScript 等工具进行编译,Jest 会通过相应的预设(如
@babel/preset - jest
或ts - jest
)对测试文件进行预处理。 - 执行测试用例:Jest 将加载的测试文件中的测试用例按照一定顺序(通常是文件顺序和测试用例定义顺序)执行。对于每个测试用例,Jest 会创建一个新的测试上下文,其中包含测试环境、全局变量等。在执行测试用例时,Jest 会运行测试逻辑,并根据断言结果判断测试是否通过。
- 生成测试报告:测试用例执行完成后,Jest 会收集所有测试用例的结果,生成测试报告。报告内容包括通过的测试用例数量、失败的测试用例数量、测试覆盖率等信息。如果启用了特定的覆盖率报告类型(如
html
、lcov
等),Jest 还会生成相应格式的覆盖率报告。
4.2 断言实现原理
Jest 的断言方法是基于 expect
函数实现的。expect
函数接受一个值作为参数,并返回一个包含各种断言方法的对象。例如,expect(2 + 2).toBe(4)
中,expect(2 + 2)
返回一个对象,该对象上有 toBe
方法。
Jest 的断言方法内部使用了 JavaScript 的 Object.defineProperty
来定义这些断言方法。当调用 toBe
等断言方法时,会在内部执行相应的比较逻辑,并根据比较结果抛出错误或返回成功。例如,toBe
方法内部使用严格相等(===
)进行比较:
function toBe(expected) {
if (this.actual!== expected) {
throw new Error(`Expected ${this.actual} to be ${expected}`);
}
return true;
}
这里的 this.actual
是 expect
函数传入的值。通过这种方式,Jest 实现了简洁且易于使用的断言语法。
4.3 模拟实现原理
Jest 的模拟功能主要通过替换模块的导出实现。当使用 jest.mock(moduleName)
时,Jest 会根据配置查找对应的模拟模块(手动模拟或自动模拟)。
- 手动模拟:Jest 会直接使用
__mocks__
目录下对应的模拟模块替换真实模块的导出。在加载模块时,Jest 会优先加载模拟模块,从而实现对模块功能的替换。 - 自动模拟:Jest 会创建一个模拟对象,该对象包含原始模块的所有导出,但将函数替换为 Jest 模拟函数。Jest 模拟函数是一个特殊的函数,它可以记录函数的调用情况(如调用次数、调用参数等),并可以设置返回值或抛出错误。自动模拟函数的实现原理是通过 JavaScript 的函数封装和属性设置,例如:
function mockFunction() {
mockFunction.callCount++;
mockFunction.mock.calls.push(arguments);
return mockFunction._defaultReturnValue;
}
mockFunction.callCount = 0;
mockFunction.mock = { calls: [] };
mockFunction._defaultReturnValue = undefined;
通过这种方式,Jest 实现了对模块中函数的模拟和调用记录功能。
4.4 快照测试原理
快照测试的原理是基于文件比较。当第一次运行包含快照测试的测试用例时,Jest 会将测试输出(如 React 组件的渲染结果)序列化为一个字符串,并将其写入到 __snapshots__
目录下对应的快照文件中。
在后续运行测试时,Jest 会再次生成测试输出的序列化字符串,并与快照文件中的内容进行比较。如果两者相同,测试通过;如果不同,Jest 会提示快照不匹配,并显示新旧快照的差异。
Jest 使用了一些算法来优化快照比较,例如对字符串进行差异分析,只显示有变化的部分,方便开发者查看和定位问题。同时,Jest 提供了更新快照的机制,当确认变化是预期的时,可以通过命令(如 jest --updateSnapshot
)更新快照文件,使其与新的测试输出一致。