服务端5月31日 15:55
Jest 中 describe、test 和 it 该怎么分工?`describe` 负责分组,`test` 和 `it` 负责定义具体用例;`test` 与 `it` 在 Jest 里是同一个能力,只是表达风格不同。写测试时不要把 `describe` 当成必填包装,也不要为了层级好看一层套一层。好的组织方式是让读者从外层看到模块或场景,从内层看到行为,再从断言里确认结果。简单说,`describe` 管上下文,`test/it` 管一个可以独立失败的事实。
很多测试文件难维护,不是因为 Jest API 难,而是命名和分组把问题藏起来了。比如 `describe('utils')` 下面放几十个用例,失败时只能知道 utils 坏了,却不知道是格式化、校验还是边界值坏了。反过来,如果每个分支都新开一层 `describe`,文件会像迷宫,读者要上下跳着看 beforeEach。好的组织方式应该让失败标题连起来就是一句清楚的话。
```js
describe('Calculator.add', () => {
test('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-2, 3)).toBe(1);
});
});
```
## 追问
### test 和 it 到底有没有功能差异?
没有,`it` 是 `test` 的别名,超时参数、异步测试、skip、only 等能力都一样。区别主要是可读性:`it('returns empty array')` 读起来像行为描述,`test('returns empty array')` 更直白。团队里最好统一一种风格,否则同一个文件里混着写会显得很乱。边界是 BDD 风格明显的组件行为测试可以用 `it`,工具函数或数据结构测试用 `test` 也很自然。取舍不是谁更高级,而是谁能让失败信息更像人话。
### describe 应该嵌套几层才合适?
通常一到两层就够了,外层放模块名或组件名,内层放方法、状态或场景。三层以上会让测试标题很长,失败输出也不容易快速定位。嵌套太深还容易把共享变量塞进外层,最后不同用例之间互相影响。更稳的做法是把复杂场景拆成多个文件,或者用清晰的测试数据工厂代替深层 `describe`。如果一个 `describe` 下面只有一个测试,也要警惕它是不是只是为了凑结构。
### beforeEach 放在 describe 里有什么坑?
`beforeEach` 适合做重复但便宜的初始化,比如创建 store、清空 mock、挂载轻量组件。不要在里面做慢请求、真实数据库连接或复杂全局配置,否则每个用例都会付一次成本。更隐蔽的坑是外层 `beforeEach` 和内层 `beforeEach` 叠加后,读者很难知道一个用例运行前到底发生了什么。只要初始化逻辑超过几行,就应该考虑抽成 `setup()`,让每个测试自己显式调用。这个取舍会让测试多写一行,但换来的是用例输入更清楚。
### 一个 test 里可以写多个 expect 吗?
可以,但多个断言应该服务于同一个行为。比如“登录成功后写入 token、更新用户信息、关闭 loading”属于一个完整结果,可以放在同一个用例里。若断言覆盖了多个原因不同的分支,失败时就很难判断到底是哪段逻辑坏了。取舍标准是:如果其中一个断言失败后,其他断言仍然代表独立需求,就拆成多个测试。另一个边界是异步流程,多个 expect 之间如果依赖执行顺序,就要确保前面的 await 已经真正结束。
### skip、only 和 todo 应该怎么用?
`test.only` 和 `describe.only` 只适合本地临时调试,不能进入主分支。`skip` 可以用于记录暂时无法运行的用例,但要写清楚原因,否则它会变成永久漏测。`todo` 适合先标记测试计划,尤其是修 bug 前先列出缺失场景。团队踩坑最多的是忘删 `.only`,建议开启 `eslint-plugin-jest` 的 `no-focused-tests` 和 `no-disabled-tests` 规则。边界是迁移期的大量失败用例,可以短期 skip,但要配 issue 或过期时间,否则它们很快没人敢动。
## 写段配置
```js
// eslint.config.js
module.exports = {
plugins: ['jest'],
extends: ['plugin:jest/recommended'],
rules: {
'jest/no-focused-tests': 'error',
'jest/no-disabled-tests': 'warn',
'jest/expect-expect': 'error'
}
};
```
组织 Jest 测试时,目标不是把层级写得像目录树,而是让失败信息能直接说明哪个行为坏了。`describe` 少而准,`test/it` 小而独立,再配合必要的 lint 约束,测试文件会比单纯追求“格式统一”更好维护。代码评审时也可以顺手看测试标题:如果只读标题看不懂行为,后面的人排查失败时也一样看不懂。标签
Jest
Jest 是一个流行的 JavaScript 测试框架,用于编写和运行测试。它由 Facebook 开发,并被应用于测试 React 组件以及其他类型的 JavaScript 代码。Jest 被设计为零配置,易于上手,同时提供了丰富的特性,如快照测试、内置的覆盖率报告和模拟系统。

服务端5月31日 15:55
Jest 测试跑得太慢时该从哪些地方优化?Jest 测试变慢时,先不要急着把所有用例都改成 mock。更稳的做法是先量出慢在哪里,再从运行范围、测试环境、并发、转换缓存和外部依赖几个点逐个处理。通常收益最大的是三件事:只跑相关测试、把不需要 DOM 的用例放到 `node` 环境、把网络和计时器这类不稳定依赖隔离掉。CI 上还要控制 worker 数量,因为机器核数看起来很多,不代表同时跑满就最快,I/O、转译和内存都会抢资源。
优化前最好先固定基线:记录完整测试耗时、最慢的测试文件、是否开启 coverage、是否每次都重新转译。很多团队感觉“Jest 越来越慢”,实际是新增了 jsdom 用例、覆盖率范围过大、mock 泄漏导致重试,或者 CI 容器内存不足。把这些因素拆开之后,优化才不会变成凭感觉调参数。
```js
// jest.config.js
module.exports = {
testEnvironment: 'node',
maxWorkers: process.env.CI ? '50%' : '75%',
testTimeout: 5000,
cacheDirectory: '<rootDir>/.jest-cache',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/*.d.ts'
]
};
```
## 追问
### 为什么先用 --runInBand 或 --detectOpenHandles 排查,而不是直接加 maxWorkers?
`maxWorkers` 只能调度并发,不能解决测试本身卡住的问题。遇到数据库连接没关闭、定时器没清理、Promise 没 await 时,并发越高日志越乱,定位反而更慢。`--runInBand` 能把问题压成单进程复现,`--detectOpenHandles` 可以暴露遗留句柄。代价是这两个参数会明显拖慢执行速度,所以适合排查,不适合长期放进默认 CI 命令。边界也要注意:如果问题只在并发下出现,单进程可能复现不了,这时可以先降低 worker 数量,再逐步缩小到具体文件。
### testEnvironment 选 node 还是 jsdom,有什么取舍?
纯函数、Node 服务端逻辑、数据转换这类测试应该优先用 `node`,启动快、内存少,也少了 DOM 模拟层带来的噪音。组件测试、依赖 `document`、`window`、布局事件的用例才需要 `jsdom`。踩坑点是有些工具库会偷偷读取浏览器全局对象,如果全局配置成 `node`,这些用例会突然失败。比较稳的做法是默认 `node`,只在需要 DOM 的测试文件顶部用 `/** @jest-environment jsdom */` 单独声明。这样做的取舍是配置会分散一些,但换来的是大部分非 UI 测试能保持轻量。
### 覆盖率收集为什么会拖慢 Jest?
覆盖率需要对代码插桩,转译和文件扫描都会增加开销,尤其是 TypeScript 项目和大仓库更明显。日常本地开发可以不默认打开 coverage,只在提交前或 CI 的独立阶段运行。`collectCoverageFrom` 要排除声明文件、story、mock、生成代码,否则数字看起来完整,实际是在统计无意义文件。边界是核心库、支付、权限这类高风险模块仍然应该保留覆盖率门禁,不能为了速度完全取消。如果覆盖率阶段太慢,可以把单元测试和 coverage 拆成两个 CI job,让开发先拿到基础测试反馈。
### Mock 外部依赖会不会让测试失真?
会,所以 mock 要用在边界上,而不是把所有内部逻辑都替换掉。API、时间、随机数、文件系统、第三方 SDK 适合 mock,因为它们慢且不稳定;业务分支和状态变更如果也全 mock,测试就只是在验证 mock 写得对。一个常见坑是 mock 没有在 `afterEach` 里恢复,导致后面的用例继承了错误状态。可以配合 `jest.clearAllMocks()` 或 `jest.restoreAllMocks()`,让用例之间保持隔离。取舍上,少量集成测试仍然要保留真实调用链,只把网络层替换掉,这样才能发现模块之间的契约问题。
### watch、onlyChanged 和 CI 命令应该怎么分开?
本地开发追求反馈快,`jest --watch` 或 `jest --onlyChanged` 很合适,因为它们只跑和改动相关的测试。CI 追求确定性,应该跑完整测试,并把 worker、coverage、缓存目录固定下来。不要把 `test.only`、`describe.only` 当成选择性运行方案,它们很容易被误提交。团队里可以加 ESLint 规则或 pre-commit 检查禁止 `.only`,这比事后排查漏测便宜得多。还有一个边界是单体仓库:只跑 changed 可能漏掉跨包依赖,CI 最好结合依赖图或至少在合并前跑一次全量。
## 写段代码
```json
{
"scripts": {
"test": "jest --watch",
"test:changed": "jest --onlyChanged",
"test:ci": "jest --ci --coverage --maxWorkers=50%",
"test:debug": "jest --runInBand --detectOpenHandles"
}
}
```
Jest 性能优化的关键不是把命令堆满,而是给不同场景配不同命令。本地要快,CI 要稳,排查要可复现。只要把环境、并发、覆盖率和 mock 边界分清,大多数“测试越来越慢”的问题都能被压回可控范围。真正需要重写测试时,也应该先从最慢、最不稳定、最依赖外部资源的文件开始,而不是把整个测试目录推倒重来。服务端5月31日 11:08
Jest 如何测试异常处理并正确使用 toThrow 和 rejects?异常测试不是为了证明代码“会报错”,而是确认它在错误输入、依赖失败和边界条件下报出正确的错,并且调用方能按预期处理。Jest 里同步异常主要用 toThrow,Promise 拒绝主要用 rejects,回调错误则要显式等待测试结束。最常见的误区是把函数先执行了,再把结果交给 expect,这样异常会在断言前就抛出。
## 同步异常怎么测?
toThrow 接收的是一个函数包装,而不是函数调用结果。可以匹配错误类型、完整消息、部分字符串或正则。
```js
function divide(a, b) {
if (b === 0) throw new RangeError('Division by zero')
return a / b
}
test('除数为 0 时抛出 RangeError', () => {
expect(() => divide(10, 0)).toThrow(RangeError)
expect(() => divide(10, 0)).toThrow(/zero/)
})
```
边界是不要过度依赖完整错误文案。文案经常为了用户体验调整,测试也会跟着碎。更稳定的断言是错误类型、错误 code,或关键短语。
## Promise 拒绝怎么测?
异步函数返回 Promise 时,用 await expect(promise).rejects...。不要忘记 await 或 return,否则测试可能在 Promise 拒绝前就结束,形成假通过。
```js
async function fetchUser(api) {
const res = await api.get('/user')
if (!res.ok) throw new Error('API Error')
return res.data
}
test('接口失败时 rejects', async () => {
const api = { get: jest.fn().mockResolvedValue({ ok: false }) }
await expect(fetchUser(api)).rejects.toThrow('API Error')
})
```
如果你需要检查错误对象上的多个字段,可以用 try/catch,但要加 expect.assertions,避免没有抛错时测试仍然通过。
```js
test('保留错误 code', async () => {
expect.assertions(2)
try {
await readConfig('/missing')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(error.code).toBe('ENOENT')
}
})
```
## 回调错误和框架错误怎么测?
Node 风格回调可以用 done,但必须保证错误路径和成功路径都能结束测试。React 错误边界、日志上报这类场景还要临时 mock console.error,并在测试后恢复。
```js
test('callback 返回错误', done => {
loadFile('bad.txt', err => {
expect(err).toBeInstanceOf(Error)
expect(err.message).toContain('bad')
done()
})
})
```
## 追问
### toThrow 为什么必须包一层函数?
因为 Jest 需要自己调用这段代码,才能捕获抛出的异常。写成 expect(divide(1, 0)).toThrow() 时,异常已经在 expect 执行前抛出,断言根本没机会运行。这个边界只针对同步异常;异步函数即使内部 throw,也会变成 rejected Promise。踩坑是把同步和异步写法混用,导致测试报错位置看起来很奇怪。
### rejects.toThrow 和 try/catch 怎么取舍?
rejects.toThrow 简洁,适合只关心错误类型或消息的场景。try/catch 更啰嗦,但适合检查多个字段,比如 code、status、details。取舍标准是断言复杂度:一两个断言用 rejects,多字段检查用 try/catch。坑是 try/catch 里忘记 expect.assertions,当函数没有抛错时测试也可能悄悄通过。
### 错误消息应该精确匹配吗?
一般不建议完整精确匹配,除非这个消息本身就是公开 API。内部错误文案经常调整,精确匹配会让测试过于脆弱。更好的选择是匹配错误类型、错误码或关键关键词。边界是表单校验、CLI 输出、SDK 对外错误这类场景,用户依赖文案时就应该精确测试。
### 如何测试“没有抛错”?
同步函数可以写 expect(() => fn()).not.toThrow(),异步函数可以写 await expect(fn()).resolves.toEqual(...)。但不要滥用“没有抛错”作为唯一断言,因为函数可能什么也没做也能通过。取舍是它适合覆盖边界输入不崩溃,更关键的业务结果仍要单独断言。踩坑是只测 not.toThrow,漏掉返回值或副作用错误。
### Mock 抛错时要注意什么?
同步依赖用 mockImplementation(() => { throw error }),异步依赖用 mockRejectedValue(error),不要混着用。错误对象最好带上真实业务会用到的字段,比如 code 或 response.status。边界是有些库抛出的不是 Error,而是普通对象,测试要和真实库行为一致。踩坑是 mock 得太理想,生产里的错误结构不同,catch 分支读取字段时再次报错。服务端5月31日 11:08
Jest 如何测试 TypeScript 项目并配置 ts-jest?Jest 测 TypeScript 项目时,先要分清两个问题:测试运行时怎么把 TS 转成 JS,以及类型错误由谁负责检查。ts-jest 可以在 Jest 运行时编译 TypeScript,配置直观,适合希望测试和 tsconfig 保持一致的项目。另一个常见选择是 Babel 或 SWC 转译,它们更快,但通常不做完整类型检查。
## ts-jest 基础配置怎么写?
先安装 Jest、类型声明和 ts-jest。Node 项目一般使用 node 环境,前端组件或 DOM 工具才需要 jsdom。
```bash
npm i -D jest ts-jest @types/jest typescript
```
```js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts']
}
```
tsconfig.json 里要包含 Jest 类型,否则 describe、test、expect 可能被编辑器标红。
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"types": ["jest", "node"]
}
}
```
如果项目本身是 ESM,配置会更敏感,需要使用 preset: 'ts-jest/presets/default-esm',并处理 extensionsToTreatAsEsm。这是 ts-jest 里最常见的坑之一。
## 类型安全的测试怎么写?
测试不应该为了通过而到处 as any。TypeScript 的价值在于让测试数据、Mock 和返回值也遵守业务类型。
```ts
export interface User { id: number; name: string }
export function formatUser(user: User): string {
return `${user.id}:${user.name}`
}
test('格式化用户信息', () => {
const user: User = { id: 1, name: 'Ada' }
expect(formatUser(user)).toBe('1:Ada')
})
```
Mock 函数可以用 jest.MockedFunction 或 jest.mocked,避免 mockResolvedValue 的类型丢失。
```ts
import { fetchUser } from './api'
jest.mock('./api')
const mockedFetchUser = jest.mocked(fetchUser)
test('加载用户', async () => {
mockedFetchUser.mockResolvedValue({ id: 1, name: 'Ada' })
await expect(loadUserName(1)).resolves.toBe('Ada')
})
```
## 要不要让 Jest 做类型检查?
ts-jest 可以诊断类型错误,但大型项目里会拖慢测试。很多团队会把 tsc --noEmit 放到 CI 的单独步骤,让 Jest 专注行为测试。这样失败信息更清晰:类型错归类型检查,逻辑错归单元测试。
## 追问
### ts-jest、babel-jest 和 swc-jest 怎么选?
ts-jest 最贴近 TypeScript 编译器,路径、装饰器和部分 tsconfig 行为更容易对齐。Babel 或 SWC 通常更快,适合大型前端项目,但它们主要是转译,不负责完整类型检查。取舍是准确性和速度:配置复杂、依赖 TS 编译特性的项目优先 ts-jest;追求测试速度并已有独立 tsc --noEmit 的项目可以选 SWC。踩坑是以为 Jest 通过就代表类型没问题,实际上转译型方案可能放过类型错误。
### 路径别名为什么在测试里经常失效?
TypeScript 的 paths 只告诉编译器怎么解析,不会自动教 Jest 解析模块。Jest 需要单独配置 moduleNameMapper,或者用 pathsToModuleNameMapper 从 tsconfig 生成。边界是 monorepo 里 rootDir、baseUrl 和包边界更复杂,不能简单复制单包配置。常见坑是源码能编译,测试却报 Cannot find module '@/xxx'。
### ESM 项目配置 Jest 有什么坑?
ESM 下 type: module、tsconfig 的 module、Jest preset 和导入扩展名必须互相匹配。很多错误不是业务代码错,而是 CJS/ESM 混用导致模块加载失败。取舍是如果项目没有强 ESM 需求,测试环境保持 CJS 会省事很多;如果库要发布 ESM,就应该尽早把测试跑在接近发布格式的环境里。踩坑是 mock ESM 模块方式和 CJS 不同,旧的 jest.mock 习惯可能失效。
### TypeScript 测试里什么时候可以用 as any?
as any 可以用于刻意构造非法输入,测试运行时防御逻辑,例如后端收到脏数据。除此之外应尽量避免,因为它会绕开类型系统,让测试数据变得不可信。取舍是:为了测边界可以局部使用,但要用注释说明这是故意破坏类型。踩坑是为了省事大量 as any,最后测试覆盖了一个现实中根本不会出现的类型形状。
### 类型测试和行为测试要分开吗?
通常要分开。Jest 擅长测运行时行为,类型层面的断言可以用 tsd、expect-type 或 tsc --noEmit。边界是工具库、SDK、泛型函数这类类型就是产品能力的代码,应该补类型测试。普通业务项目则不必把所有类型都放进 Jest,否则会让测试意图变模糊。服务端5月31日 11:08
Jest 如何测试 fs 文件系统和 I/O 操作?测试文件系统代码时,最重要的问题不是“能不能读写文件”,而是“你的业务逻辑在文件存在、缺失、权限不足、内容损坏时是否处理正确”。Jest 可以 mock fs,也可以配合临时目录做接近真实的集成测试。两种方式都该会用,因为纯 Mock 快但容易脱离真实行为,真实 I/O 准但慢且需要清理。
## 什么时候 Mock fs?
如果函数只是包装读取、解析和错误处理,mock fs 很合适。它能让测试不依赖本机路径,也不会污染项目目录。
```js
const fs = require('fs')
jest.mock('fs')
function readConfig(path) {
const raw = fs.readFileSync(path, 'utf8')
return JSON.parse(raw)
}
test('读取并解析配置文件', () => {
fs.readFileSync.mockReturnValue('{"port":3000}')
expect(readConfig('/app/config.json')).toEqual({ port: 3000 })
expect(fs.readFileSync).toHaveBeenCalledWith('/app/config.json', 'utf8')
})
```
边界是:mock 出来的 fs 不会模拟所有 Node 行为,比如真实错误对象的 code、路径分隔符、权限差异、符号链接。只靠 mock,可能会漏掉生产环境里的路径问题。
## 异步 fs.promises 怎么测?
现代 Node 项目更常用 fs/promises。这时要 mock 对应模块,并用 mockResolvedValue 或 mockRejectedValue 表达成功和失败。
```js
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
mkdir: jest.fn()
}))
const fs = require('fs/promises')
test('文件不存在时返回默认配置', async () => {
fs.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
await expect(loadConfig('/no-file.json')).resolves.toEqual({})
})
```
这里的坑是不要只 mock happy path。I/O 最容易出问题的地方恰恰是 ENOENT、EACCES、JSON 格式错误、磁盘写入中断和目录不存在。
## 什么时候用真实临时文件?
涉及路径拼接、目录创建、文件遍历、编码、换行符或原子写入时,最好用临时目录跑一层集成测试。
```js
const os = require('os')
const path = require('path')
const fs = require('fs/promises')
test('写入后可以再次读取', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'jest-fs-'))
const file = path.join(dir, 'data.txt')
await saveText(file, 'hello')
await expect(fs.readFile(file, 'utf8')).resolves.toBe('hello')
await fs.rm(dir, { recursive: true, force: true })
})
```
清理必须放在 afterEach 或 try/finally 中,否则 CI 上一次失败会影响下一次运行。
## 追问
### Mock fs 和使用临时目录怎么取舍?
Mock fs 速度快、断言清晰,适合覆盖业务分支和错误处理。临时目录更接近真实环境,适合验证路径、编码、目录递归和跨平台行为。取舍标准是测试目标:想验证“函数是否调用了 fs”用 Mock,想验证“文件结果是否真的正确”用临时目录。踩坑是把所有测试都写成真实 I/O,最后测试套件变慢且偶发失败。
### 如何测试文件不存在和权限不足?
不要只断言抛错,要断言你的代码对错误的处理策略。文件不存在可能返回默认值,也可能提示用户创建配置;权限不足通常应该向上抛出更明确的错误。边界是不同系统的错误消息不稳定,不要用完整 message 做强匹配。更稳的是匹配 error.code,例如 ENOENT 或 EACCES。
### 为什么手动 mock fs 容易失真?
Node 的 fs API 细节很多,同步、异步、callback、promise 版本行为并不完全一样。手动 mock 只覆盖你想到的分支,没想到的地方会变成测试盲区。取舍是:小函数手动 mock 足够,大量文件操作可以考虑 memfs 这类内存文件系统。坑是 memfs 也不是完整操作系统,权限和符号链接仍可能与真实环境不同。
### 文件遍历测试要注意什么边界?
目录遍历要测空目录、嵌套目录、隐藏文件、扩展名过滤和排序稳定性。跨平台时还要避免硬编码 /,应该使用 path.join 或 path.resolve。取舍是测试里是否固定排序:如果业务需要稳定输出,就应该排序并测试;如果不需要,断言可以用集合方式。踩坑是 macOS 本地文件顺序和 Linux CI 不一致,导致测试偶发失败。
### 写入文件时如何避免测试污染?
每个测试都应创建独立临时目录,不要共用项目里的 fixtures/output。清理逻辑要放在 finally 或 afterEach,即使断言失败也能删除文件。边界是调试失败时你可能想保留文件,可以通过环境变量控制是否清理。不要在测试里写固定路径,尤其不要写用户主目录或仓库根目录。服务端5月31日 11:08
Jest 如何测试 Redux 的 Action、Reducer 和 Selector?Redux 测试最好按代码职责拆开:action creator 测返回的 action,reducer 测状态如何变化,selector 测派生数据,异步 thunk 或 RTK Query 再测副作用边界。不要把所有东西都塞进一个 React 组件测试里,否则失败时很难判断是 UI、store、接口 Mock,还是 reducer 写错了。Jest 的价值在于让这些纯逻辑可以被快速、稳定地验证。
## 从 reducer 开始最划算
reducer 通常是纯函数,输入旧 state 和 action,输出新 state。它不需要 DOM,也不需要 mock store,是 Redux 里最适合单元测试的部分。
```js
const initialState = { count: 0 }
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/increment':
return { ...state, count: state.count + 1 }
case 'counter/add':
return { ...state, count: state.count + action.payload }
default:
return state
}
}
test('处理 add action', () => {
expect(counterReducer({ count: 2 }, { type: 'counter/add', payload: 3 }))
.toEqual({ count: 5 })
})
```
这里要特别测 default 分支和不可变更新。一个常见坑是 reducer 直接修改原对象,简单断言结果可能看不出来,但会影响 React-Redux 的引用比较。可以在测试里冻结输入对象,或者使用 Redux Toolkit 的 Immer,让写法和不可变语义保持一致。
## action 和 selector 怎么测?
action creator 如果只是返回对象,测试应保持克制;过度测试会让代码变得像快照翻译。selector 更值得测,因为它经常藏着筛选、排序、权限判断和空值兼容。
```js
export const addTodo = text => ({ type: 'todos/add', payload: text })
export const selectDoneTodos = state => state.todos.items.filter(x => x.done)
test('创建 addTodo action', () => {
expect(addTodo('写测试')).toEqual({ type: 'todos/add', payload: '写测试' })
})
test('筛选已完成 todos', () => {
const state = { todos: { items: [{ done: true }, { done: false }] } }
expect(selectDoneTodos(state)).toHaveLength(1)
})
```
selector 的边界包括空数组、缺字段、排序稳定性和 memoized selector 的引用复用。尤其是 Reselect,如果 selector 每次都返回新数组,组件会无意义重渲染。
## 异步逻辑如何隔离?
传统 thunk 可以 mock API,再断言 dispatch 顺序。Redux Toolkit 的 createAsyncThunk 更推荐测 fulfilled/rejected 对 reducer 的影响,少测内部实现。
```js
test('fetchUser 成功后派发 success', async () => {
const api = { getUser: jest.fn().mockResolvedValue({ id: 1 }) }
const dispatch = jest.fn()
await fetchUser(1, api)(dispatch)
expect(dispatch).toHaveBeenCalledWith({ type: 'user/success', payload: { id: 1 } })
})
```
## 追问
### 为什么 reducer 测试比 action 测试更重要?
reducer 承担业务规则,状态加减、列表合并、错误回滚都在这里发生。action creator 很多时候只是对象工厂,测太细会带来重复断言。取舍是:复杂 action payload 需要测试,简单 action 可以靠 reducer 或集成测试覆盖。踩坑是只测 action 不测 reducer,最后 action 发出去了但状态根本没变。
### selector 应该测返回值还是 memoization?
普通 selector 测返回值即可,重点是不同 state 下结果是否符合业务预期。使用 Reselect 时,可以补一两个引用稳定性的测试,确认相同输入不会重新生成对象。边界是不要把 memoization 细节测得过死,否则换实现时测试会阻碍重构。真正有性能风险的 selector,例如大列表过滤和权限树计算,才值得额外测缓存行为。
### mock store 和真实 store 怎么选?
mock store 适合验证 thunk 派发了哪些 action,速度快,也容易断言顺序。真实 store 更适合验证 reducer、middleware 和组件一起工作后的最终 UI。取舍点是你关心“过程”还是“结果”:过程用 mock store,结果用真实 store。坑在于 redux-mock-store 不会真的更新 state,拿它测组件状态变化会得到误导性结果。
### Redux Toolkit 的 slice 还需要单独测 action 吗?
大多数情况下不需要单独测自动生成的 action creator,因为它们由库保证。更有价值的是测试 slice reducer 对 pending、fulfilled、rejected 的处理,以及 payload 边界。边界是如果你写了 prepare 回调,它包含格式化、生成 id 或清洗输入,就应该单测。否则测试越多越像在测试 Redux Toolkit 本身。
### React-Redux 组件测试要 mock useSelector 吗?
可以 mock,但不建议作为默认方案。mock useSelector 很快,却会绕过 Provider、store shape 和订阅更新,容易让测试与真实运行环境脱节。更稳的方式是创建一个测试 store,用 Provider 包住组件,然后从用户视角断言页面。只有组件很小、依赖 state 很简单,并且你明确只想隔离 UI 分支时,mock hook 才是合理取舍。服务端5月31日 11:08
Jest 如何配合 Vue Test Utils 测试 Vue 组件?Vue 组件测试的核心不是把组件内部每一行都测一遍,而是验证它对外表现是否稳定:传入 props 后渲染什么,用户点击后发生什么,是否 emit 正确事件,依赖插件或异步更新时是否能按预期收尾。Jest 负责断言、Mock 和运行环境,@vue/test-utils 负责把 Vue 组件挂载成可操作的 wrapper。实际项目里,测试越贴近用户行为,后期重构越不容易被测试绑住。
## 基础配置怎么写?
Vue 3 项目常见组合是 Jest、@vue/test-utils 和 @vue/vue3-jest。如果还在 Vue 2,需要换成对应版本的 vue-jest,这是最容易踩的版本坑。
```bash
npm i -D jest @vue/test-utils @vue/vue3-jest babel-jest jest-environment-jsdom
```
```js
module.exports = {
testEnvironment: 'jsdom',
moduleFileExtensions: ['js', 'json', 'vue'],
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.js$': 'babel-jest'
},
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }
}
```
这里的边界是:Jest 跑在 jsdom 中,不是真浏览器。它适合测组件逻辑和 DOM 结果,不适合验证真实布局、滚动位置、CSS 动画或浏览器兼容性;这些应交给 E2E 或视觉回归测试。
## 一个组件应该测什么?
以计数器为例,优先测用户能感知的结果,而不是直接断言 wrapper.vm.count。这样即使以后把 Options API 改成 Composition API,只要 UI 行为不变,测试仍然有效。
```js
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
test('点击按钮后展示新的计数并派发事件', async () => {
const wrapper = mount(Counter, { props: { initialCount: 1 } })
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toContain('Count: 2')
expect(wrapper.emitted('change')?.[0]).toEqual([2])
})
```
trigger 和 setProps 后通常要 await,因为 Vue DOM 更新是异步的。漏掉 await 时,测试可能本地偶尔通过、CI 偶尔失败,这类 flaky case 很难排查。
## 追问
### mount 和 shallowMount 应该怎么取舍?
mount 会渲染子组件,适合验证父子组合后的真实行为,例如表单组件和校验提示是否一起工作。shallowMount 会把子组件替换成 stub,速度更快,也能让测试只关注当前组件。取舍点在于测试目标:如果失败原因应该定位到当前组件,用 shallowMount;如果用户路径依赖子组件交互,用 mount。踩坑是过度使用 shallowMount 会把插槽、provide/inject 或子组件事件遮掉,导致测试通过但页面真实不可用。
### 为什么不建议大量断言 wrapper.vm?
wrapper.vm 能拿到组件实例,测 computed 或方法很方便,但它会把测试绑定到实现细节。组件重构后,用户看到的页面没变,测试却因为内部变量名变化而失败,这就是维护成本。边界是复杂计算逻辑如果没有抽成纯函数,又确实需要覆盖,可以少量使用 wrapper.vm。更稳的做法是通过文本、属性、事件和可访问角色来断言外部行为。
### 异步接口和插件依赖怎么测?
接口请求通常用 jest.fn() 或 MSW Mock,不要让单元测试真的访问网络。Vue Router、Pinia、i18n 这类插件可以通过 global.plugins 注入,也可以只 Mock 当前组件用到的最小能力。取舍在于真实性和速度:插件全量接入更像真实页面,但配置重、失败链路长;局部 Mock 更快,但容易漏掉集成问题。常见坑是异步 Promise 已经 resolve,但 Vue 还没完成 DOM 更新,需要配合 flushPromises() 和 await nextTick()。
### 测 props、slots 和 emitted 时重点是什么?
props 要测边界值,例如空字符串、缺省值、禁用状态,而不只是正常值。slots 要确认组件是否把外部内容放在正确位置,尤其是弹窗、表格列和布局组件。emitted 不只看有没有触发,还要看 payload 是否稳定,因为父组件往往依赖这个契约。踩坑是事件名大小写和 Vue 版本差异,模板里写法和测试里读取的事件名要保持一致。
### 什么时候该改用组件集成测试或 E2E?
当测试需要覆盖路由跳转、真实接口拦截、浏览器焦点、拖拽、文件上传时,单靠 Jest 会越来越别扭。Jest 的优势是快、反馈短,适合把组件的输入输出守住。E2E 更慢但更真实,适合覆盖关键业务链路。好的边界是:组件内部状态交给 Jest,跨页面和浏览器能力交给 Playwright 或 Cypress。前端5月28日 07:00
如何在 Jest 中测试 React 组件?常用的测试工具和查询方法有哪些?在 Jest 中测试 React 组件,核心思路是:渲染组件 → 查询元素 → 断言行为。React 官方推荐的测试方案是 Jest + React Testing Library(RTL),本文聚焦面试中高频考察的知识点。
## React 组件测试的基本流程是什么?
测试 React 组件通常分三步:
1. **渲染**:使用 RTL 的 `render` 方法将组件挂载到虚拟 DOM
2. **查询**:通过 `screen` 对象提供的方法定位页面元素
3. **断言**:使用 Jest 的 `expect` 验证元素状态或行为
```javascript
import { render, screen } from '@testing-library/react';
import Counter from './Counter';
test('counter displays initial value', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
```
## 查询方法的优先级怎么选?
RTL 的查询方法有三个前缀,区别在于元素不存在时的行为:
| 前缀 | 元素存在 | 元素不存在 | 适用场景 |
|------|---------|-----------|---------|
| `getBy*` | 返回元素 | 抛出错误 | 断言元素一定存在 |
| `queryBy*` | 返回元素 | 返回 null | 断言元素不存在 |
| `findBy*` | 返回 Promise | Promise reject | 异步元素出现 |
具体查询方法的推荐优先级:
1. **`getByRole`** — 最优先,基于 ARIA 角色,如 `button`、`textbox`、`heading`
2. **`getByLabelText`** — 表单元素优先用,关联 label 文本
3. **`getByPlaceholderText`** — 没有 label 时使用
4. **`getByText`** — 非表单元素(按钮、链接、段落)常用
5. **`getByTestId`** — 最后手段,需要手动添加 `data-testid` 属性
```javascript
// 推荐:通过角色查询
screen.getByRole('button', { name: /submit/i });
// 不推荐但有时必要:通过 testId 查询
screen.getByTestId('submit-btn');
```
**面试关键点**:优先使用 `getByRole` 是因为它验证了组件的可访问性,这与 RTL "测试用户视角" 的核心理念一致。
## 如何测试用户交互?
使用 `fireEvent` 或 `userEvent` 模拟用户操作。`userEvent` 更接近真实用户行为,推荐优先使用。
```javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('clicking button increments counter', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
```
`fireEvent` 与 `userEvent` 的区别:
- `fireEvent.click()` 只触发 click 事件
- `userEvent.click()` 会依次触发 mousedown → mouseup → focus → click,更贴近真实操作
- `userEvent.type()` 会逐字符触发键盘事件,而 `fireEvent.change()` 直接修改值
## 异步组件怎么测试?
异步场景(接口请求、定时器、状态延迟更新)使用 `waitFor` 或 `findBy*` 处理。
```javascript
import { render, screen, waitFor } from '@testing-library/react';
test('displays user data after loading', async () => {
render(<UserProfile userId={1} />);
// 方式一:findBy(推荐,更简洁)
expect(await screen.findByText('John')).toBeInTheDocument();
// 方式二:waitFor(更灵活,可组合多个断言)
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
```
**常见坑**:`waitFor` 中不要用 `queryBy*`,因为它不抛错,断言不会失败,导致测试误通过。应使用 `getBy*`。
## 如何 Mock 模块和 API 请求?
面试中常考的 Mock 手段分两种:
**Jest.fn() — Mock 函数**
```javascript
test('calls onSubmit with form data', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com'
});
});
```
**jest.mock — Mock 模块**
```javascript
// Mock API 请求模块
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'John' })
}));
test('renders fetched user name', async () => {
render(<UserProfile />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
```
对于更复杂的 API Mock 场景,可以使用 Mock Service Worker(MSW),它在 Service Worker 层拦截请求,不需要修改业务代码。
## React Hooks 怎么测试?
自定义 Hook 使用 `renderHook` 进行测试:
```javascript
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('useCounter increments and decrements', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0);
});
```
**注意**:状态更新必须包裹在 `act()` 中,否则 Jest 会报警告。`renderHook` 已从 RTL v13 起内置,不再需要 `@testing-library/react-hooks` 包。
## 快照测试怎么用?什么场景下用?
```javascript
import renderer from 'react-test-renderer';
test('Button matches snapshot', () => {
const tree = renderer.create(<Button>Click</Button>).toJSON();
expect(tree).toMatchSnapshot();
});
```
快照测试的适用与不适用:
- 适合:配置型组件(Theme、Layout),结构稳定的纯展示组件
- 不适合:频繁变动的业务组件,否则每次改动都要更新快照,失去测试价值
**面试加分点**:快照测试只是确认结构没变,并不验证行为是否正确,所以不能替代行为测试。
## 测试 React 组件有哪些最佳实践?
1. **测试行为,不测实现** — 不测内部 state 的值,测用户看到的结果
2. **避免过度 Mock** — Mock 越多,测试离真实场景越远
3. **查询方法按优先级选** — `getByRole` > `getByLabelText` > `getByText` > `getByTestId`
4. **异步用 `findBy` 优于 `waitFor` + `getBy`** — 更简洁,语义更清晰
5. **使用 `screen` 而非 `render` 返回值** — 避免反复解构,代码更干净
6. **一个测试只验证一个行为** — 方便定位失败原因
**面试追问方向**:如何测试 Context Provider 包裹的组件?如何处理第三方库的渲染行为?如何在 CI 中提升测试执行速度?这些是区分中级与高级的关键问题。前端5月28日 06:59
如何在 Jest 中 Mock fetch 和 Axios 测试 API 调用?## 核心思路
测试 API 调用的关键原则是**隔离外部依赖**——不发出真实网络请求,用 Mock 替代,验证的是"你的代码如何调用 API、如何处理响应",而非 API 本身的行为。
Jest 提供了三种主要 Mock 手段:`jest.mock()` 模块级替换、`jest.spyOn()` 方法级监听、`jest.fn()` 手动创建假函数。理解三者的区别和适用场景,是这道题的答题主线。
## Mock Axios 的两种方式
### 方式一:jest.mock() 替换整个模块
`jest.mock('axios')` 会将 axios 模块中所有导出替换为 jest.fn(),适合需要完全控制模块行为的场景:
```javascript
import axios from 'axios';
import { getUser } from './api';
jest.mock('axios');
test('getUser 应返回用户数据', async () => {
const mockData = { id: 1, name: 'Tom' };
axios.get.mockResolvedValue({ data: mockData });
const result = await getUser(1);
expect(result).toEqual(mockData);
expect(axios.get).toHaveBeenCalledWith('/users/1');
});
```
`mockResolvedValue` 让 `axios.get` 返回一个 resolved Promise,模拟成功响应。`toHaveBeenCalledWith` 断言调用参数,确保请求地址正确。
### 方式二:jest.spyOn() 监听原方法
`jest.spyOn` 不替换模块,而是包装原方法,可以追踪调用并控制返回值,还能通过 `mockRestore()` 恢复原实现:
```javascript
import axios from 'axios';
import { getUser } from './api';
test('getUser 应返回用户数据', async () => {
const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { id: 1, name: 'Tom' } });
const result = await getUser(1);
expect(result).toEqual({ id: 1, name: 'Tom' });
spy.mockRestore(); // 恢复 axios.get 原实现
});
```
**何时选哪个?** `jest.mock()` 适合整个测试文件都需要 mock 的场景;`jest.spyOn()` 适合只想在单个测试中临时 mock、其余测试保留真实行为的场景。
## Mock fetch 的两种方式
### 方式一:jest.fn() 替换全局 fetch
fetch 是全局对象上的方法,直接赋值即可替换:
```javascript
import { fetchPosts } from './api';
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([{ id: 1, title: 'Hello' }]),
})
);
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetchPosts 应返回帖子列表', async () => {
const posts = await fetchPosts();
expect(posts).toEqual([{ id: 1, title: 'Hello' }]);
expect(global.fetch).toHaveBeenCalledWith('/api/posts');
});
```
这里用 `beforeEach` / `afterEach` 管理 Mock 生命周期,避免测试间互相污染——这是面试中经常追问的考点。
### 方式二:jest.spyOn() 监听全局 fetch
```javascript
test('fetchPosts 处理响应数据', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: 1 }]),
});
const posts = await fetchPosts();
expect(posts).toEqual([{ id: 1 }]);
});
```
## 测试错误场景
只测成功路径是不够的,面试官一定会问"网络请求失败了怎么办":
```javascript
test('getUser 应抛出网络错误', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(getUser(1)).rejects.toThrow('Network Error');
});
test('getUser 应处理 404 响应', async () => {
axios.get.mockRejectedValue({
response: { status: 404, data: { message: 'Not Found' } },
});
await expect(getUser(999)).rejects.toMatchObject({
response: { status: 404 },
});
});
```
`mockRejectedValue` 模拟 Promise reject,覆盖网络异常和服务端错误两种情况。
## 使用 MSW 做更真实的拦截
当项目有大量 API 需要测试时,逐个 `jest.mock` 维护成本高。MSW(Mock Service Worker)在网络层拦截请求,不需要修改业务代码:
```javascript
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: req.params.id, name: 'Tom' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('getUser 通过 MSW 返回数据', async () => {
const user = await getUser(1);
expect(user).toEqual({ id: '1', name: 'Tom' });
});
```
MSW 的优势:可以在运行时动态修改响应(`server.use()`),测试超时、限流等边界场景;同一套 handler 可复用于单元测试和集成测试。
## 关键差异速查
| 场景 | 推荐方案 | 原因 |
|------|---------|------|
| Mock 整个第三方库 | `jest.mock()` | 一键替换所有导出 |
| 单个测试临时 Mock | `jest.spyOn()` | 可恢复,不影响其他测试 |
| Mock 全局 API(fetch) | `jest.fn()` / `spyOn` | fetch 是全局变量,需手动处理 |
| 大量 API 集成测试 | MSW | 网络层拦截,维护成本低 |
## 面试追问方向
- **jest.mock 和 jest.spyOn 的本质区别?** mock 是替换,spyOn 是包装。mock 后原实现丢失,spyOn 可恢复。
- **为什么要避免测试中发出真实请求?** 网络不稳定、速度慢、可能产生脏数据、依赖外部服务可用性。
- **Mock 污染怎么解决?** beforeEach 重置、afterEach 调用 `jest.restoreAllMocks()`、每个测试独立设置数据。
- **如何测试请求重试逻辑?** 用 `mockRejectedValueOnce` 连续返回失败,最后一次返回成功,模拟重试后恢复。服务端5月28日 05:27
如何配置 Jest?常用配置选项有哪些?Jest 有三种配置方式:`package.json` 的 `jest` 字段、独立的 `jest.config.js`(或 `.ts`/`.json`/`.mjs`)文件、以及 CLI 参数 `--config`。实际项目中 90% 用 `jest.config.js`,因为可读性好、能写注释、支持条件逻辑。
核心配置项按优先级说:
**testEnvironment** — 决定测试运行环境。`node` 适合纯逻辑(工具函数、后端),`jsdom` 模拟浏览器 DOM(React 组件、DOM 操作)。选错会导致全局对象找不到或内存飙升。Next.js 项目用 `@jest/globals` 里的 `customExportConditions` 可以按组件区分环境。
**transform** — 告诉 Jest 用什么转换器处理非 JS 文件。`babel-jest` 是默认值,TypeScript 项目换成 `ts-jest` 或用 `@swc/jest` 加速。配错了表现为 `SyntaxError: Unexpected token`。
**moduleNameMapper** — 路径别名映射。配 Webpack/Vite 的 `@/` 前缀、CSS/图片等静态资源的 mock 都靠它。最常见写法:`'^@/(.*)$': '<rootDir>/src/$1'`,静态资源用 `identity-obj-proxy`。
**transformIgnorePatterns** — 指定哪些文件不做转换。默认忽略整个 `node_modules`,但 ESM 包(如 `lodash-es`、`axios`)没编译成 CJS 就会报错。解法是用负向先行断言:`'/node_modules/(?!(lodash-es|axios)/)'`。
**setupFilesAfterEnv** — 测试环境初始化后执行的脚本,用来引入 `@testing-library/jest-dom` 的扩展匹配器、全局 mock `window.matchMedia` 等。区别于 `setupFiles`(在测试框架加载前运行,一般用不到)。
**coverageThreshold** — 覆盖率门禁。团队规范通常设 `branches: 80, functions: 80, lines: 80`,CI 中低于阈值直接失败。
**preset** — 一键继承预置配置。`ts-jest` 提供 `preset: 'ts-jest'`,React 项目用 `react-app`(CRA)或 `@testing-library/react/jest-dom`。preset 和手动配置重复时,手动配置优先。
**projects** — monorepo 专属,每个子项目可以独立配置 testEnvironment、transform 等,Jest 并行跑所有项目。
## 追问
### testEnvironment 选 node 还是 jsdom 怎么决定?
跑纯函数、Node API 用 `node`;涉及 DOM 操作、React 渲染用 `jsdom`。`jsdom` 内存开销大,API 不完整(没有 `canvas` 布局、`IntersectionObserver`),需要额外 mock。同一个项目可以按目录分 projects 配不同环境。
### transformIgnorePatterns 配了但不生效怎么办?
先跑 `npx jest --showConfig` 看实际合并后的配置,preset 可能覆盖了你的设置。常见坑:正则里的路径分隔符在 Windows 上不一致,或者忘了负向断言里的 `/`。清缓存 `jest --clearCache` 再试。
### Jest 跑 ESM 包一直报 SyntaxError 怎么排查?
三步:1)确认 `transform` 配了对应转换器;2)检查 `transformIgnorePatterns` 是否把该包排除了忽略列表;3)如果包本身只导出 ESM,考虑用 `moduleNameMapper` 指向 CJS 入口或者直接 mock 掉。
### monorepo 里 packages 互相依赖怎么配 Jest?
用 `projects` 配置,每个 package 指定自己的 `rootDir` 和 `testMatch`。packages 间依赖通过 `moduleNameMapper` 映射到源码目录而不是 `dist`,这样改了依赖包的代码测试立即生效。
## 写段代码
```js
// jest.config.js — React + TS 项目典型配置
module.exports = {
testEnvironment: 'jsdom',
transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest' },
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less)$': 'identity-obj-proxy',
},
transformIgnorePatterns: [
'/node_modules/(?!(lodash-es)/)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80 },
},
};
```服务端5月28日 04:25
Jest 中 test.skip 和 test.only 有什么区别?Jest 用 .skip 排除测试,用 .only 聚焦测试——两种思路,作用对象都可以是单个 test 或整个 describe。
**跳过(skip)**:标记的测试不执行,但会在报告中显示为 skipped。
```javascript
test.skip('暂时不跑', () => { ... }); // 等价于 xtest / xit
describe.skip('整组跳过', () => { ... }); // 等价于 xdescribe
```
**聚焦(only)**:只执行标记的测试/套件,其余全部跳过。
```javascript
test.only('只跑这个', () => { ... }); // 等价于 fit / it.only
describe.only('只跑这组', () => { ... }); // 等价于 fdescribe
```
关键区别:skip 是"排除法",only 是"聚焦法"。多个 only 会全部执行——它不是"仅这一个",而是"至少这些"。
## 追问
### test.skip 和 describe.skip 什么时候用?
单个用例有问题用 test.skip,整个模块依赖没准备好用 describe.skip。常见场景:某个 API 还没上线、测试依赖的外部服务挂了。但千万别把 skip 当摆设——CI 里积压的 skip 测试是技术债,团队应有清理机制。
### .only 提交到 CI 会怎样?
CI 只跑被 only 标记的测试,大量测试被静默跳过,回归缺陷直接漏到线上。防御手段:eslint-plugin-jest 的 `no-focused-tests` 规则,在 pre-commit 或 CI 阶段拦截。也有团队在 CI 启动时用自定义 Jest Environment 强制把 .only 和 .skip 还原成普通函数,确保全量执行。
### 条件性跳过怎么写?
```javascript
const skipInCI = process.env.CI ? test.skip : test;
skipInCI('本地才跑的测试', () => { ... });
```
或用 Jest 28+ 的 `describe.skipIf` / `test.skipIf`:
```javascript
test.skipIf(process.env.CI)('本地才跑', () => { ... });
```
### 命令行过滤和 .only 有什么区别?
`jest --testNamePattern="should add"` 是纯命令行行为,不改代码,不污染仓库。.only 写在代码里,容易误提交。日常调试优先用命令行参数或 `--onlyChanged`,只有需要在特定文件内反复调试时才用 .only。
### 怎么防止团队积累大量 skip 测试?
三招配合:1) ESLint 规则 `no-disabled-tests` 配合 `warn`,skip 超过阈值就 CI 失败;2) 要求 skip 必须带注释说明原因和预期恢复时间;3) 每次发版前用 `jest --listTests --onlyFailures` 扫一遍,skip 数量纳入代码健康指标。服务端5月28日 04:23
Jest 如何测试异步代码?4 种方式与常见坑Jest 测试异步代码有四种方式,按推荐优先级排列:async/await、resolves/rejects 匹配器、返回 Promise、done 回调。核心原则只有一个——**让 Jest 知道测试什么时候算完**。
最常用的是 async/await,直接在 test 函数加 `async`,用 `await` 等待异步结果:
```javascript
test('fetches user', async () => {
const user = await getUser(1);
expect(user.name).toBe('Alice');
});
```
如果你不需要对结果做复杂断言,`.resolves` / `.rejects` 更简洁:
```javascript
test('resolves with data', () => {
return expect(fetchData()).resolves.toBe('ok');
});
test('rejects on error', () => {
return expect(fetchData()).rejects.toThrow('not found');
});
```
注意这里必须 `return`,否则 Jest 不会等 Promise 结束。
老项目里遇到回调风格的异步代码,用 `done` 参数:
```javascript
test('callback style', done => {
readFile('config.json', (err, data) => {
if (err) { done(err); return; }
try {
expect(data.port).toBe(3000);
done();
} catch (e) {
done(e);
}
});
});
```
`done` 里面务必包 try-catch,否则 expect 失败会抛异常,`done()` 永远不被调用,你看到的不是断言错误而是超时错误,排查半天。
## 追问
### 忘记 return Promise 会怎样?
测试立即通过——而且是假通过。Jest 认为同步部分执行完就算结束,Promise 还没 resolve 就已经收工了。这是异步测试里最常见的坑,排查时看测试函数有没有 return 或 await 就行。
### done 和 Promise 能混用吗?
不能。Jest 检测到同一个测试既传了 `done` 又返回了 Promise,会直接抛错,防止内存泄漏。选一种用到底。
### async 函数抛错怎么测?
`expect(fn()).toThrow()` 对 async 无效,因为 async 函数返回的是 Promise 而不是直接抛错。正确写法:
```javascript
await expect(getUser(-1)).rejects.toThrow('invalid id');
```
或者用 try-catch 配合 `expect.assertions(1)` 确保断言真的被执行了。
### 定时器相关的异步怎么测?
用 `jest.useFakeTimers()` 把定时器替换成模拟的,然后手动推进时间,不用真等:
```javascript
jest.useFakeTimers();
test('debounce fires after delay', () => {
const fn = jest.fn();
debounce(fn, 300);
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalled();
});
```
### 实际项目里哪种用得最多?
async/await 占绝大多数场景,`.resolves`/`.rejects` 适合单行断言,`done` 基本只在对接老式回调 API 时才用。定时器模拟主要出现在防抖、轮询、超时重试这类逻辑里。服务端5月28日 04:22
Jest 测试怎么运行和调试?常用命令有哪些?## 核心命令一览
运行测试最常用的几个命令:
```bash
# 运行所有测试
npx jest
# 运行指定文件
npx jest path/to/test.spec.js
# 运行匹配名称的用例
npx jest --testNamePattern="should add"
# 监听模式,文件变动自动重跑
npx jest --watch
# 只跑上次失败的用例
npx jest --onlyFailures
# 只跑和改动文件相关的用例
npx jest --onlyChanged
```
`--watch` 是日常开发最高频的选项,保存即跑,不用手动重复执行。`--onlyFailures` 在修复阶段很实用——测试多的时候不用每次全量跑一遍。
## 运行测试的常见场景
### 按文件或路径筛选
```bash
# 跑某个目录下的所有测试
npx jest src/utils/
# 用正则匹配文件名
npx jest --testPathPattern="auth"
```
`--testPathPattern` 接收正则表达式,比手动拼路径灵活得多。比如项目里测试文件散落在多个目录,用 `--testPathPattern="user"` 就能一次跑完所有用户相关的测试。
### 按用例名称筛选
```bash
# 缩写形式
npx jest -t "login"
# 完整写法
npx jest --testNamePattern="should handle error"
```
`-t` 是 `--testNamePattern` 的缩写,匹配的是 `describe` 或 `test` 块的名字。注意它是正则匹配,写 `"add"` 会同时命中 `"should add"` 和 `"should handle addError"`。
### 在 CI 环境中运行
CI 环境和本地开发不同,通常需要关注几个问题:
```bash
# CI 中推荐的做法
npx jest --ci --coverage --forceExit --detectOpenHandles
```
- `--ci`:禁用快照交互提示,避免 CI 卡住
- `--coverage`:生成覆盖率报告,配合配置阈值可以在覆盖率不达标时让构建失败
- `--forceExit`:测试跑完强制退出进程,防止异步操作(定时器、未关闭的连接)导致进程挂起
- `--detectOpenHandles`:检测未关闭的句柄,帮你定位是哪个异步操作阻止了退出
## 调试测试的实用方法
### 用 console.log 快速排查
最直接的方式,适合简单问题:
```javascript
test('计算结果验证', () => {
const result = calculate(2, 3);
console.log('结果:', result); // 快速看输出
expect(result).toBe(5);
});
```
注意 `console.log` 在并行模式下输出顺序可能混乱,调试时建议加 `--runInBand`。
### 用 --runInBand 单线程运行
这是调试的关键选项。Jest 默认用多个 worker 进程并行跑测试,这会导致断点无法命中、日志顺序错乱。`--runInBand` 让所有测试在同一个进程中顺序执行:
```bash
npx jest --runInBand
```
什么时候必须加 `--runInBand`:
- 使用 `debugger` 断点调试时
- 用 Chrome DevTools Inspector 时
- 测试间有共享状态(虽然不推荐,但遗留项目常见)
- 需要 console.log 输出按顺序排列时
### 用 Node Inspector 调试
在代码中加 `debugger` 语句,然后用 Node 的 Inspector 模式启动 Jest:
```bash
node --inspect-brk ./node_modules/.bin/jest --runInBand
```
`--inspect-brk` 会在第一行就暂停,给你时间打开调试工具。然后打开 Chrome,访问 `chrome://inspect`,点击 "inspect" 就能进入 DevTools 调试界面。
### 用 VSCode 调试
在 `.vscode/launch.json` 中添加配置:
```json
{
"type": "node",
"request": "launch",
"name": "Jest Current File",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${fileBasenameNoExtension}", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
```
配好之后,打开测试文件直接按 F5 就能断点调试,比每次手敲命令方便很多。
### 用 --verbose 查看详细输出
```bash
npx jest --verbose
```
`--verbose` 会让每个测试用例单独列出结果,包括嵌套的 `describe` 层级。默认输出只显示文件级别的通过/失败,用 `--verbose` 能快速定位是哪个用例出了问题。
## 常用命令行选项速查
| 选项 | 作用 | 使用场景 |
|------|------|----------|
| `--runInBand` | 单进程顺序执行 | 调试、需要稳定输出顺序 |
| `--watch` | 监听文件变化自动重跑 | 日常开发 |
| `--onlyFailures` | 只跑失败的用例 | 修复阶段 |
| `--bail` | 遇到失败立即停止 | 快速发现问题 |
| `--coverage` | 生成覆盖率报告 | CI 检查、质量把控 |
| `--detectOpenHandles` | 检测未关闭的句柄 | 进程挂起时排查 |
| `--forceExit` | 强制退出 | CI 环境、异步泄漏 |
| `--verbose` | 显示详细用例结果 | 定位具体失败用例 |
| `--no-cache` | 禁用缓存 | 怀疑缓存导致问题时 |
| `--ci` | CI 模式 | 持续集成环境 |
## 常见问题排查
测试跑不过的时候,按这个顺序排查:
1. 先加 `--verbose` 看清楚是哪个用例失败
2. 用 `--runInBand` 单线程重跑,排除并行导致的问题
3. 加 `--no-cache` 排除缓存干扰
4. 用 `debugger` 或 `console.log` 在失败处打断点
5. 如果进程卡住不退出,用 `--detectOpenHandles` 找到未关闭的资源
记住一点:并行模式下测试通过但单线程失败,或者反过来,通常说明测试之间有隐式依赖,需要检查是否共享了状态或 mock 没有正确清理。
服务端5月27日 19:58
Jest 代码覆盖率怎么配置?四个指标分别是什么意思?Jest 内置了代码覆盖率收集功能,基于 Istanbul(Babel provider)或 V8 引擎实现。运行 `jest --coverage` 即可生成报告,四种核心指标:**语句覆盖率(Statements)**衡量代码语句执行比例,**分支覆盖率(Branches)**衡量 if/switch 等分支走过了多少,**函数覆盖率(Functions)**统计函数调用比例,**行覆盖率(Lines)**统计代码行执行比例。四个指标中分支覆盖率通常最低,也最值得重点关注——因为未覆盖的分支意味着逻辑路径没被测到。配置方面,`collectCoverageFrom` 控制统计范围,`coverageThreshold` 设置门槛,`coverageReporters` 选择输出格式(text 控制台、lcov 给 CI、html 可视化浏览)。阈值支持全局和按文件/目录设置,还能用负数表示"最多允许 N 个未覆盖项"。
## 追问
### Statements 和 Lines 有什么区别?不都是行吗?
不是。一行代码可以包含多条语句,比如 `let a = 1, b = 2;` 是一条行但两条语句。反过来,一条 if 判断如果跨行书写,行覆盖率可能覆盖了但分支没覆盖。实际项目中这两个数字通常很接近,差异大说明代码风格比较紧凑。
### 覆盖率到了 100% 就说明测试充分吗?
不是。覆盖率只衡量"有没有被执行过",不衡量"有没有被正确验证"。比如一个函数返回值你从没断言,但函数被调用了,语句覆盖率照样算通过。另外边界值、异常路径、并发场景这些覆盖率工具本身很难捕捉。80% 是常见基线,核心模块可以要求更高。
### babel provider 和 v8 provider 怎么选?
Babel provider 是默认选项,通过代码插桩(instrumentation)收集覆盖率,支持 `/* istanbul ignore next */` 跳过指定行。V8 provider 利用 V8 引擎原生覆盖率 API,速度更快但不支持 Istanbul 忽略注释(改用 `/* c8 ignore next */`)。大型项目如果 Babel provider 跑覆盖率太慢,可以试 `coverageProvider: "v8"`,但注意 V8 provider 是实验性功能,输出精度在某些边界场景有差异。
### CI 里覆盖率检查不通过怎么排查?
先看 HTML 报告里标红的文件,重点看分支覆盖——很多是 `else` 分支或三元表达式的某一端没走到。常见原因:错误处理路径没测、环境判断(`if (process.env.NODE_ENV === "production")`)在测试环境走不到、死代码没排除。用 `collectCoverageFrom` 排除配置文件和类型定义,用负数阈值给特定模块放宽限制,比如 `{ "./src/legacy/**/*.js": { statements: -20 } }` 允许老代码最多 20 个语句未覆盖。
## 写段代码
```javascript
// jest.config.js
module.exports = {
collectCoverage: true,
coverageProvider: "v8", // 或 "babel"
collectCoverageFrom: [
"src/**/*.{js,ts}",
"!src/**/*.d.ts",
"!src/index.ts",
],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
"./src/core/**/*.ts": { branches: 90 }, // 核心模块更严格
},
coverageReporters: ["text-summary", "lcov", "html"],
};
```服务端5月27日 19:58
如何在 Jest 中进行参数化测试?如何使用 test.each 和 describe.each?## 为什么需要参数化测试
写测试的时候,经常会遇到同一套逻辑需要用不同数据反复验证的情况。比如一个加法函数,你要测正数、负数、零、边界值,如果每组数据都单独写一个 test,代码会变得冗长且难以维护。参数化测试就是为了解决这个问题——把数据和断言逻辑分离,用一份测试代码覆盖多组输入。
Jest 提供了 `test.each` 和 `describe.each` 两个 API 来实现参数化测试。前者对单条测试用例做参数化,后者对整组测试做参数化,两者搭配可以显著减少重复代码。
## test.each 的基本用法
`test.each` 接收一个数组,数组中的每个元素代表一组测试数据,Jest 会为每组数据生成一条独立的测试用例。
用二维数组传入参数,这是最直接的写法:
```javascript
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('adds %i + %i = %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
```
注意测试名称中的 `%i` 是占位符,Jest 会按顺序用数组元素替换它们。常用的占位符有:`%s`(字符串)、`%i`(整数)、`%d`(数字)、`%p`(pretty-format)、`%#`(测试索引)。
## 用对象数组提高可读性
二维数组的参数顺序容易搞混,特别是参数多的时候。用对象数组可以让每组数据的含义一目了然:
```javascript
test.each([
{ a: 1, b: 1, expected: 2 },
{ a: 1, b: 2, expected: 3 },
{ a: 2, b: 1, expected: 3 },
])('$a + $b = $expected', ({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
});
```
对象数组的测试名称用 `$key` 的语法引用对象属性,比位置占位符更清晰。如果某个属性值是对象或数组,用 `$key` 也能自动展开显示。
## 表格语法的写法
Jest 还支持用模板字符串写表格式的参数化数据,可读性更好,特别适合数据量较多的场景:
```javascript
test.each`
a | b | expected
${1} | ${1} | ${2}
${1} | ${2} | ${3}
${2} | ${1} | ${3}
`('returns $expected when $a is added to $b', ({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
});
```
表格语法有几个要点:表头行定义变量名,用 `|` 分隔;数据行中 JavaScript 表达式必须用 `${}` 包裹;字符串值可以不用 `${}`,直接写即可。这种方式在测试报告里看起来像一张表格,维护和审查都很方便。
## describe.each 分组参数化
当你需要针对不同环境或配置运行一整套测试时,`describe.each` 就派上用场了。它为每组数据生成一个 describe 块,里面可以包含多条测试:
```javascript
describe.each([
['node', 'node'],
['jsdom', 'browser'],
])('test environment: %s', (env, type) => {
test(`runs in ${type} environment`, () => {
expect(process.env.NODE_ENV).toBeDefined();
});
test('has correct global scope', () => {
if (env === 'jsdom') {
expect(window).toBeDefined();
} else {
expect(global).toBeDefined();
}
});
});
```
这个例子中,两组环境配置各自生成一个 describe 块,每个块里有两条测试。`describe.each` 同样支持对象数组和表格语法,用法和 `test.each` 一致。
## 参数化测试边界情况和错误处理
参数化测试不只是测正常路径,更实用的场景是批量覆盖边界值和异常输入:
```javascript
test.each([
[0, 0, 0],
[Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER + 1],
[Number.MIN_SAFE_INTEGER, -1, Number.MIN_SAFE_INTEGER - 1],
])('handles edge cases: %i + %i = %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
```
```javascript
test.each([
[undefined, 'input is required'],
[null, 'input is required'],
['', 'input cannot be empty'],
])('throws error for invalid input: %p', (input, expectedError) => {
expect(() => validate(input)).toThrow(expectedError);
});
```
把正常值、边界值、异常值分不同的 `test.each` 组织,测试报告里失败用例一目了然,比把所有数据塞进一个 `each` 更容易定位问题。
## 常见踩坑点
占位符和参数数量不匹配。测试名称里的 `%s`、`%i` 等占位符数量必须和数组元素个数一致,多一个少一个都会报错。如果嫌数占位符麻烦,推荐用对象数组加 `$key` 的方式。
异步测试忘记返回 Promise。参数化测试中的回调函数如果是异步的,和普通测试一样需要返回 Promise 或使用 async/await,这个容易遗漏:
```javascript
test.each([
[1, 2],
[3, 4],
])('async test for %i and %i', async (a, b) => {
const result = await asyncAdd(a, b);
expect(result).toBe(a + b);
});
```
表格语法中的类型陷阱。表格语法里不加 `${}` 的值会被当作字符串处理,所以数字、布尔值、对象必须用 `${}` 包裹,否则拿到的是字符串类型的值,断言结果可能不符合预期。
## 实战建议
在实际项目中,参数化测试用得好可以大幅提升测试覆盖率和可维护性,但也要注意分寸。一组测试数据建议控制在 10 条以内,超过这个数量就要考虑是否该拆分场景。数据太多时测试报告可读性会下降,调试也不方便。
选择哪种语法形式可以按场景来:两三个简单参数用二维数组就够了;参数多或者含义不明显时用对象数组;数据量大、需要表格化展示时用模板字符串语法。
`test.each` 和 `describe.each` 也可以嵌套使用,外层用 `describe.each` 按环境或配置分组,内层用 `test.each` 跑具体数据,这样测试结构既清晰又紧凑。服务端5月27日 19:55
Jest 断言方法有哪些?expect 和匹配器怎么用?Jest 断言就一个套路:`expect(实际值).匹配器(期望值)`。匹配器决定怎么比,面试常考的分这几类:
**相等性**:`toBe` 用 `===`,只适合基本类型;`toEqual` 递归比较对象和数组每个属性,比对象首选它。两个高频坑:`expect({a:1}).toBe({a:1})` 永远失败(引用不同);`toEqual` 会忽略 `undefined` 属性,需要严格比较用 `toStrictEqual`。`toMatchObject` 只匹配属性子集,适合只关心部分字段。
**真假值**:`toBeNull`/`toBeUndefined`/`toBeDefined` 各自只匹配一个值;`toBeTruthy`/`toBeFalsy` 按 JS 强制布尔转换——`0`、`""`、`null`、`undefined`、`NaN` 是 falsy,其余 truthy。别混用:`toBeFalsy` 比 `toBeUndefined` 宽泛得多。
**数字**:`toBeGreaterThan`/`toBeLessThan` 及 OrEqual 变体。浮点数必须 `toBeCloseTo`——`0.1 + 0.2 !== 0.3` 是 JS 经典问题,用 `toBe` 比浮点数会翻车。
**字符串与容器**:`toMatch` 匹配正则或子串;`toContain` 检查数组含元素或字符串含子串;`toHaveLength` 检查长度;`toHaveProperty` 检查对象属性。
**异常**:`toThrow` 断言函数抛错,可匹配错误消息(字符串或正则)。必须传函数引用 `expect(fn).toThrow()`,传调用结果 `expect(fn()).toThrow()` 会在 expect 执行前就崩了。
**异步**:`resolves`/`rejects` 断言 Promise 结果,**必须 await**——忘了 await 是新手最常犯的错,断言还没完成测试就静默通过了。
**否定修饰**:任何匹配器前加 `.not` 取反。但别滥用:`expect(x).not.toBeUndefined()` 不如直接 `expect(x).toBeDefined()`。
**Mock**:`toHaveBeenCalledWith` 检查调用参数;`toHaveBeenCalledTimes` 检查调用次数;`toMatchSnapshot` 做 UI 渲染快照回归。
## 追问
### toBe 和 toEqual 有什么区别?什么时候用哪个?
`toBe` 是引用相等(`===`),基本类型值相同就过,对象必须同一引用才过。`toEqual` 递归比较每个属性,结构相同就过。一句话:基本类型用 `toBe`,对象数组用 `toEqual`。面试里 90% 的坑就是拿 `toBe` 比对象然后一脸懵。
### Jest 异步测试怎么写?
三种方式:回调用 `done` 参数,Promise 用 `resolves`/`rejects`,async/await 同样配 `resolves`/`rejects`。最大坑是忘 await——`expect(promise).resolves.toBe(x)` 不加 await,断言没跑完测试就 passed 了。正确写法:`await expect(fetchData()).resolves.toEqual(data)`。
### toThrow 有什么注意点?
两个坑:一、必须传函数引用不是调用结果,前面说了;二、只捕获同步错误,异步错误得用 `rejects.toThrow()`。还有个细节:`toThrow` 匹配的是 error message 不是 error 类型,要精确匹配传字符串或正则。
### .not 能和所有匹配器组合吗?
语法上可以,但语义上别乱用。`expect(x).not.toBeUndefined()` 和 `expect(x).toBeDefined()` 结果一样,后者更清晰。`.not` 用在"不应该发生"的场景:函数不应抛错、返回不应为 null、mock 不应被调用。
### 项目里哪些匹配器用得最多?
`toEqual` 和 `toBe` 占七成以上——几乎所有测试都在比较值;`toHaveBeenCalledWith` 和 `toThrow` 是第二梯队——验证 mock 和错误分支;`toMatchSnapshot` 在组件测试中大量使用。掌握这几个就能覆盖日常 80% 的断言场景。
## 写段代码
```javascript
// toBe vs toEqual
expect(1 + 1).toBe(2);
expect({ name: 'a' }).not.toBe({ name: 'a' }); // 引用不同,失败
expect({ name: 'a' }).toEqual({ name: 'a' }); // 深度相等,通过
// 异步断言必须 await
await expect(api.getUser(1)).resolves.toEqual({ id: 1 });
// toThrow 传函数引用,匹配错误消息
expect(() => JSON.parse('invalid')).toThrow();
expect(() => risky()).toThrow(/permission denied/);
// Mock 验证
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(2);
```服务端5月27日 19:54
如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?测试 React Hooks 的核心工具是 `renderHook` 和 `act`。React 18 之后,`renderHook` 已从废弃的 `@testing-library/react-hooks` 迁移到 `@testing-library/react`,用法也有变化。
## 核心思路
- **renderHook**:在测试环境中渲染 Hook,返回 `result`(当前返回值)、`rerender`(重新渲染)、`unmount`(卸载)
- **act**:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言
- **waitFor**:处理异步状态更新,替代旧版的 `waitForNextUpdate`
## 安装依赖
```bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
```
> 注意:`@testing-library/react-hooks` 已废弃,React 18+ 请统一使用 `@testing-library/react`。
## 测试 useState
```javascript
import { renderHook, act } from '@testing-library/react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
test('useCounter 初始值和更新', () => {
const { result } = renderHook(() => useCounter(0));
// 验证初始状态
expect(result.current.count).toBe(0);
// 用 act 包裹状态更新
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
```
**关键点**:任何触发 `setState` 的调用都必须包裹在 `act()` 中,否则 React 会发出警告,断言也可能基于未更新的状态。
## 测试 useEffect
```javascript
import { renderHook, act } from '@testing-library/react';
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
return () => { document.title = 'default'; };
}, [title]);
}
test('useEffect 设置和清理', () => {
const { result, unmount, rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'Hello' } }
);
expect(document.title).toBe('Hello');
// 依赖变化时 effect 重新执行
rerender({ title: 'World' });
expect(document.title).toBe('World');
// 卸载时执行清理函数
unmount();
expect(document.title).toBe('default');
});
```
**关键点**:用 `rerender` 测试依赖变化,用 `unmount` 测试清理逻辑。
## 测试 useContext
```javascript
import { renderHook } from '@testing-library/react';
const ThemeContext = createContext('light');
function useTheme() {
return useContext(ThemeContext);
}
test('useContext 读取 Provider 值', () => {
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');
});
```
**关键点**:Hook 依赖 Context 时,通过 `wrapper` 选项注入 Provider,`renderHook` 会自动用 wrapper 包裹组件树。
## 测试异步 Hook
```javascript
import { renderHook, waitFor, act } from '@testing-library/react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(json => { if (!cancelled) { setData(json); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
test('useFetch 异步请求', async () => {
// 用 jest.fn mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ name: 'test' }) })
);
const { result } = renderHook(() => useFetch('/api/data'));
// 初始状态
expect(result.current.loading).toBe(true);
// 等待异步完成
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'test' });
expect(result.current.error).toBeNull();
});
```
**关键点**:
- 用 `waitFor` 等待异步更新,不要在 `act` 里 `await waitFor`(那是反模式)
- 异步 Hook 需要处理竞态:组件卸载后不应再 `setState`,用 `cancelled` 标志位或 `AbortController`
## 测试自定义 Hook
```javascript
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
test('useDebounce 防抖', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'hello' } }
);
expect(result.current).toBe('hello');
// 快速更新值,防抖未到期
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // 还是旧值
// 快进 500ms
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('world');
jest.useRealTimers();
});
```
**关键点**:涉及定时器的 Hook,用 `jest.useFakeTimers()` + `act(() => jest.advanceTimersByTime(ms))` 精确控制时间。
## 常见报错排查
### "not wrapped in act()" 警告
**原因**:状态更新发生在 `act()` 之外(如异步回调、定时器未用 fake timers)。
**解决**:
- 异步操作用 `waitFor` 或 `await act(async () => ...)`
- 定时器用 `jest.useFakeTimers()` 并在 `act` 中推进时间
- 确保所有 `setState` 调用都在 `act` 内
### "Can't perform a React state update on an unmounted component"
**原因**:异步操作完成后组件已卸载,仍然调用了 `setState`。
**解决**:在 `useEffect` 清理函数中取消异步操作(`cancelled` 标志位 / `AbortController`)。
## 最佳实践
1. **用 `@testing-library/react` 的 `renderHook`**,不要再用废弃的 `@testing-library/react-hooks`
2. **所有状态更新包裹 `act`**,同步用 `act(fn)`,异步用 `await act(async fn)` 或 `waitFor`
3. **测试行为不测实现**:关注 Hook 的输入输出,不关注内部状态变量名
4. **测试边界**:初始值、空值、错误状态、并发场景
5. **用 `rerender` 测试依赖变化**,用 `unmount` 测试清理逻辑
6. **Mock 外部依赖**(API、定时器、DOM API),不 Mock React 内置 Hook服务端5月27日 19:52
Jest 怎么测试 setTimeout 和 setInterval?fake timers 怎么用?Jest 用 `jest.useFakeTimers()` 把 `setTimeout`、`setInterval` 替换成模拟实现,然后通过 `jest.runAllTimers()`、`jest.advanceTimersByTime()` 等方法手动推进时间,不用真等。
核心流程就三步:开启假定时器 → 写业务代码 → 手动推进时间并断言。
```javascript
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
```
`runAllTimers` 会一口气跑完所有待执行的定时器,包括嵌套的。如果你的代码里定时器会不断递归注册自己(比如轮询),用 `runAllTimers` 会死循环——这种情况用 `runOnlyPendingTimers` 只跑当前这轮。
`advanceTimersByTime(ms)` 更精确,只推进指定毫秒数,适合测"3 秒后应该执行了 3 次"这类场景:
```javascript
const cb = jest.fn();
setInterval(cb, 1000);
jest.advanceTimersByTime(3000);
expect(cb).toHaveBeenCalledTimes(3);
```
每个测试用例结束记得恢复真实定时器:`jest.useRealTimers()`,不然会影响后续测试。推荐放 `afterEach` 里统一清理。
## 追问
### useFakeTimers 和手动 mock setTimeout 有什么区别?
`useFakeTimers` 是 Jest 内置的,会替换全局的 `setTimeout`/`setInterval`/`clearTimeout`/`clearInterval`/`setImmediate` 等,提供 `runAllTimers`、`advanceTimersByTime` 等控制 API。手动 mock 只替换你 spyOn 的那一个函数,控制力更弱,需要自己模拟时间推进。
### fake timers 和 Promise 混用时有什么坑?
这是最常见的坑:`jest.useFakeTimers()` 默认也会 fake 掉 `process.nextTick` 和微任务队列,导致 `Promise.resolve().then(...)` 里的回调不执行。Jest 27+ 可以用 `jest.useFakeTimers({ doNotFake: ['nextTick'] })` 排除 nextTick,或者手动 `await new Promise(process.nextTick)` 让微任务跑完再推进时间。
### jest.advanceTimersByTime 和 jest.runTimersToTime 有什么区别?
`runTimersToTime` 是旧 API(Jest 22 及之前),行为和 `advanceTimersByTime` 基本一致但语义模糊。Jest 23+ 推荐用 `advanceTimersByTime`,旧 API 仅为向后兼容保留。
### 实际项目里测定时器最容易犯什么错?
忘记在 beforeEach 里开启 fake timers,导致前一个测试的真实定时器泄漏到下一个测试;或者用 `runAllTimers` 跑有递归定时器的代码导致栈溢出。另一个常见问题是 `afterEach` 里只调了 `useRealTimers` 但没调 `clearAllTimers`,残留的定时器可能干扰后续用例。服务端5月27日 19:51
Jest 中有哪些测试匹配器(Matchers)?如何使用自定义匹配器?## 为什么匹配器是 Jest 测试的核心
写测试本质上就是做断言——拿实际结果和期望结果比对。匹配器(Matchers)就是 Jest 提供的断言语言,决定了你能用多自然、多精确的方式表达"我期望这段代码的行为是什么"。
如果你只会 `toBe` 和 `toEqual`,很多场景要么写不出断言,要么写得很别扭。掌握完整的匹配器体系,加上自定义匹配器的能力,才能写出既清晰又健壮的测试。
---
## 相等性匹配器:判断值是否如你所料
最基础也是用得最多的一组:
- `toBe(value)` — 严格相等,即 `===`。适合原始类型(number、string、boolean)和 `null`/`undefined` 的比较。**注意**:对象比较的是引用,不是内容。
```javascript
expect(1 + 1).toBe(2);
expect(null).toBe(null);
```
- `toEqual(value)` — 深度递归比较。对象和数组逐字段比对,是测试复杂数据结构的首选。
```javascript
expect({ name: 'Jest', version: 29 }).toEqual({ name: 'Jest', version: 29 });
// 通过:内容一致即可,不要求同一引用
```
- `toStrictEqual(value)` — 比 `toEqual` 更严格:`undefined` 属性、稀疏数组空位、`Date` 实例等都会纳入比较。当你需要确保数据结构完全一致、没有多余属性时使用。
```javascript
expect({ a: undefined, b: 1 }).not.toStrictEqual({ b: 1 });
// toEqual 会认为两者相同,toStrictEqual 不会
```
- `toMatchObject(object)` — 部分匹配,只检查给定的属性是否存在且值相等,忽略对象中的其他属性。适合只关心几个关键字段的场景。
```javascript
const user = { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' };
expect(user).toMatchObject({ name: 'Alice', role: 'admin' });
// 只验证这两个字段,其余忽略
```
---
## 真值匹配器:处理 null、undefined 和真假值
JavaScript 的真假值规则经常让人踩坑,Jest 专门提供了一组匹配器:
| 匹配器 | 通过条件 | 典型用途 |
|--------|---------|---------|
| `toBeNull()` | 仅 `null` | 区分 null 和 undefined |
| `toBeUndefined()` | 仅 `undefined` | 检测未赋值变量 |
| `toBeDefined()` | 非 `undefined` | 确认变量已定义 |
| `toBeTruthy()` | 真值(`!!value === true`) | 检查非空字符串、非零数字等 |
| `toBeFalsy()` | 假值(`0`、`''`、`null`、`undefined`、`false`) | 检查空值或无数据状态 |
```javascript
// 常见场景:函数返回 null 表示未找到
expect(findUser(-1)).toBeNull();
// 常见场景:检查可选配置项是否存在
expect(config.timeout).toBeDefined();
// 常见场景:检查有内容(非空字符串、非零数字)
expect(response.body).toBeTruthy();
```
一个常见的坑:`toBeTruthy()` 对 `0` 和空字符串返回 false。如果你确实需要区分 `0` 和 `undefined`,别用 `toBeTruthy`,用 `toBeDefined`。
---
## 数字匹配器:比较大小和精度
- `toBeGreaterThan(n)` / `toBeGreaterThanOrEqual(n)` — 大于 / 大于等于
- `toBeLessThan(n)` / `toBeLessThanOrEqual(n)` — 小于 / 小于等于
- `toBeCloseTo(n, precision)` — 浮点数近似比较,避免精度问题
```javascript
expect(0.1 + 0.2).not.toBe(0.3); // JavaScript 浮点精度问题
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 正确做法:指定精度比较
```
`toBeCloseTo` 是处理浮点运算的必备匹配器,第二个参数是小数点后的精度位数,默认是 2。如果测试中涉及金额计算或科学计算,务必用它替代 `toBe`。
---
## 字符串匹配器
- `toMatch(regexp | string)` — 匹配正则或包含子串
- `toContain(item)` — 包含子字符串
```javascript
expect('Hello, Jest!').toMatch(/jest/i);
expect('error: file not found').toContain('error');
```
`toMatch` 支持正则,比 `toContain` 更灵活。需要模式匹配时用 `toMatch`,只需判断是否包含子串时用 `toContain`。
---
## 数组匹配器
- `toContain(item)` — 数组中是否包含某元素(用 `===` 比较)
- `toContainEqual(item)` — 数组中是否包含深度相等的元素
- `toHaveLength(n)` — 数组/字符串长度
```javascript
const users = [{ id: 1 }, { id: 2 }];
expect(users).toContainEqual({ id: 1 }); // 深度比较,通过
expect(users).not.toContain({ id: 1 }); // 引用比较,不通过
expect(users).toHaveLength(2);
```
`toContain` 对对象用的是引用比较,如果数组里存的是对象字面量,一定要用 `toContainEqual`,否则断言会失败。
---
## 对象匹配器
- `toHaveProperty(keyPath, value?)` — 检查对象是否有指定属性路径,可选检查值
- `toMatchObject(object)` — 部分匹配(上文已介绍)
```javascript
const config = { db: { host: 'localhost', port: 5432 } };
expect(config).toHaveProperty('db.port', 5432); // 支持点号路径
expect(config).toHaveProperty(['db', 'host']); // 也支持数组路径
```
`toHaveProperty` 的 `keyPath` 参数支持点号分隔的字符串或字符串数组,可以深层数据校验。
---
## 函数匹配器:验证函数调用行为
这组匹配器配合 `jest.fn()` 或 `jest.spyOn()` 使用,是 Mock 测试的核心工具:
- `toHaveBeenCalled()` — 函数被调用过
- `toHaveBeenCalledWith(...args)` — 用特定参数调用过
- `toHaveBeenCalledTimes(n)` — 调用了 n 次
- `toHaveLastReturnedWith(value)` — 最后一次返回值
- `toHaveNthReturnedWith(n, value)` — 第 n 次返回值
- `toHaveReturned()` — 成功返回过(没抛错)
- `toHaveReturnedWith(value)` — 返回过指定值
```javascript
const onClick = jest.fn();
button.click();
button.click();
expect(onClick).toHaveBeenCalledTimes(2);
expect(onClick).toHaveBeenCalledWith(); // 无参数调用
// 带参数的场景
const save = jest.fn();
save({ name: 'Alice' });
expect(save).toHaveBeenCalledWith({ name: 'Alice' });
```
一个实用技巧:`toHaveBeenCalledWith` 只检查某一次调用是否匹配,不要求所有调用都匹配。如果需要验证所有调用的参数序列,可以用 `expect(fn.mock.calls).toEqual([[arg1], [arg2]])`。
---
## 异常匹配器:测试错误抛出
- `toThrow(error?)` — 函数抛出错误,可匹配错误消息或类型
- `toThrowErrorMatchingSnapshot()` — 错误消息快照
```javascript
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
expect(() => divide(1, 0)).toThrow('Division by zero');
expect(() => divide(1, 0)).toThrow(/zero/);
expect(() => divide(1, 0)).toThrow(Error);
```
关键点:`toThrow` 的参数必须是包裹在函数中的(`expect(() => fn())` 而不是 `expect(fn())`),否则错误会在 `expect` 执行前直接抛出,测试框架捕获不到。
---
## 否定匹配器:用 `.not` 取反
所有匹配器都可以通过 `.not` 前缀取反:
```javascript
expect(value).not.toBe(42);
expect(array).not.toContain('deprecated');
expect(fn).not.toHaveBeenCalled();
```
`.not` 链式调用让断言的语义更自然。当 `not` 加上语义明确的匹配器仍不够用时,就是自定义匹配器登场的时候了。
---
## 快照匹配器:捕获和比对输出
- `toMatchSnapshot(propertyMatchers?, hint?)` — 与存储的快照比对
- `toThrowErrorMatchingSnapshot()` — 异常消息快照
```javascript
expect(component.render()).toMatchSnapshot();
// 首次运行会生成快照文件,后续运行自动比对
// 输出变化时测试失败,需用 --updateSnapshot 更新
```
快照适合测试稳定的序列化输出(如组件渲染结果、配置对象)。不适合频繁变化的数据,否则快照文件会不断需要更新,失去测试价值。
---
## 异步匹配器:处理 Promise
- `resolves` — 期望 Promise 成功 resolve
- `rejects` — 期望 Promise 被 reject
```javascript
// 测试异步函数成功返回
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
// 测试异步函数抛错
await expect(fetchUser(-1)).rejects.toThrow('User not found');
```
使用 `resolves` / `rejects` 时必须加 `await`,否则 Jest 无法正确捕获异步结果,测试会提前结束并始终通过。
---
## 自定义匹配器:让断言更贴合业务语义
当内置匹配器无法精确表达你的断言意图时,`expect.extend()` 允许你创建自己的匹配器。
### 基本结构
自定义匹配器接收 `received`(`expect()` 传入的值)和自定义参数,返回一个包含 `pass` 和 `message` 的对象:
```javascript
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `Expected ${received} NOT to be within range ${floor}–${ceiling}`
: `Expected ${received} to be within range ${floor}–${ceiling}`,
};
},
});
test('score is within passing range', () => {
expect(85).toBeWithinRange(60, 100);
expect(30).not.toBeWithinRange(60, 100);
});
```
`message` 函数要同时处理通过和不通过两种场景。`pass` 为 `true` 时,message 描述的是 `.not` 取反后的预期(因为 `.not` 让通过的变成失败),反之亦然。
### 在 TypeScript 项目中使用
自定义匹配器需要扩展 `jest.Matchers` 接口,否则 TypeScript 会报类型错误:
```typescript
// 在 jest.d.ts 或 global.d.ts 中声明
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
}
}
}
```
### 实际案例:验证日期范围
```javascript
expect.extend({
toBeDateAfter(received, baseline) {
const pass = received instanceof Date && baseline instanceof Date && received > baseline;
return {
pass,
message: () =>
pass
? `Expected ${received.toISOString()} NOT to be after ${baseline.toISOString()}`
: `Expected ${received.toISOString()} to be after ${baseline.toISOString()}`,
};
},
});
test('expiry date is after creation date', () => {
const created = new Date('2025-01-01');
const expires = new Date('2026-01-01');
expect(expires).toBeDateAfter(created);
});
```
### 自定义匹配器的最佳实践
1. **命名要语义化**:`toBeValidEmail` 比 `toMatchEmailRegex` 更易读,测试代码读起来像自然语言。
2. **输入校验不能省**:对 `received` 做类型检查,遇到非法输入抛出有意义的错误,而不是返回莫名其妙的 `pass: false`。
3. **配合 `setupFilesAfterEnv` 全局注册**:把 `expect.extend()` 放在独立的 setup 文件中,在 Jest 配置的 `setupFilesAfterEnv` 里引入,避免每个测试文件重复注册。
4. **优先组合内置匹配器**:如果只是 `expect(a).toBeGreaterThan(x)` 和 `expect(a).toBeLessThan(y)` 的组合,直接用 `.and` 或写两行断言就够了,不必自定义。自定义匹配器的价值在于表达内置匹配器无法简洁描述的业务规则。
---
## 选择匹配器的思路
遇到断言需求时,按这个顺序选择:
1. **值比较** — `toBe` / `toEqual` / `toStrictEqual`
2. **类型或存在性** — `toBeDefined` / `toBeNull` / `toBeTruthy`
3. **大小或范围** — `toBeGreaterThan` / `toBeCloseTo`
4. **包含关系** — `toContain` / `toContainEqual` / `toMatchObject`
5. **函数行为** — `toHaveBeenCalledWith` / `toThrow`
6. **异步结果** — `resolves` / `rejects`
7. **内置都不合适** — `expect.extend()` 自定义
匹配器选对了,测试的可读性和维护性都会上一个台阶。不必死记硬背所有匹配器,理解每个类别的适用场景,需要时查阅即可。自定义匹配器则是把反复出现的断言模式封装成可复用工具,在项目规模变大时尤其值得投入。服务端5月27日 19:51
什么是 Jest 快照测试?如何使用快照测试来验证组件输出?Jest 快照测试(Snapshot Testing)是前端测试中一种高效的质量保障手段,它通过"拍照对比"的方式确保组件输出和数据结构不会发生意外变化。本文将从原理、用法、进阶技巧到常见踩坑,全面讲解快照测试的实践方法。
## 快照测试的工作原理
快照测试的核心思路是"第一次运行时记录预期输出,后续运行时与预期比对":
1. **首次运行**:Jest 将组件的渲染输出序列化为字符串,保存到 `__snapshots__/` 目录下的 `.snap` 文件中
2. **后续运行**:重新渲染组件,将输出与已保存的快照进行逐行对比
3. **差异处理**:如果输出与快照不一致,测试失败并在终端展示 diff 信息;开发者确认变更合理后,可更新快照
与传统的断言式测试相比,快照测试无需手写每个期望值,尤其适合 UI 组件这种结构复杂的输出对象。
## 基本用法:React 组件快照
使用 `react-test-renderer` 创建组件的渲染树,再调用 `toMatchSnapshot()` 生成快照:
```javascript
import renderer from 'react-test-renderer';
import UserProfile from './UserProfile';
test('UserProfile renders correctly', () => {
// 创建组件的渲染树
const tree = renderer
.create(<UserProfile name="Alice" role="admin" />)
.toJSON();
// 首次运行:生成快照文件;后续运行:与快照比对
expect(tree).toMatchSnapshot();
});
```
首次运行后,Jest 会在 `__snapshots__/UserProfile.test.js.snap` 中生成类似以下的快照:
```javascript
exports[`UserProfile renders correctly 1`] = `
<div
className="user-profile"
>
<h2>
Alice
</h2>
<span
className="role"
>
admin
</span>
</div>
`;
```
如果后续修改了组件结构,快照测试会立即捕获变化并报告差异。
## 使用 React Testing Library 进行快照
在现代 React 项目中,更推荐使用 `@testing-library/react` 结合快照测试:
```javascript
import { render } from '@testing-library/react';
import NavMenu from './NavMenu';
test('NavMenu snapshot', () => {
const { container } = render(<NavMenu items={['Home', 'About', 'Contact']} />);
expect(container.firstChild).toMatchSnapshot();
});
```
这种方式更贴近用户的真实交互方式,渲染结果也更接近浏览器中的实际 DOM。
## 内联快照:toMatchInlineSnapshot
`toMatchInlineSnapshot()` 将快照内容直接写在测试文件中,而不是单独的 `.snap` 文件,适合输出较短的场景:
```javascript
test('formatUserInfo returns correct structure', () => {
const result = formatUserInfo({ name: 'Bob', age: 28 });
expect(result).toMatchInlineSnapshot(`
{
"age": 28,
"displayName": "Bob",
"isActive": true
}
`);
});
```
内联快照的优势在于:快照与测试代码在同一文件中,code review 时更直观;不会产生额外的快照文件。但输出较长时不建议使用,会让测试文件变得臃肿。
## 属性匹配器:处理动态数据
当快照中包含动态生成的值(时间戳、UUID、随机数)时,每次运行快照都会不同,导致测试误报。使用属性匹配器可以优雅地解决这个问题:
```javascript
test('user creation response matches expected structure', () => {
const response = createUser({
name: 'Charlie',
email: 'charlie@example.com'
});
expect(response).toMatchSnapshot({
id: expect.any(String), // id 是动态生成的,只验证类型
createdAt: expect.any(Date), // 时间戳也是动态的
token: expect.any(String), // JWT token 每次不同
});
// 其余字段会进行精确匹配
});
```
快照文件中对应字段会记录为 `Any<String>`、`Any<Date>`,后续运行只校验类型而不校验具体值。
## 自定义序列化器
当组件中包含无法直接序列化的对象(如 CSS-in-JS 的样式对象、Moment.js 日期对象)时,可以编写自定义序列化器:
```javascript
// customSerializer.js
const styleSerializer = {
// 判断是否需要自定义序列化
test: (val) => val && val.$$typeof === Symbol.for('react.element'),
// 自定义序列化逻辑
print: (val, serialize) => {
// 移除动态生成的 className,避免快照频繁变化
const props = { ...val.props };
delete props.className;
return serialize({ ...val, props });
},
};
// 在 jest.config.js 中配置
module.exports = {
snapshotSerializers: ['./customSerializer.js'],
};
```
## 快照更新的正确姿势
当有意修改组件导致快照测试失败时,需要更新快照:
```bash
# 交互式更新(推荐):逐个确认是否更新
jest --updateSnapshot
# 简写
jest -u
# 只更新匹配特定测试名的快照
jest -u --testNamePattern="UserProfile"
# CI 环境中禁止意外更新
jest --ci
```
**重要提醒**:在 CI/CD 流水线中务必使用 `--ci` 标志,防止快照被意外更新而掩盖真正的 bug。
## Vue 组件的快照测试
Vue 项目中使用 `@vue/test-utils` 进行快照测试:
```javascript
import { mount } from '@vue/test-utils';
import TodoItem from './TodoItem.vue';
test('TodoItem snapshot', () => {
const wrapper = mount(TodoItem, {
props: {
title: 'Learn Jest',
completed: false
}
});
expect(wrapper.html()).toMatchSnapshot();
});
```
Vue 的快照通常基于渲染后的 HTML 字符串,比 React 的虚拟 DOM 树更加可读。
## 常见踩坑与解决方案
### 1. 快照文件体积膨胀
大组件的快照可能长达数百行,diff 审查成本高。
**解决方案**:将大组件拆分为子组件分别测试;使用 `toMatchSnapshot({ mode: 'deep' })` 控制序列化深度。
### 2. 快照测试频繁误报
包含动态数据的组件每次渲染输出不同,快照测试反复失败。
**解决方案**:使用属性匹配器(Property Matchers)忽略动态字段;使用自定义序列化器过滤不稳定属性。
### 3. 快照更新沦为"无脑确认"
开发者遇到快照失败时不审查 diff,直接 `jest -u` 更新,导致快照测试失去意义。
**解决方案**:在 CI 中强制使用 `--ci` 标志;团队 code review 时要求检查快照变更;定期清理过时快照(`jest --listTests` 配合 `--findRelatedTests`)。
### 4. 快照测试运行缓慢
组件依赖过多,渲染链路长导致快照测试耗时。
**解决方案**:使用 `shallow` 渲染(浅渲染)代替 `mount`(全渲染),只渲染当前组件而不渲染子组件。
## 快照测试的适用场景与局限
| 适用场景 | 不适用场景 |
|---------|-----------|
| UI 组件结构回归测试 | 需要验证交互行为(点击、输入) |
| API 响应数据结构验证 | 需要验证计算逻辑正确性 |
| 配置文件结构检查 | 频繁变化的动态内容 |
| 序列化/格式化函数输出验证 | 需要精确数值断言的场景 |
快照测试是回归测试的好帮手,但不能替代行为测试和单元测试。推荐将快照测试与 `fireEvent`、`waitFor` 等交互测试结合使用,形成完整的测试覆盖。
## 总结
- 快照测试通过"首次记录、后续比对"的方式高效检测 UI 和数据结构的意外变化
- 使用 `toMatchSnapshot()` 生成外部快照,`toMatchInlineSnapshot()` 生成内联快照
- 属性匹配器解决动态数据问题,自定义序列化器处理特殊对象
- CI 中务必使用 `--ci` 标志,团队 review 流程中必须审查快照变更
- 快照测试适合结构回归,不适合验证交互行为和计算逻辑