Jest 笔记
约 12921 字大约 43 分钟
2025-11-01
Jest 是目前前端单元测试最常用的框架之一,它的核心语法其实非常简洁,主要由三部分组成:
- 测试定义函数(
test,it,describe) - 断言函数(
expect,toBe,toEqual,toContain等) - 生命周期钩子(
beforeEach,afterEach,beforeAll,afterAll)
定义测试
test(name, fn) 或 it(name, fn)
定义一个测试用例(两者完全等价)
test('1 + 1 应该等于 2', () => {
expect(1 + 1).toBe(2);
});describe(name, fn)
用于分组测试,常配合多个 test 使用
describe('数组操作', () => {
test('push 应该增加长度', () => {
const arr = [];
arr.push(1);
expect(arr.length).toBe(1);
});
test('pop 应该删除最后一个元素', () => {
const arr = [1, 2];
arr.pop();
expect(arr).toEqual([1]);
});
});断言(expect 系列)
expect(value)
创建一个断言对象,用于链式调用匹配器(matchers)
基础比较
| 方法 | 说明 | 示例 |
|---|---|---|
.toBe(value) | 使用 === 严格比较 | expect(2 + 2).toBe(4) |
.toEqual(value) | 深度比较(对象/数组结构相同即可) | expect({a:1}).toEqual({a:1}) |
.not.toBe(value) | 否定断言 | expect(1 + 1).not.toBe(3) |
数值匹配
| 方法 | 说明 | 示例 |
|---|---|---|
.toBeGreaterThan(number) | 大于 | expect(10).toBeGreaterThan(9) |
.toBeGreaterThanOrEqual(number) | 大于等于 | expect(10).toBeGreaterThanOrEqual(10) |
.toBeLessThan(number) | 小于 | expect(5).toBeLessThan(10) |
.toBeLessThanOrEqual(number) | 小于等于 | expect(5).toBeLessThanOrEqual(5) |
.toBeCloseTo(number[, numDigits]) | 浮点数比较(可指定精度) | expect(0.1 + 0.2).toBeCloseTo(0.3) 或 expect(0.12345).toBeCloseTo(0.1234, 3) |
字符串匹配
| 方法 | 示例 |
|---|---|
.toMatch(/regex/) | expect('yumeng').toMatch(/meng/) |
数组 & 可迭代对象
| 方法 | 示例 |
|---|---|
.toContain(item) | expect([1, 2, 3]).toContain(2) |
.toHaveLength(n) | expect([1, 2, 3]).toHaveLength(3) |
对象属性
| 方法 | 示例 |
|---|---|
.toHaveProperty('key', value?) | expect(user).toHaveProperty('name', '鱼梦江湖') |
异常捕获
| 方法 | 示例 |
|---|---|
.toThrow() | expect(() => fn()).toThrow() |
.toThrowError('msg') | 精确匹配错误信息 |
异步测试
对于 Promise:
test('异步请求成功', async () => {
await expect(fetchData()).resolves.toEqual('data');
});
test('异步请求失败', async () => {
await expect(fetchData()).rejects.toThrow('Network Error');
});生命周期钩子
| 方法 | 作用 |
|---|---|
beforeAll(fn) | 所有测试前执行一次 |
afterAll(fn) | 所有测试后执行一次 |
beforeEach(fn) | 每个测试前执行 |
afterEach(fn) | 每个测试后执行 |
beforeEach(() => {
initDatabase();
});
afterEach(() => {
clearDatabase();
});Mock & Spy(常用于函数、模块测试)
Mock(模拟)就是在测试环境中,用“假的”函数、对象或模块来代替真实的实现。
这样做的目的是:
让测试 不依赖真实环境(例如网络请求、数据库、系统 API),而专注于 测试逻辑是否正确。
假设你写了一个函数:
import { getUser } from './api';
export async function getUserName(id) {
const user = await getUser(id);
return user.name;
}但问题是:
getUser是真实网络请求;- 你写单元测试时 不希望真的发请求(太慢、会失败、依赖网络)。
于是就用 Mock 来伪造这个函数
import { getUserName } from './user';
import { getUser } from './api';
jest.mock('./api'); // 告诉 Jest:不要用真模块,用假的
test('返回用户名', async () => {
// 假设接口返回的数据是:
getUser.mockResolvedValue({ name: '鱼梦江湖' });
const name = await getUserName(1);
expect(name).toBe('鱼梦江湖');
});这里:
jest.mock('./api')→ 替换掉真实的模块;getUser.mockResolvedValue(...)→ 定义它的“假返回值”;- 整个测试 不发网络请求,但行为完全一致。
Mock 的几种类型
| 类型 | 说明 | Jest 提供的 API |
|---|---|---|
| 函数 Mock | 模拟一个函数的实现 | jest.fn() |
| 模块 Mock | 模拟整个模块 | jest.mock('模块名') |
| 定时器 Mock | 模拟 setTimeout, Date.now() 等 | jest.useFakeTimers() |
| 类 Mock | 模拟类及其方法 | jest.mock() + class spy |
函数 Mock 示例
const mockFn = jest.fn();
// 调用函数
mockFn('a', 'b');
// 查看调用情况
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('a', 'b');
// 伪造返回值
mockFn.mockReturnValue(123);
expect(mockFn()).toBe(123);模拟定时器
有时候测试中有延迟逻辑,比如:
function delay(fn) {
setTimeout(fn, 1000);
}我们不想真的等 1 秒钟:
jest.useFakeTimers();
test('测试定时器', () => {
const fn = jest.fn();
delay(fn);
// 快进时间
jest.runAllTimers();
expect(fn).toHaveBeenCalled();
});模拟函数
const mockFn = jest.fn();
mockFn('a');
expect(mockFn).toHaveBeenCalled(); // 调用过
expect(mockFn).toHaveBeenCalledWith('a'); // 参数正确模拟模块
jest.mock('./api');
import { getData } from './api';
test('mock模块返回值', () => {
getData.mockResolvedValue('ok');
await expect(getData()).resolves.toBe('ok');
});实用技巧总结
| 场景 | 常用方法 |
|---|---|
| 精确比较基础类型 | toBe |
| 比较对象/数组结构 | toEqual |
| 判断包含 | toContain / toHaveProperty |
| 浮点数计算 | toBeCloseTo |
| 正则匹配字符串 | toMatch |
| 捕获错误 | toThrow |
| 异步断言 | .resolves / .rejects |
| 模拟函数 | jest.fn() |
| 模拟模块 | jest.mock() |
起步
使用 npm 初始化,并安装 jest。
# 创建项目
mkdir jest-demo
cd jest-demo
# 初始化
npm init -y
# 安装依赖
npm i -D jest安装 Jest 后,用 jest-cli 初始化 jest 配置文件:
npm init jest@latest注
The following questions will help Jest to create a suitable configuration for your project
以下这些问题将帮助 Jest 为你的项目创建一个合适的配置文件。
√ Would you like to use Jest when running "test" script in "package.json"? ... yes
是否希望在运行 package.json 中的 "test" 脚本时使用 Jest?…… 是的。
√ Would you like to use Typescript for the configuration file? ... yes
是否希望使用 TypeScript 来编写 Jest 的配置文件?…… 是的。
√ Choose the test environment that will be used for testing » node
选择用于测试的运行环境 → 选的是 “node” 环境。(说明测试将在 Node.js 环境中执行,而不是浏览器环境。)
√ Do you want Jest to add coverage reports? ... yes 是否希望 Jest 生成测试覆盖率报告?…… 是的。 (覆盖率报告能告诉你哪些代码被测试覆盖到了。)
√ Which provider should be used to instrument code for coverage? » v8 选择用于生成覆盖率统计的底层引擎 → 选的是 “v8”。(表示使用 Node.js 内置的 V8 引擎来计算测试覆盖率。)
√ Automatically clear mock calls, instances, contexts and results before every test? ... yes 是否希望在每次测试前自动清除所有 mock 的调用记录、实例、上下文和结果?…… 是的。(保证每个测试都是独立的,互不影响。)
执行完之后,就会看到有一个 jest.config.js 的配置文件:
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*
* 关于每个配置项的详细解释,请访问官方文档:
* https://jestjs.io/docs/configuration
*/
import type {Config} from 'jest'; // 导入 Jest 配置类型,用于 TypeScript 类型提示
const config: Config = {
// All imported modules in your tests should be mocked automatically
// 是否自动对测试中导入的模块进行 mock(默认 false)
// automock: false,
// Stop running tests after `n` failures
// 当发生 n 个测试失败后,是否停止继续运行(默认 0,表示不停止)
// bail: 0,
// The directory where Jest should store its cached dependency information
// Jest 存储缓存依赖信息的目录(加快测试速度)
// cacheDirectory: "C:\\Users\\Administrator\\AppData\\Local\\Temp\\jest",
// Automatically clear mock calls, instances, contexts and results before every test
// 在每个测试前自动清除 mock 调用记录、实例、上下文和结果
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// 是否在执行测试时收集代码覆盖率信息
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// 需要收集覆盖率信息的文件匹配模式
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// Jest 输出覆盖率报告文件的目录
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// 不需要收集覆盖率的路径匹配规则
// coveragePathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// Indicates which provider should be used to instrument code for coverage
// 指定使用哪个覆盖率收集器(此处为 v8)
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// Jest 生成覆盖率报告时使用的报告格式(如 json、text、lcov 等)
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// 设置代码覆盖率的最低门槛(如 80%)
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// 指定一个自定义依赖提取器模块路径
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// 调用已废弃 API 时是否抛出错误信息
// errorOnDeprecated: false,
// The default configuration for fake timers
// 模拟定时器(如 setTimeout)时的默认配置
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// 强制对被忽略的文件收集覆盖率信息
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// 在所有测试开始前执行的全局初始化模块路径
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// 在所有测试结束后执行的全局清理模块路径
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// 在所有测试环境中可用的全局变量
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number.
// 设置最大并行测试 worker 数量,可为百分比或具体数字
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// Jest 搜索模块时要查找的目录(通常为 node_modules)
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// 模块文件使用的扩展名列表
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "mts",
// "cts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources
// 模块路径的正则映射,用于替换或模拟模块(常用于 alias)
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible'
// 模块加载前需要忽略的路径匹配模式
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// 是否启用系统通知显示测试结果
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// 通知的触发模式(需开启 notify)
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// Jest 配置的预设模板(如 ts-jest)
// preset: undefined,
// Run tests from one or more projects
// 可以配置多个项目同时运行测试
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// 自定义测试报告输出方式(可添加插件)
// reporters: undefined,
// Automatically reset mock state before every test
// 每次测试前自动重置 mock 的状态
// resetMocks: false,
// Reset the module registry before running each individual test
// 在每个测试前重置模块注册表(强制重新加载模块)
// resetModules: false,
// A path to a custom resolver
// 自定义模块解析器路径(决定模块如何被找到)
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// 在每个测试前自动恢复 mock 的实现
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// Jest 搜索测试文件和模块的根目录
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// Jest 搜索文件的目录路径
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// 使用自定义测试运行器(默认 jest-runner)
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// 每个测试运行前执行的初始化模块路径(通常用于全局配置)
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// 在测试框架初始化后运行的配置模块路径
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// 定义测试超过多少秒算“慢测试”
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// Jest 快照测试时使用的序列化器模块
// snapshotSerializers: [],
// The test environment that will be used for testing
// 指定测试运行的环境(例如 jest-environment-node 或 jsdom)
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// 传递给测试环境的额外配置项
// testEnvironmentOptions: {},
// Adds a location field to test results
// 是否在测试结果中添加位置信息
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// Jest 用于识别测试文件的匹配模式
// testMatch: [
// "**/__tests__/**/*.?([mc])[jt] s?(x)",
// "**/?(*.)+(spec|test).?([mc])[jt] s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// 用于跳过特定测试文件的路径匹配模式
// testPathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// 另一种定义测试文件匹配规则的方式(可替代 testMatch)
// testRegex: [],
// This option allows the use of a custom results processor
// 使用自定义结果处理器的路径
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// 使用自定义测试运行器(默认 jest-circus/runner)
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// 定义文件如何被转换(如 Babel 或 ts-jest)
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// 匹配这些文件的路径时跳过代码转换
// transformIgnorePatterns: [
// "\\\\node_modules\\\\",
// "\\.pnp\\.[^\\\\]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// 匹配的模块在加载前会自动返回 mock
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// 是否在执行时逐条显示测试结果(详细模式)
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watch 模式下忽略重新运行的文件路径匹配
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// 是否使用 watchman 监控文件变化(macOS 默认开启)
// watchman: true,
};
export default config; // 导出 Jest 配置对象// sum.js
const sum = (a, b) => {
return a + b;
}
module.exports = sum;// sum.test.js
const sum = require("./sum");
describe('sum', () => {
it('加法', () => {
expect(sum(1, 1)).toEqual(2);
});
})npm run testPS F:\jest-demo> npm run test
> [email protected] test
> jest
PASS src/sum.test.js
sum
√ 加法 (3 ms)
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
sum.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.578 s
Ran all test suites. PASS src/sum.test.js 表示测试文件 src/sum.test.js 执行通过(PASS),如果是失败,会显示 FAIL 并列出错误。
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
sum.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------这是 测试覆盖率报告(Coverage Report),显示代码被测试覆盖的比例:
| 列名 | 含义 |
|---|---|
| File | 文件名 |
| % Stmts | 语句(statements)覆盖率,执行到的语句比例 |
| % Branch | 分支(branch)覆盖率,比如 if / switch 等条件分支的覆盖情况 |
| % Funcs | 函数(functions)覆盖率,表示定义的函数有多少被调用过 |
| % Lines | 行(lines)覆盖率,被执行的代码行占总行数的比例 |
| Uncovered Line #s | 未被测试覆盖的行号(此处为空,说明全覆盖) |
这里都是 100%,说明 测试完全覆盖了 sum.js 文件。
Test Suites: 1 passed, 1 total测试套件(Test Suite)是一个测试文件。
总共有 1 个测试文件,且全部通过。
Tests: 1 passed, 1 total测试用例(Test)数量统计。
一共有 1 个测试用例,已通过。
Snapshots: 0 totalSnapshot 测试数量。
这里没有使用快照测试(常用于 React 组件或 UI 测试)。
Time: 0.578 s本次测试总耗时约 0.578 秒。
当你启用了 collectCoverage: true 时,Jest 会在测试结束后生成一个 coverage/ 文件夹,里面保存了详细的覆盖率结果。
coverage/ 文件夹结构(典型)
coverage/
├── clover.xml
├── coverage-final.json
├── lcov-report/
│ ├── index.html
│ ├── base.css
│ ├── prettify.css
│ ├── prettify.js
│ ├── sorter.js
│ ├── src/
│ │ └── sum.js.html
│ └── ...
└── lcov.infoJest 在运行测试时会自动在 coverage 目录下生成多种格式的覆盖率报告文件,包括 JSON、XML 和 HTML 等。
这些不同格式的报告,本质上描述的内容是相同的,只是为了 方便不同工具或平台读取与展示——例如,JavaScript 工具更容易读取 JSON,CI/CD 系统或 SonarQube 更偏好 XML。
不过,无论是哪种格式,文本报告都不够直观。
因此,Jest 还会生成一个 网页版本的可视化报告,位于 coverage/lcov-report/index.html。
只需在浏览器中打开这个文件,就能以图形化的方式清晰地看到每个文件、函数、语句、分支的测试覆盖情况——让测试结果一目了然。
转译器
Jest 本身不做代码转译工作。 在执行测试时,它会调用已有的 转译器/编译器 来做代码转译。
在前端,我们最熟悉的两个转译器就是 Babel 以及 TSC 了。
TSC 转译
npm i -D typescriptnpx tsc --initnpm i -D ts-jest在 jest.config.js 里添加一行配置:
module.exports = {
preset: 'ts-jest',
// ...
};如果有很多的报错,大概是 TS 找不到对应的 类型定义,
npm i -D @types/jest然后在 tsconfig.json 里加上 jest 和 node 类型声明:
{
"compilerOptions": {
"types": ["node", "jest"]
}
}路径简写
在 moduleDirectories 添加 "src":
// jest.config.js
module.exports = {
moduleDirectories: ["node_modules", "src"],
// ...
}在 tsconfig.json 里指定 baseUrl 和 paths 路径:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"utils/*": ["src/utils/*"]
}
}
}tsconfig.json 里的 paths 就是把 utils/xxx 映射成 src/utils/xxx
jest.config.js 里的 moduleDirectories 直接把 utils/sum 当作第三方模块,先在 node_modules 里找,找不到再从 src/xxx 下去找。
@ 路径匹配
{
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}// jest.config.js
modulex.exports = {
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1"
}
}还可以用 ts-jest 里的工具函数 pathsToModuleNameMapper 来把 tsconfig.json 里的 paths 配置复制到 jest.config.js 里的 moduleNameMapper:
// jest.config.js
const { pathsToModuleNameMapper } = require('ts-jest/utils')
const { compilerOptions } = require('./tsconfig')
module.exports = {
// [...]
// { prefix: '<rootDir/>' }
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: "<rootDir>/",
}),
}测试环境
在使用 Jest 进行单元测试时,有时需要测试一些仅在浏览器环境中存在的 API,例如 localStorage、sessionStorage 或 fetch。因为 Jest 默认运行在 Node 环境下,这些浏览器专有的 API 并不存在,会导致测试报错。
为了解决这个问题,我们可以使用 全局 Mock(全局伪实现),在测试前创建好这些 API 的模拟实现。常用做法如下:
创建全局 Mock 文件
比如在项目中创建 tests/jest-setup.ts,对浏览器 API 做伪实现:
// tests/jest-setup.ts
class LocalStorageMock {
private store: Record<string, string> = {};
clear() {
this.store = {};
}
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = value.toString();
}
removeItem(key: string) {
delete this.store[key];
}
}
global.localStorage = new LocalStorageMock() as any;在 Jest 配置中使用 setupFilesAfterEnv
在 jest.config.js 中添加:
module.exports = {
// ...其他配置
setupFilesAfterEnv: ['./tests/jest-setup.ts'],
};作用:
setupFilesAfterEnv会在每个测试文件执行前加载指定文件。- 可以用来全局设置 Jest 的环境,例如添加自定义匹配器、配置 Mock、初始化全局变量等。
- 与
setupFiles不同,setupFiles在测试框架安装前执行,而setupFilesAfterEnv在测试框架安装后执行,更适合进行环境配置和扩展 Jest 功能。
效果
- 配置完成后,所有测试文件都能直接使用
localStorage,无需单独在每个测试文件中 Mock。 - 保证测试环境尽可能接近浏览器环境,同时提高测试可维护性。
setupFiles 和 setupFilesAfterEnv 的区别:
[ 准备测试环境(Node 或 jsdom) ]
│
▼
setupFiles
│
▼
[ 引入测试框架(Jest/Jasmine) ]
│
▼
setupFilesAfterEnv
│
▼
[ 执行测试文件 (xxx.test.ts) ]setupFiles
执行时机:在 引入测试环境后,但在安装测试框架之前。
作用:适合做测试环境的基础准备,例如:
- Mock 全局对象(
window,document,localStorage) - 设置环境变量
- Polyfill 或全局函数
示例:
// setupFiles 中的例子
global.abcd = '测试用全局变量';setupFilesAfterEnv
执行时机:在 测试框架安装之后,在每个测试文件执行之前。
作用:适合做与 Jest/Jasmine 相关的配置,例如:
- 自定义 Matcher
- Jest 插件配置
- 测试全局钩子(
beforeEach、afterEach)
示例:
// setupFilesAfterEnv 中的例子
import '@testing-library/jest-dom'; // 扩展 expect 的 matcher只要是测试文件执行前,理论上全局 Mock 都能工作。
区别在于:如果 Mock 只需要全局对象,不依赖 Jest API,用 setupFiles 就够;如果 Mock 需要 Jest API 或全局钩子,必须用 setupFilesAfterEnv。
像前面手动 Mock localStorage 这样的做法,其实有点“傻”。原因是:
- 浏览器提供的 API 太多,我们不可能全都手动 Mock。
- 手动 Mock 永远无法做到 100% 还原浏览器的真实行为。
为了简化这个问题,Jest 提供了 testEnvironment 配置:
// jest.config.js
module.exports = {
testEnvironment: "jsdom",
};设置 testEnvironment: "jsdom" 后,每个测试文件都会运行在一个 虚拟浏览器环境 中。
全局自动拥有浏览器标准 API,包括 window、document、localStorage、fetch 等。
原理:Jest 使用 jsdom 库在 Node.js 中模拟一个浏览器环境。
- jsdom 用纯 JS 实现了大部分 Web 标准 API。
- 由于 Jest 的测试文件本身在 Node.js 下运行,jsdom 就充当了“浏览器环境的 Mock 实现”。
注意:如果清空 jest-setup.ts 的代码,直接 npm run test,依然能正常使用 localStorage 等 API,这是因为 testEnvironment: "jsdom" 已经提供了这些全局对象。
除了 jsdom,Jest 还有其他内置的测试环境,但一般都是 jsdom 的扩展或变体。
模拟 URL
仅仅配置 jsdom 并不能解决所有问题,尤其是修改 window.location 相关的场景。
const getSearchObj = () => {
const { search } = window.location;
const searchStr = search.slice(1);
const pairs = searchStr.split("&");
const searchObj: Record<string, string> = {};
pairs.forEach(pair => {
const [key, value] = pair.split("=");
searchObj[key] = value;
});
return searchObj;
};
export default getSearchObj;功能:把 URL 查询参数转换为对象
window.location.href = 'https://www.baidu.com?a = 1&b = 2';
getSearchObj(); // { a: '1', b: '2' }现代写法可以用 Object.fromEntries(new URLSearchParams(window.location.search).entries())。
尝试在测试中修改 window.location.href:
window.location.href = "https://www.baidu.com?a=1&b=2";测试会报错或无法生效
原因:jsdom 默认环境不允许直接修改 href(除了 hash)
尝试的解决办法
Hack window.location
Object.defineProperty(window, 'location', {
writable: true,
value: { href: 'https://google.com?a = 1&b = 2', search: '?a = 1&b = 2' },
});- 可以生效,但需要同时写
href和search - 不够优雅,维护成本高
扩展测试环境
可以继承 jest-environment-jsdom,把 jsdom 暴露到全局:
const JSDOMEnvironment = require("jest-environment-jsdom");
module.exports = class JSDOMEnvironmentGlobal extends JSDOMEnvironment {
constructor(config, options) {
super(config, options);
this.global.jsdom = this.dom;
}
teardown() {
this.global.jsdom = null;
return super.teardown();
}
};这样就可以用 global.jsdom.reconfigure({ url }) 来修改 URL
缺点:要写全局类型声明,还需要 any,比较麻烦
使用 NPM 包:jest-location-mock
npm i -D jest-location-mock在 jest-setup.ts 引入:
import "jest-location-mock";测试中直接使用 window.location.assign 修改 URL:
window.location.assign('https://www.baidu.com?a = 1&b = 2');
expect(getSearchObj()).toEqual({ a: '1', b: '2' });优点:操作简单、代码量少、维护成本低
限制:只能 Mock assign、reload、replace 三个 API,但对于大部分测试场景足够用
总结
- jsdom 提供基础浏览器环境,但是不允许直接修改
window.location.href。 - 手动 Hack 可以解决,但不够优雅,维护成本高。
- 扩展 Jest 测试环境 或 使用现成 NPM 包 是更稳妥的方案。
- 对于 URL 修改这种场景,推荐使用
jest-location-mock,简单、可维护、效果好。 - jsdom 本身 就有修改 URL 的能力(
jsdom.reconfigure({ url })),但是 Jest 默认不暴露 jsdom 对象到测试文件的全局作用域,所以你在测试文件里没法直接调用。 - 自己扩展测试环境 把 jsdom 挂到
global.jsdom也可以解决,但需要写额外的代码、类型声明,而且麻烦。 - 使用
jest-location-mock就简单得多:
window.location.assign('https://www.baidu.com?a = 1&b = 2');- 在测试文件里直接就像在真实浏览器中操作 URL 一样
- 不需要修改全局测试环境,也不需要 Hack
window.location - 对于大多数测试场景已经足够,简单、直观、易维护
测试驱动开发
在日常开发中,我们经常在实现新功能时写下无数个 console.log() 调试输出。
调试完之后还要一条条删除,费时又繁琐。
其实,这些 console.log() 本质上就是一种 “手动测试” ,即我们手动执行程序,然后用眼睛去验证结果是否正确。
但如果我们能让 程序自己验证程序 呢?
这就是 TDD(Test-Driven Development,测试驱动开发) 的核心思想。
TDD
TDD 是一种开发模式,它提倡:
先写测试,再写代码。
开发流程大致如下:
- 编写测试用例,定义功能目标和预期输出。
- 运行测试(此时一定会失败),因为功能还没实现。
- 实现功能代码,让测试通过。
- 重构代码,优化结构与可维护性,同时确保测试依然全部通过。
这个循环被称为 “红 → 绿 → 重构” 循环。
- 红:测试未通过(Red)
- 绿:所有测试通过(Green)
- 重构:在安全的前提下改进代码结构(Refactor)
优势
明确目标,提升开发效率
TDD 相当于先给自己定一个清晰的“任务目标”。
每写一个功能,都有相应的测试用例作为验证标准。
运行 npm run test 时,看着控制台中红变绿的过程,就能直观看出开发进度。
自动化验证,减少人工测试
测试用例能自动执行、自动验证输出,不需要手动运行、打印、检查结果。
节省大量调试时间,也避免了“改完忘删 log”的情况。
安全重构,不怕崩逻辑
当你想重构代码(例如优化可读性或维护性)时,测试就是最好的 安全网。
测试会立刻告诉你是否破坏了原有逻辑,能极大减少重构风险。
很多开发者都有过“重构完发现业务崩了”的惨痛经历,而 TDD 能有效避免。
用例即文档
一个优秀的测试文件本身就是最好的文档。
它展示了函数的输入、输出以及预期行为。
比起阅读文字描述,看测试运行过程更直观、更准确。
对新接手项目的开发者来说,只需阅读测试,就能快速理解功能。
减少线上事故
结合 CI/CD 流程,在推送(commit/push)前自动执行测试:
npm run test如果测试未通过,就阻止推送或构建。
能有效防止“推上去后才发现崩溃”的情况。
Git 提供了很多钩子(hooks),在执行特定操作时自动触发,比如:
| 钩子名称 | 触发时机 |
|---|---|
pre-commit | 在提交前执行 |
commit-msg | 提交信息写完后执行 |
pre-push | 在推送前执行 |
post-merge | 合并完成后执行 |
我们要用的就是 pre-push 钩子。
Husky 是目前前端项目里最流行的 Git Hooks 管理工具。
它的作用是:让你能用 npm 脚本管理 git hooks,而不用去手动编辑 .git/hooks 文件。
安装依赖
npm install husky --save-dev启用 Git Hooks
npx husky install然后在 package.json 中添加一行,确保别人克隆项目后也能自动启用:
{
"scripts": {
"prepare": "husky install"
}
}添加一个 pre-push 钩子
npx husky add .husky/pre-push "npm run test"执行完后会生成文件:.husky/pre-push
内容如下:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run test现在效果是:每次执行 git push 时,Husky 会自动触发脚本:
- 如果
npm run test全部通过 → 推送成功 - 如果有测试 失败 → 推送中断
可扩展的实践技巧
你还可以:
- 在 pre-commit 阶段执行
npm run lint来检查代码风格; - 在 pre-push 阶段执行
npm run test; - 甚至在 commit-msg 阶段强制检查提交信息格式(配合 commitlint),more;
例如:
npx husky add .husky/pre-commit "npm run lint"
npx husky add .husky/commit-msg "npx commitlint --edit \$1"适用场景
TDD 并非万能,它更适合以下几类开发任务:
| 场景类型 | 特点 | 适用程度 |
|---|---|---|
| 纯函数开发 | 输入输出固定,可预测性高 | 非常适合 |
| 数据转换函数 | 数据结构清晰,逻辑稳定 | 非常适合 |
| 后端接口测试 | 请求响应明确,可自动验证 | 非常适合 |
| Bug 修复场景 | 先写测试复现 bug,再修复并验证 | 推荐 |
| UI/交互开发 | 状态多、操作复杂 | 一般,适合用 BDD 方式 |
TDD vs BDD
| 对比项 | TDD(测试驱动开发) | BDD(行为驱动开发) |
|---|---|---|
| 关注点 | 功能是否实现正确 | 行为是否符合业务逻辑 |
| 典型语法 | test() / expect() | describe() / it() / given-when-then |
| 适用范围 | 工具函数、后端接口、算法 | 用户场景、业务流程、UI 行为 |
两者并不冲突。
在实际开发中,TDD 更偏底层逻辑测试,而 BDD 更适合复杂业务场景测试。
Mock Timer
当代码中存在 setTimeout、setInterval、setImmediate、Promise 等异步逻辑时,如果直接让 Jest 按真实时间等待执行,那么测试会 非常慢(甚至超时)。
Jest 提供了一个强大的机制叫做 “Fake Timers(伪造计时器)”,可以 模拟时间流逝,让所有定时器同步执行。
你要用:
jest.useFakeTimers()
jest.runAllTimers()就能让多个 setTimeout 同步执行,不用真的等那么久。
假设有以下代码要测试:
function delayedAction(callback) {
setTimeout(() => {
callback('done')
}, 10000)
}如果写测试这样:
test('delayedAction works', (done) => {
delayedAction((result) => {
expect(result).toBe('done')
done()
})
})那这段代码要 等 10 秒 才能结束,非常不合理。
解决方案:使用 Jest 的“虚拟时间”API
jest.useFakeTimers()
告诉 Jest:用假的计时器替换真实的定时器函数
也就是把 setTimeout、setInterval、clearTimeout 等替换成“可控的版本”。
jest.useFakeTimers()此后,所有定时器都不会真实等待时间,而是进入 Jest 的“计时器队列”。
以下 API 会被 “替换成 Jest 自己的版本”(不是浏览器的原生版本):
'Date'
'hrtime'
'nextTick'
'performance'
'queueMicrotask'
'requestAnimationFrame'
'cancelAnimationFrame'
'requestIdleCallback'
'cancelIdleCallback'
'setImmediate'
'clearImmediate'
'setInterval'
'clearInterval'
'setTimeout'
'clearTimeout'也就是说:所有时间相关的 API,都被 Jest 接管了。
jest.runAllTimers()
立即执行 所有 等待中的计时器(无论它们延迟多少)。
jest.runAllTimers()意思是:跳过时间流逝,所有 setTimeout 的回调 立刻执行。
完整示例:
执行时间:瞬间完成。
不需要等 10 秒。
function delayedAction(callback) {
setTimeout(() => {
callback('done')
}, 10000)
}
test('delayedAction executes immediately with fake timers', () => {
jest.useFakeTimers()
const callback = jest.fn()
delayedAction(callback)
// 模拟所有定时器立即触发
jest.runAllTimers()
expect(callback).toHaveBeenCalledWith('done')
})代码分析:
jest.useFakeTimers() 告诉 Jest,从现在开始,不要用系统真实的 setTimeout,而用 Jest 自己的“虚拟时间系统”版本。
实际效果:所有 setTimeout() 调用不会真的等待;而是被 Jest 放入一个“待执行的定时器队列”;由 jest.runAllTimers() 来手动触发。
jest.fn() 会创建一个 模拟函数(mock function)。
它长得就像一个普通函数,但 Jest 会在内部 记录它的调用信息。
可以理解为:
function mockFn(...args) {
// Jest 内部记录:mockFn 被调用了几次、传了哪些参数
}它能做什么?
| 功能 | 示例 | 说明 |
|---|---|---|
| 查看调用次数 | callback.mock.calls.length | 看看被调用了几次 |
| 查看第一次调用的参数 | callback.mock.calls[0] | 查看第 1 次调用时传的参数 |
| 查看是否被调用 | expect(callback).toHaveBeenCalled() | 断言函数至少调用过一次 |
| 查看具体参数 | expect(callback).toHaveBeenCalledWith('done') | 断言被调用时的参数 |
调用 delayedAction 时,由于启用了 jest.useFakeTimers(),这个 setTimeout 不会真的等 10 秒,而是被 Jest 放进了一个内部的「定时任务列表」。
jest.runAllTimers() 这一行是关键操作:告诉 Jest:“别等了,把所有定时器的回调函数立刻执行!”
于是内部执行了:
callback('done')expect(callback).toHaveBeenCalledWith('done') 这是断言(expectation)。意思是:我期望这个回调函数曾经被调用过,而且调用时传入的参数是 'done'。
在 Jest 内部,它会检查:
callback.mock.calls里有没有一项等于 ['done']。
如果有,测试通过;
如果没有,测试失败。
执行流程
| 顺序 | 代码 | 发生了什么 |
|---|---|---|
| 1 | jest.useFakeTimers() | 开启虚拟时间系统 |
| 2 | jest.fn() | 创建一个可追踪调用情况的模拟函数 |
| 3 | delayedAction(callback) | 调用了一个含有 setTimeout 的函数,但不会真的等 |
| 4 | jest.runAllTimers() | 手动触发所有 setTimeout 执行,立刻调用了 callback('done') |
| 5 | expect(...).toHaveBeenCalledWith('done') | 断言:模拟函数确实被传入 'done' 调用过 |
其他常用的定时器控制 API
| API | 作用 |
|---|---|
jest.useFakeTimers() | 启用伪造计时器 |
jest.useRealTimers() | 恢复真实的计时器(关闭模拟) |
jest.runAllTimers() | 立即执行所有定时器(包括嵌套) |
jest.runOnlyPendingTimers() | 只运行当前已排队的定时器,不触发新加入的 |
jest.advanceTimersByTime(ms) | 模拟“时间流逝”,执行到 ms 毫秒之后的定时器 |
jest.advanceTimersToNextTimer() | 快进到下一个定时器的触发点 |
jest.clearAllTimers() | 清除所有定时器 |
jest.getTimerCount() | 获取当前还有多少定时器未执行(Jest 28+) |
复杂例子
如果你有多个延迟不同的定时器:
function schedule() {
setTimeout(() => console.log('A'), 1000)
setTimeout(() => console.log('B'), 5000)
setTimeout(() => console.log('C'), 10000)
}你可以选择不同方式来控制执行节奏:
jest.useFakeTimers()
schedule()
// 模拟经过 1 秒
jest.advanceTimersByTime(1000)
// 输出:A
// 再经过 4 秒
jest.advanceTimersByTime(4000)
// 输出:B
// 再经过 5 秒
jest.advanceTimersByTime(5000)
// 输出:C整个过程几乎瞬间执行完成。
执行分析:
当你执行 jest.useFakeTimers() 后,setTimeout 等函数被替换成 Jest 自己的“虚拟版本”,它 不会真的等待时间,而是记录下这些定时任务:
| 回调 | 延迟时间 | 状态 |
|---|---|---|
A | 1000ms | 未触发 |
B | 5000ms | 未触发 |
C | 10000ms | 未触发 |
Jest 在内部维护一个「虚拟时间指针」:let currentFakeTime = 0;
每当你调用 jest.advanceTimersByTime(ms) 时,它就会:
- 把虚拟时间往前推进
ms毫秒; - 检查所有定时任务:
- 哪些任务的触发时间 <= 当前虚拟时间;
- 就立即执行这些任务(同步执行)。
举例子
初始状态
currentFakeTime = 0
定时器队列:
A @1000
B @5000
C @10000第一次:jest.advanceTimersByTime(1000)
虚拟时间推进到 1000ms → 执行所有到期任务
执行:
A()此时:
currentFakeTime = 1000
已执行:A
剩余:B @5000, C @10000第二次:jest.advanceTimersByTime(4000)
虚拟时间从 1000 ➜ 5000 → 执行所有触发时间 ≤ 5000 的任务
执行:
B()此时:
currentFakeTime = 5000
已执行:A, B
剩余:C @10000第三次:jest.advanceTimersByTime(5000)
虚拟时间从 5000 ➜ 10000 → 执行所有触发时间 ≤ 10000 的任务
执行:
C()结束:
currentFakeTime = 10000
所有任务已执行所以,advanceTimersByTime() 的逻辑是:模拟时间流逝,推进虚拟时钟到指定时刻,执行所有“应该在这段时间内触发”的任务。并不是“按顺序执行第 N 个定时器”,而是“根据延迟时间 + 当前时间”来判断哪些该执行。
补充演示:嵌套定时器情况
如果定时器内部再设定新的定时器:
function nested() {
setTimeout(() => {
console.log('A')
setTimeout(() => console.log('B'), 2000)
}, 1000)
}
jest.useFakeTimers()
nested()
jest.advanceTimersByTime(1000) // 输出 A
jest.advanceTimersByTime(2000) // 输出 B这时,第二个 setTimeout 是在第一个执行时新注册的,
Jest 也会动态加入定时任务队列中,因此也能被后续推进触发。
现在知道了:jest.useFakeTimers() 会让所有的 setTimeout 等定时器变成“可控的假时间”。
但在真实业务中,很多延迟逻辑并不仅仅是 setTimeout,而是这样:
setTimeout(async () => {
const data = await fetchData() // Promise 异步请求
console.log(data)
}, 1000)这时候就出现一个麻烦:
jest.advanceTimersByTime(1000)只能触发回调;- 但里面的
await仍然是异步的 Promise; - Jest 不会自动等 Promise 解决(resolve)。
所以如果你测试写得太快,断言就可能还没等到 Promise 完成,测试就结束了。
这时的解决办法:等待 Promise 任务完成
Jest 的事件循环(event loop)模型跟 Node 一样,区分:
- Timer 阶段(setTimeout、setInterval)
- Microtask 阶段(Promise.then、await)
Fake Timer 只模拟 宏任务时间(timers),不会直接执行微任务(Promise 的 then 回调)。
解决方式 1:手动 await Promise.resolve()
function delayedRequest(callback) {
setTimeout(async () => {
const result = await Promise.resolve('OK')
callback(result)
}, 1000)
}
test('works with async callback inside timer', async () => {
jest.useFakeTimers()
const callback = jest.fn()
delayedRequest(callback)
// 触发定时器
jest.advanceTimersByTime(1000)
// 等待所有微任务(Promise)执行完毕
await Promise.resolve()
expect(callback).toHaveBeenCalledWith('OK')
})这里的
await Promise.resolve()相当于让测试“让出执行权”,执行所有当前微任务队列中的 Promise。
解决方式 2:使用 Jest 的异步定时器控制(v27+)
Jest 在 v27 之后引入了异步 API:
await jest.runAllTimersAsync()这个方法会同时处理:
- 所有定时器;
- 所有等待中的 Promise。
示例:
test('async timer with promise', async () => {
jest.useFakeTimers()
const callback = jest.fn()
setTimeout(async () => {
const data = await Promise.resolve('data')
callback(data)
}, 1000)
await jest.advanceTimersByTimeAsync(1000) // 等价于手动+Promise.resolve()
expect(callback).toHaveBeenCalledWith('data')
})这更干净,推荐写法(如果 Jest 版本支持)。
注意事项
异步 + fake timers
如果定时器回调中有异步逻辑(例如 Promise),需要配合 await + runAllTimersAsync()(Jest 27+ 支持异步 fake timers)。
await jest.runAllTimersAsync()测试结束别忘了还原
有时为了防止影响别的测试,需要:
jest.useRealTimers()现代 Jest 建议写法
Jest 28+ 新 API:
jest.useFakeTimers({ legacyFakeTimers: false })它实际上决定了 Jest 是用哪一种定时器“模拟引擎”,legacy timers(老版) vs modern timers(现代版)。
Jest 早期(v26 以前)自带的假定时器实现叫 “Legacy Fake Timers”,是 Jest 团队自己写的一套 手搓版时间模拟系统。
但后来发现它有几个明显问题:
- 与真实浏览器 / Node 行为不完全一致;
- 对
Promise、queueMicrotask、Date.now()等支持不好; - 内部维护复杂(跟 JS 引擎差异大)。
于是 Jest 在 v27 开始引入了新的实现:基于 @sinonjs/fake-timers,这是业界成熟、精准的时间模拟库。
因此就有了两种模式:
| 模式 | 关键字 | 实现方式 |
|---|---|---|
| 旧版 | legacyFakeTimers: true(或默认旧 Jest 版本) | Jest 自己的实现 |
| 新版 | legacyFakeTimers: false | 使用 @sinonjs/fake-timers |
jest.useFakeTimers({ legacyFakeTimers: false }) // 启用现代版定时器(推荐)
jest.useFakeTimers({ legacyFakeTimers: true }) // 启用旧版(兼容旧项目)两者的核心区别
| 对比项 | legacyFakeTimers(旧) | modernFakeTimers(新) |
|---|---|---|
| 实现来源 | Jest 自研 | @sinonjs/fake-timers |
| 精度 | 较低,容易偏差 | 高度还原 Node/浏览器逻辑 |
| 支持微任务(Promise) | ❌ 不支持,需要手动 flush | ✅ 完整支持 |
支持 Date | ❌ 手动更新 Date.now() | ✅ 自动更新 |
支持 performance.now() | ❌ | ✅ |
jest.advanceTimersByTimeAsync() | ❌ 无 | ✅ 有 |
jest.runAllTimersAsync() | ❌ 无 | ✅ 有 |
| 内部模拟的 API | setTimeout, setInterval | 还包括 setImmediate, queueMicrotask |
| 推荐度 | 仅限旧项目 | ✅ 推荐默认使用 |
新版的行为更接近真实环境。它能正确处理以下场景:
Promise + setTimeout 组合:
jest.useFakeTimers({ legacyFakeTimers: false })
setTimeout(async () => {
await Promise.resolve()
console.log('OK')
}, 1000)
await jest.advanceTimersByTimeAsync(1000)
// 输出 OK在旧版中,这段代码可能永远不会输出 "OK",因为它不处理 Promise 微任务。
自动同步 Date.now() 与 performance.now()
在旧版中:
jest.advanceTimersByTime(1000)
console.log(Date.now()) // 仍是原来的时间新版:
jest.advanceTimersByTime(1000)
console.log(Date.now()) // 向前跳了 1000ms支持更多 Node 环境定时函数
比如:
process.nextTick()
setImmediate()
queueMicrotask()新版都能正确控制它们的执行顺序。
实测例子对比
function delayedPromise() {
return new Promise((resolve) => {
setTimeout(async () => {
await Promise.resolve()
resolve('done')
}, 1000)
})
}测试:
test('delayedPromise', async () => {
jest.useFakeTimers({ legacyFakeTimers: false }) // ← 新版
const p = delayedPromise()
await jest.advanceTimersByTimeAsync(1000)
await expect(p).resolves.toBe('done')
})- 新版可通过
- 旧版则挂掉(因为 async/await 的 Promise 从未被执行)
async/await 的本质
顺便复习:async/await 的本质
async/await 是基于 Promise 的语法糖。
举个 🌰 子
async function foo() {
const data = await fetchData()
console.log(data)
return 123
}等价于(近似转换为 Promise 写法):
function foo() {
return fetchData()
.then((data) => {
console.log(data)
return 123
})
}所以:
await会暂停函数执行,等到 Promise 解决;async函数总是返回一个 Promise;- 任何抛出的错误会变成 Promise 的 reject。
更细一点说:
await 会把 后续代码(await 之后的部分)包装成 Promise.then 的回调,放到 微任务(microtask queue) 里,在当前宏任务执行完毕后马上执行。
举个事件循环顺序例子:
setTimeout(() => console.log('timeout'), 0)
async function test() {
console.log('before await')
await null
console.log('after await')
}
test()
console.log('sync end')输出顺序是:
before await
sync end
after await
timeout解释:
- 执行 async 函数,打印
before await await null→ 进入微任务队列- 打印
sync end - 执行微任务:打印
after await - 最后执行宏任务:
timeout
综合示例(FakeTimer + Promise + async/await)
function doSomethingLater(callback) {
setTimeout(async () => {
const value = await Promise.resolve('done')
callback(value)
}, 1000)
}
test('doSomethingLater works correctly', async () => {
jest.useFakeTimers()
const callback = jest.fn()
doSomethingLater(callback)
// 推进 1 秒
jest.advanceTimersByTime(1000)
// 等待微任务执行(await 内部的 Promise)
await Promise.resolve()
expect(callback).toHaveBeenCalledWith('done')
})- 没有真实等待
- async/await 逻辑得到完整测试
- callback 被正确触发
setTimeout + Promise
为什么这个测试会失败?
sleep(1000).then(() => callback())
jest.runAllTimers()
expect(callback).toHaveBeenCalledTimes(1)原因是:setTimeout 的 resolve() 触发后,then(callback) 是“微任务(Promise Job)”,不会立刻执行。
Fake Timers 的执行顺序:
1. runAllTimers → 立即执行 setTimeout 内部的 resolve
2. resolve 会把 then(...) 推入 Promise Job Queue
3. 当前 Message 未结束 → Promise Job Queue 不会立刻执行
4. 测试继续跑 → callback 还没执行因此断言失败。
解决办法就是必须等待 Promise Job Queue:
jest.runAllTimers()
await promise // ← 关键:等微任务执行
expect(callback).toHaveBeenCalled()一个完整 Event Loop 顺序如下:
同步代码(当前 Message)
↓
Promise Job Queue(微任务)
↓
下一条 Message(如 setTimeout 回调)因此:
- setTimeout → 放进 Message Queue
- Promise.then → 放进 微任务 Job Queue
Fake Timers 只会处理 Message Queue 的 setTimeout 回调,不会自动处理 Promise Job!
所以你的测试必须手动:
jest.runAllTimers()
await promise总结
Fake Timer 的本质:替换所有时间 API,把定时任务存入 Jest 自己维护的定时器队列,让你可以手动推进时间,而不是等待真实时间。
Event Loop 本质:同步 → 微任务(Promise)→ 定时器回调。Jest Fake Timer 只能处理“定时器回调”,不会自动处理“微任务”,因此 runAllTimers 后必须 await Promise。
同步代码
│
├─ setTimeout(callback) → Jest Timer Queue
│
└─ Promise.then(cb) → Job Queue(微任务)
调用 runAllTimers()
│
└─ 执行所有 Timer → 触发 resolve → then(cb) 加入微任务
必须 await Promise 来清空 Job Queuelogger Mock(减少噪声)
测试中 console.log 很烦,可以 mock 掉:
方法 1:手动 spy
比如在 tests/jest-setup.ts 里手动 Mock console.xxx
jest.spyOn(console, 'log').mockReturnValue();
jest.spyOn(console, 'error').mockReturnValue();方法 2:jest-mock-console(更简单),在 tests/jest-setup.ts 里引入并使用它:
import mockConsole from 'jest-mock-console';
mockConsole();快照测试
在前端开发中,我们常用 Jest + React Testing Library 做交互和功能测试,比如:
- 按钮点击是否触发事件
- 输入框是否更新状态
- 弹窗是否按条件显示
这些测试保证了 功能正确性,但是 组件不仅仅有功能,还有 HTML 结构(DOM)。
问题来了:如何确保组件渲染的 HTML 没有被意外修改?
最直观的方法是:
- 打印组件的 HTML 输出
- 保存到一个文本文件(如
xxx.txt) - 下次测试时,把当前 HTML 和上次保存的文件对比
如果不同,就说明组件的结构发生了变化
这就是 快照测试的基本理念:先保存一份“快照”,下次测试自动对比,发现变化。
Jest 快照测试的优势
Jest 提供了对上面手动对比方法的自动化支持:
自动生成快照文件
- 输出内容会被写入
.snap文件 - 例如:
Title.test.tsx.snap
格式化输出,易读
- 快照文件经过格式化,方便开发者阅读
差异高亮显示
- 当组件结构改变时,Jest 会用高亮展示差异部分
- 让你快速判断是BUG还是预期变化
快照测试流程
以 React 组件为例:
首次运行测试
- Jest 渲染组件
- 将组件的 DOM 输出记录到快照文件
.snap
再次运行测试
Jest 渲染组件
将当前 DOM 输出和
.snap文件对比结果有两种情况:
快照一致:组件渲染没有变化
快照不一致:
代码有 Bug:意外改动破坏了组件结构
实现了新功能:DOM 结构变化,需要更新快照
jest --updateSnapshot快照测试的缺陷与优化策略
大快照问题(避免大快照)
组件结构复杂时,快照会把整个 DOM 树都记录下来,包括嵌套的第三方组件(如 Ant Design 的 Row、Col),结果是生成的快照文件非常庞大且难读
解决方法:只对关键部分生成快照,而不是整个组件 DOM
const { getByText } = render(<Title type="large" title="大字" />);
const content = getByText('大字');
expect(content).toMatchSnapshot();结果快照:
exports[`Title 可以正确渲染大字 1`] = `<div>大字</div>`;优点:简洁、易读
注意:快照不能太小,否则不如直接用 expect(content.children).toEqual('大字')
假错误问题
问题描述:
- 快照测试对比的是输出结果,一旦输出发生任何变化(即使只是文案修改),测试就会失败
- 这种错误称为 “假错误”
示例:
- 文案从
"第一个"改为"第壹个" - 测试立即报错,但功能并无问题
后果:
- 在大快照中,一点改动可能导致整片快照报错
- 更新快照成本低,开发者容易直接用
--updateSnapshot忽略问题 - 最终导致 快照测试不被信任
快照的扩展用途
快照不仅能记录 DOM,还可以记录 任意可序列化的数据:
- JSON 对象
- 文本
- XML 等
示例:接口返回数据快照
const result = await getUserById('1');
expect(result).toMatchSnapshot();适用场景:
- 老项目没有测试,想快速引入验证
- 大块数据或复杂结构结果的对比
快照测试相关 API
快照测试主要由 Jest 和 React Testing Library (RTL) 协作完成。
- Jest:负责断言、快照对比
- RTL:负责渲染组件并提供查询 DOM 的方法
render
render 是 RTL 提供的核心 API,用来渲染一个 React 组件到虚拟 DOM(jsdom)中。
它返回一个对象,里面包含多种工具函数,用于查询组件 DOM 和操作。
import { render } from '@testing-library/react';
import Title from './Title';
const { getByText, baseElement } = render(<Title title="大字" type="large" />);返回对象常用属性和方法
| 属性/方法 | 用途 | 示例 |
|---|---|---|
getByText | 根据文本内容查找元素(找不到会报错) | getByText('第一个') |
queryByText | 根据文本查找元素(找不到返回 null,不会报错) | queryByText('不存在') |
findByText | 异步查找文本(返回 Promise) | await findByText('第壹个') |
getByRole | 根据 ARIA 角色查找元素 | getByRole('button') |
getByTestId | 根据 data-testid 查找元素 | getByTestId('my-button') |
container | 返回渲染组件的根 DOM 节点 | container.querySelector('div') |
baseElement | 返回整个文档根元素(一般是 <body>) | baseElement.innerHTML |
unmount | 卸载组件,清理 DOM | unmount() |
rerender | 重新渲染同一个组件或新组件 | rerender(<Title title="第二个" />) |
getByText 与 content
const { getByText } = render(<Title title="大字" type="large" />);
const content = getByText('大字');getByText('大字'):查找渲染后的 DOM 中 文本内容为 "大字" 的元素
content 就是这个 DOM 元素,例如:
<div>大字</div>然后你可以对 content 做断言:
expect(content).toBeInTheDocument(); // 元素存在
expect(content).toHaveTextContent('大字'); // 文本正确toMatchSnapshot
Jest 的方法,用于将某个值(字符串、对象、DOM 节点等)与之前保存的 快照文件 比对。
expect(content).toMatchSnapshot();第一次运行:会生成 .snap 文件,并记录 DOM 或值
后续运行:
如果一致:测试通过
如果不一致:
说明组件 DOM 或值改变
可选择:
调查原因(可能是 Bug)
更新快照:jest --updateSnapshot
baseElement
render 返回对象的一个属性,指向 整个渲染的根节点(通常是 <body>)
可以直接查看整个渲染的 HTML:
const { baseElement } = render(<Title title="大字" />);
console.log(baseElement.innerHTML);DOM 断言常用 API
使用前,需要安装并在 Jest 中引入 @testing-library/jest-dom:
// jest.setup.ts
import '@testing-library/jest-dom';元素存在相关
| 方法 | 描述 | 示例 |
|---|---|---|
toBeInTheDocument() | 判断元素是否存在于 DOM | expect(getByText('大字')).toBeInTheDocument(); |
not.toBeInTheDocument() | 判断元素不存在 | expect(queryByText('不存在')).not.toBeInTheDocument(); |
元素可见性
| 方法 | 描述 | 示例 |
|---|---|---|
toBeVisible() | 元素是否可见(CSS 不隐藏、display/block 等) | expect(getByText('按钮')).toBeVisible(); |
not.toBeVisible() | 元素不可见 | expect(queryByText('隐藏')).not.toBeVisible(); |
文本内容相关
| 方法 | 描述 | 示例 |
|---|---|---|
toHaveTextContent() | 判断元素的文本内容 | expect(getByText('大字')).toHaveTextContent('大字'); |
not.toHaveTextContent() | 文本不匹配 | expect(getByText('大字')).not.toHaveTextContent('小字'); |
属性与样式
| 方法 | 描述 | 示例 |
|---|---|---|
toHaveAttribute(attr, value?) | 判断元素是否有指定属性,value 可选 | expect(button).toHaveAttribute('type', 'submit'); |
toHaveClass(className) | 判断元素包含指定 class | expect(div).toHaveClass('active'); |
toHaveStyle(cssString) | 判断元素样式 | expect(div).toHaveStyle('color: red; font-size: 16px'); |
表单控件状态
| 方法 | 描述 | 示例 |
|---|---|---|
toBeDisabled() | 元素是否禁用 | expect(button).toBeDisabled(); |
toBeEnabled() | 元素是否可用 | expect(button).toBeEnabled(); |
toBeChecked() | checkbox/radio 是否选中 | expect(checkbox).toBeChecked(); |
not.toBeChecked() | 未选中 | expect(checkbox).not.toBeChecked(); |
toHaveValue(value) | 输入框值是否正确 | expect(input).toHaveValue('hello'); |
DOM 层级和关系
| 方法 | 描述 | 示例 |
|---|---|---|
toContainElement(element) | 判断元素包含另一个元素 | expect(container).toContainElement(button); |
toHaveFocus() | 元素是否获取焦点 | expect(input).toHaveFocus(); |
Mock 的三种方式
写测试时,你经常会碰到一些 不方便、不能、或不应该 执行的代码,比如:
- 需要等待 3 秒的
setTimeout - 会发真实 AJAX 请求的
fetch - 会操作真正的数据库
- 会随机的
Math.random() - 会产生不可控输出的
Date.now() - 第三方模块不受你控制
Mock 就是为了“假装执行”,但不真正运行真实逻辑,让测试可控、快速、稳定、可靠。
Mock Function(函数 Mock)
你有一个函数内部依赖另一个函数,但你不想执行“真实逻辑”,只想看它有没有被调用。
function sendMessage(api, text) {
return api(text);
}
test('sendMessage 会调用 api', () => {
const mockApi = jest.fn().mockReturnValue('ok');
const res = sendMessage(mockApi, '你好');
expect(mockApi).toHaveBeenCalledTimes(1);
expect(mockApi).toHaveBeenCalledWith('你好');
expect(res).toBe('ok');
});因为你不想让 sendMessage 真的“发送消息”,只想确认 “函数有没有叫另一函数干活”。
Mock Module(模块 Mock)
为什么要 Mock 模块?
- 不想发真实 HTTP 请求
- 不想加载真正的文件系统
- 想模拟一个第三方库的返回
- 想替换模块里的某个函数
你测试的通常只是“业务代码”,第三方模块应该被 mock。
Mock 整个模块:
jest.mock('./utils');Mock 模块中特定函数:
import { getUser } from './api';
jest.mock('./api', () => ({
getUser: jest.fn()
}));举例
真实模块(api.ts):
export const fetchUser = () => {
return fetch('/user').then(res => res.json());
};你的业务逻辑(logic.ts):
import { fetchUser } from './api';
export async function load() {
return fetchUser();
}测试(logic.test.ts):
import { load } from './logic';
import { fetchUser } from './api';
jest.mock('./api'); // 自动把 fetchUser 变成 jest.fn()
test('load 调用了 fetchUser', async () => {
fetchUser.mockResolvedValue({ name: '张三' });
const res = await load();
expect(fetchUser).toHaveBeenCalled();
expect(res).toEqual({ name: '张三' });
});为什么要 Mock 模块?
因为你不想让测试真的访问 /user。
Mock Timer(定时器 Mock)
...
组件测试
组件测试(Component Testing)是前端测试中最常见的测试方式,主要用于验证一个组件 是否能按预期渲染 + 交互 + 输出结果。
它通常使用:
- Jest(断言、Mock)
- React Testing Library (RTL)(让你像用户一样测试)
组件测试要验证的核心事情只有三件:
- 组件能正确渲染
- 组件能正确展示内容(DOM)
- 组件能正确响应交互和触发逻辑
渲染测试(Render Test)
测试组件能否 正常创建并插入 DOM。
render(<Button>确定</Button>);常用断言:
expect(screen.getByText('确定')).toBeInTheDocument();用来验证基本渲染,通常是所有组件测试的起点
DOM 查询 / 展示测试(DOM Query Test)
测试组件渲染出来的 文字、属性、样式、类名 是否正确。
常用方法:
| 常用查询 API | 含义 |
|---|---|
| getByText | 通过文本查 DOM |
| getByRole | 更语义化,通过 role 查按钮、输入框… |
| getByTestId | 测试专用,不推荐滥用 |
| container / baseElement | 整个 DOM 根 |
例子:
expect(screen.getByRole('button')).toHaveClass('primary');用来验证内容是否正确呈现,适用于文本、类名、结构等验证
行为 / 交互测试(Interaction Test)
用户行为模拟:Click、Input、Change…
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();或更推荐:
await userEvent.click(screen.getByRole('button'));用来验证事件回调
验证状态变化和操作流程
UI 组件库测试的核心部分
状态与逻辑测试(State / Logic Test)
组件内部逻辑是否正确,例如:
- state 更新
- props 改变
- 异步 effect
- 回调函数
- API 调用
快照测试(Snapshot)属于 DOM 测试的子集
expect(container).toMatchSnapshot();用途:
- 检测 DOM 是否被无意改变
- 大结构对比简单好用
总结
组件测试 = DOM(渲染 + 查询 + 快照) + 交互(事件) + 逻辑(状态)
或者说
组件测试测两部分:看得见(DOM) + 看不见(逻辑)
一个完整的组件测试模板
describe("Button", () => {
it("渲染正常", () => {
render(<Button>确定</Button>);
expect(screen.getByText("确定")).toBeInTheDocument();
});
it("显示正确的类名", () => {
render(<Button type="primary">OK</Button>);
expect(screen.getByRole("button")).toHaveClass("primary");
});
it("点击能触发事件", async () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>测试</Button>);
await userEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("DOM 结构保持不变(快照)", () => {
const { container } = render(<Button>Snapshot</Button>);
expect(container).toMatchSnapshot();
});
});