Jest 笔记
约 6950 字大约 23 分钟
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
> jest-demo@1.0.0 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 更适合复杂业务场景测试。