标签

Jest

Jest 是一个流行的 JavaScript 测试框架,用于编写和运行测试。它由 Facebook 开发,并被应用于测试 React 组件以及其他类型的 JavaScript 代码。Jest 被设计为零配置,易于上手,同时提供了丰富的特性,如快照测试、内置的覆盖率报告和模拟系统。

Jest
查看更多相关内容
服务端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 约束,测试文件会比单纯追求“格式统一”更好维护。代码评审时也可以顺手看测试标题:如果只读标题看不懂行为,后面的人排查失败时也一样看不懂。
服务端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 流程中必须审查快照变更 - 快照测试适合结构回归,不适合验证交互行为和计算逻辑