面试题手册

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

服务端阅读 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 流程中必须审查快照变更快照测试适合结构回归,不适合验证交互行为和计算逻辑