面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月31日 15:55

Jest 中 describe、test 和 it 该怎么分工?

describe 负责分组,test 和 it 负责定义具体用例;test 与 it 在 Jest 里是同一个能力,只是表达风格不同。写测试时不要把 describe 当成必填包装,也不要为了层级好看一层套一层。好的组织方式是让读者从外层看到模块或场景,从内层看到行为,再从断言里确认结果。简单说,describe 管上下文,test/it 管一个可以独立失败的事实。很多测试文件难维护,不是因为 Jest API 难,而是命名和分组把问题藏起来了。比如 describe('utils') 下面放几十个用例,失败时只能知道 utils 坏了,却不知道是格式化、校验还是边界值坏了。反过来,如果每个分支都新开一层 describe,文件会像迷宫,读者要上下跳着看 beforeEach。好的组织方式应该让失败标题连起来就是一句清楚的话。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 或过期时间,否则它们很快没人敢动。写段配置// eslint.config.jsmodule.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 约束,测试文件会比单纯追求“格式统一”更好维护。代码评审时也可以顺手看测试标题:如果只读标题看不懂行为,后面的人排查失败时也一样看不懂。
服务端阅读 05月31日 15:55

Jest 测试跑得太慢时该从哪些地方优化?

Jest 测试变慢时,先不要急着把所有用例都改成 mock。更稳的做法是先量出慢在哪里,再从运行范围、测试环境、并发、转换缓存和外部依赖几个点逐个处理。通常收益最大的是三件事:只跑相关测试、把不需要 DOM 的用例放到 node 环境、把网络和计时器这类不稳定依赖隔离掉。CI 上还要控制 worker 数量,因为机器核数看起来很多,不代表同时跑满就最快,I/O、转译和内存都会抢资源。优化前最好先固定基线:记录完整测试耗时、最慢的测试文件、是否开启 coverage、是否每次都重新转译。很多团队感觉“Jest 越来越慢”,实际是新增了 jsdom 用例、覆盖率范围过大、mock 泄漏导致重试,或者 CI 容器内存不足。把这些因素拆开之后,优化才不会变成凭感觉调参数。// jest.config.jsmodule.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 最好结合依赖图或至少在合并前跑一次全量。写段代码{ "scripts": { "test": "jest --watch", "test:changed": "jest --onlyChanged", "test:ci": "jest --ci --coverage --maxWorkers=50%", "test:debug": "jest --runInBand --detectOpenHandles" }}Jest 性能优化的关键不是把命令堆满,而是给不同场景配不同命令。本地要快,CI 要稳,排查要可复现。只要把环境、并发、覆盖率和 mock 边界分清,大多数“测试越来越慢”的问题都能被压回可控范围。真正需要重写测试时,也应该先从最慢、最不稳定、最依赖外部资源的文件开始,而不是把整个测试目录推倒重来。
服务端阅读 05月31日 11:08

Jest 如何测试异常处理并正确使用 toThrow 和 rejects?

异常测试不是为了证明代码“会报错”,而是确认它在错误输入、依赖失败和边界条件下报出正确的错,并且调用方能按预期处理。Jest 里同步异常主要用 toThrow,Promise 拒绝主要用 rejects,回调错误则要显式等待测试结束。最常见的误区是把函数先执行了,再把结果交给 expect,这样异常会在断言前就抛出。同步异常怎么测?toThrow 接收的是一个函数包装,而不是函数调用结果。可以匹配错误类型、完整消息、部分字符串或正则。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 拒绝前就结束,形成假通过。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,避免没有抛错时测试仍然通过。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,并在测试后恢复。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 分支读取字段时再次报错。
服务端阅读 05月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。npm i -D jest ts-jest @types/jest typescriptmodule.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 可能被编辑器标红。{ "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 和返回值也遵守业务类型。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 的类型丢失。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,否则会让测试意图变模糊。
服务端阅读 05月31日 11:08

Jest 如何测试 fs 文件系统和 I/O 操作?

测试文件系统代码时,最重要的问题不是“能不能读写文件”,而是“你的业务逻辑在文件存在、缺失、权限不足、内容损坏时是否处理正确”。Jest 可以 mock fs,也可以配合临时目录做接近真实的集成测试。两种方式都该会用,因为纯 Mock 快但容易脱离真实行为,真实 I/O 准但慢且需要清理。什么时候 Mock fs?如果函数只是包装读取、解析和错误处理,mock fs 很合适。它能让测试不依赖本机路径,也不会污染项目目录。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 表达成功和失败。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 格式错误、磁盘写入中断和目录不存在。什么时候用真实临时文件?涉及路径拼接、目录创建、文件遍历、编码、换行符或原子写入时,最好用临时目录跑一层集成测试。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,即使断言失败也能删除文件。边界是调试失败时你可能想保留文件,可以通过环境变量控制是否清理。不要在测试里写固定路径,尤其不要写用户主目录或仓库根目录。
服务端阅读 05月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 里最适合单元测试的部分。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 更值得测,因为它经常藏着筛选、排序、权限判断和空值兼容。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 的影响,少测内部实现。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 才是合理取舍。
服务端阅读 05月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,这是最容易踩的版本坑。npm i -D jest @vue/test-utils @vue/vue3-jest babel-jest jest-environment-jsdommodule.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 行为不变,测试仍然有效。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。
前端阅读 05月28日 07:00

如何在 Jest 中测试 React 组件?常用的测试工具和查询方法有哪些?

在 Jest 中测试 React 组件,核心思路是:渲染组件 → 查询元素 → 断言行为。React 官方推荐的测试方案是 Jest + React Testing Library(RTL),本文聚焦面试中高频考察的知识点。React 组件测试的基本流程是什么?测试 React 组件通常分三步:渲染:使用 RTL 的 render 方法将组件挂载到虚拟 DOM查询:通过 screen 对象提供的方法定位页面元素断言:使用 Jest 的 expect 验证元素状态或行为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 | 异步元素出现 |具体查询方法的推荐优先级:getByRole — 最优先,基于 ARIA 角色,如 button、textbox、headinggetByLabelText — 表单元素优先用,关联 label 文本getByPlaceholderText — 没有 label 时使用getByText — 非表单元素(按钮、链接、段落)常用getByTestId — 最后手段,需要手动添加 data-testid 属性// 推荐:通过角色查询screen.getByRole('button', { name: /submit/i });// 不推荐但有时必要:通过 testId 查询screen.getByTestId('submit-btn');面试关键点:优先使用 getByRole 是因为它验证了组件的可访问性,这与 RTL "测试用户视角" 的核心理念一致。如何测试用户交互?使用 fireEvent 或 userEvent 模拟用户操作。userEvent 更接近真实用户行为,推荐优先使用。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* 处理。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 函数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 模块// 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 进行测试: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 包。快照测试怎么用?什么场景下用?import renderer from 'react-test-renderer';test('Button matches snapshot', () => { const tree = renderer.create(<Button>Click</Button>).toJSON(); expect(tree).toMatchSnapshot();});快照测试的适用与不适用:适合:配置型组件(Theme、Layout),结构稳定的纯展示组件不适合:频繁变动的业务组件,否则每次改动都要更新快照,失去测试价值面试加分点:快照测试只是确认结构没变,并不验证行为是否正确,所以不能替代行为测试。测试 React 组件有哪些最佳实践?测试行为,不测实现 — 不测内部 state 的值,测用户看到的结果避免过度 Mock — Mock 越多,测试离真实场景越远查询方法按优先级选 — getByRole > getByLabelText > getByText > getByTestId异步用 findBy 优于 waitFor + getBy — 更简洁,语义更清晰使用 screen 而非 render 返回值 — 避免反复解构,代码更干净一个测试只验证一个行为 — 方便定位失败原因面试追问方向:如何测试 Context Provider 包裹的组件?如何处理第三方库的渲染行为?如何在 CI 中提升测试执行速度?这些是区分中级与高级的关键问题。
前端阅读 05月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(),适合需要完全控制模块行为的场景: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() 恢复原实现: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() 替换全局 fetchfetch 是全局对象上的方法,直接赋值即可替换: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() 监听全局 fetchtest('fetchPosts 处理响应数据', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve([{ id: 1 }]), }); const posts = await fetchPosts(); expect(posts).toEqual([{ id: 1 }]);});测试错误场景只测成功路径是不够的,面试官一定会问"网络请求失败了怎么办":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)在网络层拦截请求,不需要修改业务代码: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 连续返回失败,最后一次返回成功,模拟重试后恢复。
服务端阅读 05月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,这样改了依赖包的代码测试立即生效。写段代码// 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 }, },};
服务端阅读 05月28日 04:25

Jest 中 test.skip 和 test.only 有什么区别?

Jest 用 .skip 排除测试,用 .only 聚焦测试——两种思路,作用对象都可以是单个 test 或整个 describe。跳过(skip):标记的测试不执行,但会在报告中显示为 skipped。test.skip('暂时不跑', () => { ... }); // 等价于 xtest / xitdescribe.skip('整组跳过', () => { ... }); // 等价于 xdescribe聚焦(only):只执行标记的测试/套件,其余全部跳过。test.only('只跑这个', () => { ... }); // 等价于 fit / it.onlydescribe.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 还原成普通函数,确保全量执行。条件性跳过怎么写?const skipInCI = process.env.CI ? test.skip : test;skipInCI('本地才跑的测试', () => { ... });或用 Jest 28+ 的 describe.skipIf / test.skipIf: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 数量纳入代码健康指标。
服务端阅读 05月28日 04:23

Jest 如何测试异步代码?4 种方式与常见坑

Jest 测试异步代码有四种方式,按推荐优先级排列:async/await、resolves/rejects 匹配器、返回 Promise、done 回调。核心原则只有一个——让 Jest 知道测试什么时候算完。最常用的是 async/await,直接在 test 函数加 async,用 await 等待异步结果:test('fetches user', async () => { const user = await getUser(1); expect(user.name).toBe('Alice');});如果你不需要对结果做复杂断言,.resolves / .rejects 更简洁: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 参数: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 而不是直接抛错。正确写法:await expect(getUser(-1)).rejects.toThrow('invalid id');或者用 try-catch 配合 expect.assertions(1) 确保断言真的被执行了。定时器相关的异步怎么测?用 jest.useFakeTimers() 把定时器替换成模拟的,然后手动推进时间,不用真等: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 时才用。定时器模拟主要出现在防抖、轮询、超时重试这类逻辑里。
服务端阅读 05月28日 04:22

Jest 测试怎么运行和调试?常用命令有哪些?

核心命令一览运行测试最常用的几个命令:# 运行所有测试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 在修复阶段很实用——测试多的时候不用每次全量跑一遍。运行测试的常见场景按文件或路径筛选# 跑某个目录下的所有测试npx jest src/utils/# 用正则匹配文件名npx jest --testPathPattern="auth"--testPathPattern 接收正则表达式,比手动拼路径灵活得多。比如项目里测试文件散落在多个目录,用 --testPathPattern="user" 就能一次跑完所有用户相关的测试。按用例名称筛选# 缩写形式npx jest -t "login"# 完整写法npx jest --testNamePattern="should handle error"-t 是 --testNamePattern 的缩写,匹配的是 describe 或 test 块的名字。注意它是正则匹配,写 "add" 会同时命中 "should add" 和 "should handle addError"。在 CI 环境中运行CI 环境和本地开发不同,通常需要关注几个问题:# CI 中推荐的做法npx jest --ci --coverage --forceExit --detectOpenHandles--ci:禁用快照交互提示,避免 CI 卡住--coverage:生成覆盖率报告,配合配置阈值可以在覆盖率不达标时让构建失败--forceExit:测试跑完强制退出进程,防止异步操作(定时器、未关闭的连接)导致进程挂起--detectOpenHandles:检测未关闭的句柄,帮你定位是哪个异步操作阻止了退出调试测试的实用方法用 console.log 快速排查最直接的方式,适合简单问题:test('计算结果验证', () => { const result = calculate(2, 3); console.log('结果:', result); // 快速看输出 expect(result).toBe(5);});注意 console.log 在并行模式下输出顺序可能混乱,调试时建议加 --runInBand。用 --runInBand 单线程运行这是调试的关键选项。Jest 默认用多个 worker 进程并行跑测试,这会导致断点无法命中、日志顺序错乱。--runInBand 让所有测试在同一个进程中顺序执行:npx jest --runInBand什么时候必须加 --runInBand:使用 debugger 断点调试时用 Chrome DevTools Inspector 时测试间有共享状态(虽然不推荐,但遗留项目常见)需要 console.log 输出按顺序排列时用 Node Inspector 调试在代码中加 debugger 语句,然后用 Node 的 Inspector 模式启动 Jest:node --inspect-brk ./node_modules/.bin/jest --runInBand--inspect-brk 会在第一行就暂停,给你时间打开调试工具。然后打开 Chrome,访问 chrome://inspect,点击 "inspect" 就能进入 DevTools 调试界面。用 VSCode 调试在 .vscode/launch.json 中添加配置:{ "type": "node", "request": "launch", "name": "Jest Current File", "program": "${workspaceFolder}/node_modules/.bin/jest", "args": ["${fileBasenameNoExtension}", "--runInBand"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen"}配好之后,打开测试文件直接按 F5 就能断点调试,比每次手敲命令方便很多。用 --verbose 查看详细输出npx jest --verbose--verbose 会让每个测试用例单独列出结果,包括嵌套的 describe 层级。默认输出只显示文件级别的通过/失败,用 --verbose 能快速定位是哪个用例出了问题。常用命令行选项速查| 选项 | 作用 | 使用场景 ||------|------|----------|| --runInBand | 单进程顺序执行 | 调试、需要稳定输出顺序 || --watch | 监听文件变化自动重跑 | 日常开发 || --onlyFailures | 只跑失败的用例 | 修复阶段 || --bail | 遇到失败立即停止 | 快速发现问题 || --coverage | 生成覆盖率报告 | CI 检查、质量把控 || --detectOpenHandles | 检测未关闭的句柄 | 进程挂起时排查 || --forceExit | 强制退出 | CI 环境、异步泄漏 || --verbose | 显示详细用例结果 | 定位具体失败用例 || --no-cache | 禁用缓存 | 怀疑缓存导致问题时 || --ci | CI 模式 | 持续集成环境 |常见问题排查测试跑不过的时候,按这个顺序排查:先加 --verbose 看清楚是哪个用例失败用 --runInBand 单线程重跑,排除并行导致的问题加 --no-cache 排除缓存干扰用 debugger 或 console.log 在失败处打断点如果进程卡住不退出,用 --detectOpenHandles 找到未关闭的资源记住一点:并行模式下测试通过但单线程失败,或者反过来,通常说明测试之间有隐式依赖,需要检查是否共享了状态或 mock 没有正确清理。
服务端阅读 05月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 个语句未覆盖。写段代码// jest.config.jsmodule.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"],};
服务端阅读 05月27日 19:58

如何在 Jest 中进行参数化测试?如何使用 test.each 和 describe.each?

为什么需要参数化测试写测试的时候,经常会遇到同一套逻辑需要用不同数据反复验证的情况。比如一个加法函数,你要测正数、负数、零、边界值,如果每组数据都单独写一个 test,代码会变得冗长且难以维护。参数化测试就是为了解决这个问题——把数据和断言逻辑分离,用一份测试代码覆盖多组输入。Jest 提供了 test.each 和 describe.each 两个 API 来实现参数化测试。前者对单条测试用例做参数化,后者对整组测试做参数化,两者搭配可以显著减少重复代码。test.each 的基本用法test.each 接收一个数组,数组中的每个元素代表一组测试数据,Jest 会为每组数据生成一条独立的测试用例。用二维数组传入参数,这是最直接的写法: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)、%#(测试索引)。用对象数组提高可读性二维数组的参数顺序容易搞混,特别是参数多的时候。用对象数组可以让每组数据的含义一目了然: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 还支持用模板字符串写表格式的参数化数据,可读性更好,特别适合数据量较多的场景: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 块,里面可以包含多条测试: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 一致。参数化测试边界情况和错误处理参数化测试不只是测正常路径,更实用的场景是批量覆盖边界值和异常输入: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);});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,这个容易遗漏: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 跑具体数据,这样测试结构既清晰又紧凑。
服务端阅读 05月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% 的断言场景。写段代码// toBe vs toEqualexpect(1 + 1).toBe(2);expect({ name: 'a' }).not.toBe({ name: 'a' }); // 引用不同,失败expect({ name: 'a' }).toEqual({ name: 'a' }); // 深度相等,通过// 异步断言必须 awaitawait 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);
服务端阅读 05月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安装依赖npm install --save-dev jest @testing-library/react @testing-library/jest-dom 注意:@testing-library/react-hooks 已废弃,React 18+ 请统一使用 @testing-library/react。测试 useStateimport { 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 会发出警告,断言也可能基于未更新的状态。测试 useEffectimport { 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 测试清理逻辑。测试 useContextimport { 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 包裹组件树。测试异步 Hookimport { 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测试自定义 Hookfunction 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)。最佳实践用 @testing-library/react 的 renderHook,不要再用废弃的 @testing-library/react-hooks所有状态更新包裹 act,同步用 act(fn),异步用 await act(async fn) 或 waitFor测试行为不测实现:关注 Hook 的输入输出,不关注内部状态变量名测试边界:初始值、空值、错误状态、并发场景用 rerender 测试依赖变化,用 unmount 测试清理逻辑Mock 外部依赖(API、定时器、DOM API),不 Mock React 内置 Hook
服务端阅读 05月27日 19:52

Jest 怎么测试 setTimeout 和 setInterval?fake timers 怎么用?

Jest 用 jest.useFakeTimers() 把 setTimeout、setInterval 替换成模拟实现,然后通过 jest.runAllTimers()、jest.advanceTimersByTime() 等方法手动推进时间,不用真等。核心流程就三步:开启假定时器 → 写业务代码 → 手动推进时间并断言。jest.useFakeTimers();const callback = jest.fn();setTimeout(callback, 1000);jest.advanceTimersByTime(1000);expect(callback).toHaveBeenCalledTimes(1);runAllTimers 会一口气跑完所有待执行的定时器,包括嵌套的。如果你的代码里定时器会不断递归注册自己(比如轮询),用 runAllTimers 会死循环——这种情况用 runOnlyPendingTimers 只跑当前这轮。advanceTimersByTime(ms) 更精确,只推进指定毫秒数,适合测"3 秒后应该执行了 3 次"这类场景: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,残留的定时器可能干扰后续用例。
服务端阅读 05月27日 19:51

Jest 中有哪些测试匹配器(Matchers)?如何使用自定义匹配器?

为什么匹配器是 Jest 测试的核心写测试本质上就是做断言——拿实际结果和期望结果比对。匹配器(Matchers)就是 Jest 提供的断言语言,决定了你能用多自然、多精确的方式表达"我期望这段代码的行为是什么"。如果你只会 toBe 和 toEqual,很多场景要么写不出断言,要么写得很别扭。掌握完整的匹配器体系,加上自定义匹配器的能力,才能写出既清晰又健壮的测试。相等性匹配器:判断值是否如你所料最基础也是用得最多的一组:toBe(value) — 严格相等,即 ===。适合原始类型(number、string、boolean)和 null/undefined 的比较。注意:对象比较的是引用,不是内容。expect(1 + 1).toBe(2);expect(null).toBe(null);toEqual(value) — 深度递归比较。对象和数组逐字段比对,是测试复杂数据结构的首选。expect({ name: 'Jest', version: 29 }).toEqual({ name: 'Jest', version: 29 });// 通过:内容一致即可,不要求同一引用toStrictEqual(value) — 比 toEqual 更严格:undefined 属性、稀疏数组空位、Date 实例等都会纳入比较。当你需要确保数据结构完全一致、没有多余属性时使用。expect({ a: undefined, b: 1 }).not.toStrictEqual({ b: 1 });// toEqual 会认为两者相同,toStrictEqual 不会toMatchObject(object) — 部分匹配,只检查给定的属性是否存在且值相等,忽略对象中的其他属性。适合只关心几个关键字段的场景。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) | 检查空值或无数据状态 |// 常见场景:函数返回 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) — 浮点数近似比较,避免精度问题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) — 包含子字符串expect('Hello, Jest!').toMatch(/jest/i);expect('error: file not found').toContain('error');toMatch 支持正则,比 toContain 更灵活。需要模式匹配时用 toMatch,只需判断是否包含子串时用 toContain。数组匹配器toContain(item) — 数组中是否包含某元素(用 === 比较)toContainEqual(item) — 数组中是否包含深度相等的元素toHaveLength(n) — 数组/字符串长度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) — 部分匹配(上文已介绍)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) — 返回过指定值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() — 错误消息快照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 前缀取反:expect(value).not.toBe(42);expect(array).not.toContain('deprecated');expect(fn).not.toHaveBeenCalled();.not 链式调用让断言的语义更自然。当 not 加上语义明确的匹配器仍不够用时,就是自定义匹配器登场的时候了。快照匹配器:捕获和比对输出toMatchSnapshot(propertyMatchers?, hint?) — 与存储的快照比对toThrowErrorMatchingSnapshot() — 异常消息快照expect(component.render()).toMatchSnapshot();// 首次运行会生成快照文件,后续运行自动比对// 输出变化时测试失败,需用 --updateSnapshot 更新快照适合测试稳定的序列化输出(如组件渲染结果、配置对象)。不适合频繁变化的数据,否则快照文件会不断需要更新,失去测试价值。异步匹配器:处理 Promiseresolves — 期望 Promise 成功 resolverejects — 期望 Promise 被 reject// 测试异步函数成功返回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 的对象: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 会报类型错误:// 在 jest.d.ts 或 global.d.ts 中声明declare global { namespace jest { interface Matchers<R> { toBeWithinRange(floor: number, ceiling: number): R; } }}实际案例:验证日期范围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);});自定义匹配器的最佳实践命名要语义化:toBeValidEmail 比 toMatchEmailRegex 更易读,测试代码读起来像自然语言。输入校验不能省:对 received 做类型检查,遇到非法输入抛出有意义的错误,而不是返回莫名其妙的 pass: false。配合 setupFilesAfterEnv 全局注册:把 expect.extend() 放在独立的 setup 文件中,在 Jest 配置的 setupFilesAfterEnv 里引入,避免每个测试文件重复注册。优先组合内置匹配器:如果只是 expect(a).toBeGreaterThan(x) 和 expect(a).toBeLessThan(y) 的组合,直接用 .and 或写两行断言就够了,不必自定义。自定义匹配器的价值在于表达内置匹配器无法简洁描述的业务规则。选择匹配器的思路遇到断言需求时,按这个顺序选择:值比较 — toBe / toEqual / toStrictEqual类型或存在性 — toBeDefined / toBeNull / toBeTruthy大小或范围 — toBeGreaterThan / toBeCloseTo包含关系 — toContain / toContainEqual / toMatchObject函数行为 — toHaveBeenCalledWith / toThrow异步结果 — resolves / rejects内置都不合适 — expect.extend() 自定义匹配器选对了,测试的可读性和维护性都会上一个台阶。不必死记硬背所有匹配器,理解每个类别的适用场景,需要时查阅即可。自定义匹配器则是把反复出现的断言模式封装成可复用工具,在项目规模变大时尤其值得投入。
服务端阅读 05月27日 19:51

什么是 Jest 快照测试?如何使用快照测试来验证组件输出?

Jest 快照测试(Snapshot Testing)是前端测试中一种高效的质量保障手段,它通过"拍照对比"的方式确保组件输出和数据结构不会发生意外变化。本文将从原理、用法、进阶技巧到常见踩坑,全面讲解快照测试的实践方法。快照测试的工作原理快照测试的核心思路是"第一次运行时记录预期输出,后续运行时与预期比对":首次运行:Jest 将组件的渲染输出序列化为字符串,保存到 __snapshots__/ 目录下的 .snap 文件中后续运行:重新渲染组件,将输出与已保存的快照进行逐行对比差异处理:如果输出与快照不一致,测试失败并在终端展示 diff 信息;开发者确认变更合理后,可更新快照与传统的断言式测试相比,快照测试无需手写每个期望值,尤其适合 UI 组件这种结构复杂的输出对象。基本用法:React 组件快照使用 react-test-renderer 创建组件的渲染树,再调用 toMatchSnapshot() 生成快照: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 中生成类似以下的快照: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 结合快照测试: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。内联快照:toMatchInlineSnapshottoMatchInlineSnapshot() 将快照内容直接写在测试文件中,而不是单独的 .snap 文件,适合输出较短的场景:test('formatUserInfo returns correct structure', () => { const result = formatUserInfo({ name: 'Bob', age: 28 }); expect(result).toMatchInlineSnapshot(` { "age": 28, "displayName": "Bob", "isActive": true } `);});内联快照的优势在于:快照与测试代码在同一文件中,code review 时更直观;不会产生额外的快照文件。但输出较长时不建议使用,会让测试文件变得臃肿。属性匹配器:处理动态数据当快照中包含动态生成的值(时间戳、UUID、随机数)时,每次运行快照都会不同,导致测试误报。使用属性匹配器可以优雅地解决这个问题: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 日期对象)时,可以编写自定义序列化器:// customSerializer.jsconst 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'],};快照更新的正确姿势当有意修改组件导致快照测试失败时,需要更新快照:# 交互式更新(推荐):逐个确认是否更新jest --updateSnapshot# 简写jest -u# 只更新匹配特定测试名的快照jest -u --testNamePattern="UserProfile"# CI 环境中禁止意外更新jest --ci重要提醒:在 CI/CD 流水线中务必使用 --ci 标志,防止快照被意外更新而掩盖真正的 bug。Vue 组件的快照测试Vue 项目中使用 @vue/test-utils 进行快照测试: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 流程中必须审查快照变更快照测试适合结构回归,不适合验证交互行为和计算逻辑