Jest 快照测试(Snapshot Testing)完全指南:从 `toMatchSnapshot` 到内联快照的实战与原理
Jest 快照测试Snapshot Testing完全指南从toMatchSnapshot到内联快照的实战与原理【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest快照测试Snapshot Testing是 Jest 内置的一项输出回归能力它把组件渲染结果或任意可序列化值序列化保存为快照文件在后续每次运行中自动对比从而在 UI、API 响应、日志或错误信息发生非预期变化时第一时间报警。本文以 Jest 官方文档《Snapshot Testing》为骨架结合当前仓库中的 examples/snapshot 完整示例与 packages/jest-snapshot 核心实现讲解快照的生成、更新、内联快照、属性匹配器Property Matchers与最佳实践并深入源码说明何时写快照、如何比较、如何写回源码的底层机制。读完本文你将能够在自己的项目中熟练使用外部快照、内联快照与属性匹配器并理解 CI 环境下快照行为差异的根源。快照测试是什么快照测试是一种回归测试手段适用于任何不希望输出发生意外变化的场景。一个典型的快照测试流程是渲染渲染一个 UI 组件或其他可序列化值取快照将渲染结果序列化保存对比在后续运行中把新的渲染结果与保存的参考快照文件存放在测试文件旁边逐字对比。如果两次结果不一致测试失败。失败只说明两种情况之一要么是代码引入了非预期 bug要么是实现被有意修改需要更新参考快照。文档在 website/versioned_docs/version-30.4/SnapshotTesting.md 中对这一机制有完整定义本文即围绕该文档展开。使用 Jest 对 React 组件做快照测试在 Jest 中测试 React 组件时我们不需要真正渲染出图形界面那要求启动整个应用而是借助测试渲染器快速生成组件的可序列化值再交给快照匹配器。仓库的 examples/snapshot 提供了完整可运行的示例下面以其中的Link组件为例说明。被测组件与测试用例examples/snapshot/Link.js 是一个带 hover 状态的链接组件import {useState} from react; const STATUS { HOVERED: hovered, NORMAL: normal, }; export default function Link({page, children}) { const [status, setStatus] useState(STATUS.NORMAL); // ... return ( a aria-label{status} className{status} href{page || #} onMouseEnter{onMouseEnter} onMouseLeave{onMouseLeave} {children} /a ); }对应测试 examples/snapshot/tests/link.test.js 中最核心的用例import Link from ../Link; import {cleanup, render} from testing-library/react; afterEach(() cleanup()); it(renders correctly, () { const {container} render( Link pagehttp://www.facebook.comFacebook/Link, ); expect(container.firstChild).toMatchSnapshot(); });要点用testing-library/react的render渲染组件然后对container.firstChild真实 DOM 节点调用expect(...).toMatchSnapshot()。首次运行自动生成 .snap 文件第一次运行该测试时Jest 没有参考快照会直接创建快照文件 examples/snapshot/tests/snapshots/link.test.js.snap内容形如// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[renders correctly 1] a aria-labelnormal classnormal hrefhttp://www.facebook.com Facebook /a ;几点值得注意快照文件放在测试文件同级的__snapshots__目录下命名规则为测试文件名 .snap。这是由 packages/jest-snapshot/src/SnapshotResolver.ts 中的默认解析器决定的详见下文快照文件存放规则一节。序列化由 pretty-format 完成它把 DOM/JS 值格式化成人类可读的文本便于在代码评审中阅读。每个快照的键名由测试名称 计数序号组成renders correctly 1。在 packages/jest-snapshot/src/State.ts 中通过_counters为同名测试递增序号保证同名用例可以共存。后续运行时Jest 会把新渲染结果与已有快照逐字比较一致则通过不一致则失败。失败时你要判断是代码例如Link组件有 bug 需要修复还是实现有意变更、快照需要更新。快照的作用域文档特别指出快照只与测试中实际渲染的数据绑定。例如上例中快照仅针对传入page属性的Link组件即使其他文件比如App.js漏传了page属性导致组件渲染异常本测试依然通过——因为该测试并不感知Link在别处的用法它的作用域仅限于Link.js。同理在别的快照测试中用不同 props 渲染同一组件也不会互相影响因为各测试之间彼此无感知。这既是快照精准聚焦的优点也意味着快照无法覆盖组件在所有调用场景下的行为。更新快照--updateSnapshot与相关参数当快照失败是因为有意修改实现时就需要重新生成快照。文档中的典型场景把 Link 指向的地址从 Facebook 改为 Instagram// Updated test case with a Link to a different address it(renders correctly, () { const {container} render( Link pagehttp://www.instagram.comInstagram/Link, ); expect(container.firstChild).toMatchSnapshot(); });此时运行 Jest 会得到快照不匹配的失败输出。由于改动是预期的我们可以接受新输出并重新生成快照jest --updateSnapshot也可以使用等价单字符参数jest -u--updateSnapshot-u会为所有失败的快照测试重新生成快照文件。因此务必注意如果失败中有部分是由无意的 bug 引起的应当先修复 bug 再重新生成避免把 bug 行为录进快照。如果只想重录部分测试的快照可以配合--testNamePattern参数只更新名称匹配指定模式的测试jest --updateSnapshot --testNamePatternrenders correctly想亲手体验可以查看 examples/snapshot 目录下的Link.js修改后运行 Jest 观察快照变化。仓库的 e2e 测试也覆盖了更新快照的各种组合场景例如 e2e/tests/toMatchSnapshotWithRetries.test.ts、e2e/tests/watchModeUpdateSnapshot.test.ts。交互式快照模式Interactive Snapshot Mode除了命令行参数失败的快照还可以在**监听模式watch mode**下交互式更新运行jest --watch当快照测试失败时按i进入交互式快照模式Jest 会一次一个地带你过一遍失败的快照让你逐一审阅失败输出对每个失败快照可以选择更新u或跳到下一个s/skip结束后 Jest 会先给出本次更新的汇总再返回 watch 模式。这种模式把审阅 diff 后决定是否接受变成可操作的流程避免盲目-u全量重录。文档在Interactive Snapshot Mode一节对此有完整说明。内联快照toMatchInlineSnapshot内联快照与外部快照.snap文件行为完全一致唯一区别是快照值会被自动写回源代码作为toMatchInlineSnapshot()的参数。这样既享受自动生成快照的便利又不必跳到外部文件确认写入的值是否正确。先写一个不带参数的调用it(renders correctly, () { const {container} render( Link pagehttps://example.comExample Site/Link, ); expect(container.firstChild).toMatchInlineSnapshot(); });下次运行 Jest 时渲染结果会被求值并作为模板字符串参数写回源码it(renders correctly, () { const {container} render( Link pagehttps://example.comExample Site/Link, ); expect(container.firstChild).toMatchInlineSnapshot( a aria-labelnormal classNamenormal hrefhttps://example.com Example Site /a ); });后续更新同样支持--updateSnapshot或在--watch模式下按u键。内联快照的写回机制与 prettier 委托从源码看内联快照的写回源码并不是简单的字符串拼接而是由 packages/jest-snapshot/src/utils.ts 中的processInlineSnapshotsWithBabel完成Jest 用 Babel 解析测试源码的 AST定位toMatchInlineSnapshot调用处通过调用点的行列号定位再把快照作为模板字面量参数插入 AST 并重新生成代码。这也是为何内联快照要求测试源码能被解析TS/TSX 文件会自动加载 TypeScript 语法插件JSX 语法缺失时还会尝试恢复。State.ts的save()方法会调用saveInlineSnapshots把累积的内联快照批量写回。另外默认情况下由 Jest 自己负责把快照写入源码但如果你的项目使用了 prettierJest 会自动检测并把格式化工作委托给 prettier包括遵守你的 prettier 配置。这一点在 packages/jest-snapshot/src/State.ts 中有对应实现——SnapshotStateOptions接收prettierPath构造函数将其保存在_prettierPath中供写回时使用。属性匹配器Property Matchers驯服动态字段实际对象里常有每次运行都不同的生成字段比如 ID、时间戳。如果直接快照测试每次都会失败it(will fail every time, () { const user { createdAt: new Date(), id: Math.floor(Math.random() * 20), name: LeBron James, }; expect(user).toMatchSnapshot(); }); // Snapshot exports[will fail every time 1] { createdAt: 2018-05-19T23:36:09.816Z, id: 3, name: LeBron James, } ;解决办法是给toMatchSnapshot传入一个属性匹配器对象其中任意属性都可以用非对称匹配器asymmetric matcher如expect.any(...)描述。这些匹配器在写入或比较快照之前先校验然后以匹配器形式而非具体值保存进快照文件it(will check the matchers and pass, () { const user { createdAt: new Date(), id: Math.floor(Math.random() * 20), name: LeBron James, }; expect(user).toMatchSnapshot({ createdAt: expect.any(Date), id: expect.any(Number), }); }); // Snapshot exports[will check the matchers and pass 1] { createdAt: AnyDate, id: AnyNumber, name: LeBron James, } ;不是匹配器的普通值会被精确校验并原样存入快照it(will check the values and pass, () { const user { createdAt: new Date(), name: Bond... James Bond, }; expect(user).toMatchSnapshot({ createdAt: expect.any(Date), name: Bond... James Bond, }); }); // Snapshot exports[will check the values and pass 1] { createdAt: AnyDate, name: Bond... James Bond, } ;从源码看属性匹配器的实现位于 packages/jest-snapshot/src/index.ts 的_toMatchSnapshot先用context.equals结合iterableEquality与subsetEquality校验属性是否匹配匹配时通过deepMerge见 packages/jest-snapshot/src/utils.ts把匹配器描述并入待快照对象使AnyDate这类占位能进入快照文件不匹配则直接判定失败。字符串中的随机部分怎么办如果快照对象是字符串而不是对象属性匹配器就不适用了需要在快照前自己把随机部分替换掉。文档给出的做法是用String.prototype.replace()配合正则const randomNumber Math.round(Math.random() * 100); const stringWithRandomData div id${randomNumber}Lorem ipsum/div; const stringWithConstantData stringWithRandomData.replace(/id\d/, 123); expect(stringWithConstantData).toMatchSnapshot();除此之外还有两条替代路线使用快照序列化器snapshot serializer统一处理配置项见 docs/Configuration.md 中的snapshotSerializers对生成随机部分的库做mockdocs/MockFunctions.md从源头固定输出。快照的工作原理走进 jest-snapshot 源码toMatchSnapshot系列匹配器由独立的jest-snapshot包实现packages/jest-snapshot下面从源码角度解释几个关键机制。匹配器家族packages/jest-snapshot/src/index.ts 导出了四个匹配器匹配器用途toMatchSnapshot(properties?, hint?)快照任意可序列化值可带属性匹配器与提示字符串toMatchInlineSnapshot(properties?, inlineSnapshot?)内联版快照toThrowErrorMatchingSnapshot(hint?)快照函数抛出的错误消息toThrowErrorMatchingInlineSnapshot(inlineSnapshot?)内联版错误消息快照其中toThrowErrorMatchingSnapshot会执行传入函数并捕获异常把error.message含cause链作为快照对象交给_toMatchSnapshot。所有快照匹配器都禁止与.not连用源码中有NOT_SNAPSHOT_MATCHERS检查。何时写入快照CI 行为的根源SnapshotState.match()packages/jest-snapshot/src/State.ts注释明确列出了写快照的条件写非 CI 环境下没有快照文件 / 快照文件里没有该条快照 / 显式传入--updateSnapshot不写更新标志为none或在 CI 环境下既没有快照文件、文件里也没有这条快照。更新标志updateSnapshot有三态all/new/none由 CLI 参数推导。因此文档 FAQ 中CI 上不会自动写入新快照这一行为正是该逻辑的体现CI 中新增的、没有参考值的快照会以失败告终报告信息为New snapshot was not written. The update flag must be explicitly passed...以此强制团队把所有快照纳入版本控制。比较过程本身很简单把收到的值序列化后与期望字符串逐字比较expected receivedSerialized匹配时还会用新格式化结果刷新内存中的数据保证转义一致性。SnapshotState内部维护added / matched / unmatched / updated四个计数器正是最终测试报告中快照统计的来源。快照文件存放规则与自定义解析器默认解析器packages/jest-snapshot/src/SnapshotResolver.ts 的createDefaultSnapshotResolver逻辑为resolveSnapshotPath(testPath)把foo/__tests__/bar.test.js映射到foo/__tests__/__snapshots__/bar.test.js.snapresolveTestPath(snapshotPath)逆向还原测试路径扩展名固定为.snapEXTENSION snap。如果你希望快照放在其他位置例如统一目录可以在 jest 配置中通过snapshotResolver指定自定义解析器模块它必须实现resolveSnapshotPath、resolveTestPath两个函数和testPathForConsistencyCheck示例路径且两个转换函数必须互逆源码中的verifyConsistentTransformations会在加载时做一致性校验。仓库 packages/jest-snapshot/src/tests/fixtures/customSnapshotResolver.js 提供了自定义解析器样例packages/jest-snapshot/src/tests/SnapshotResolver.test.ts 中覆盖了缺失函数、不一致转换等异常分支。序列化与可读性快照序列化由 packages/jest-snapshot/src/utils.ts 的serialize完成其内部调用 pretty-format 并注入快照序列化插件getSerializers()最后统一换行符。多行快照前后会加上空行addExtraLineBreaks/removeExtraLineBreaks以增强可读性。这也是为什么.snap文件天然适合 Code Review——它本质上是可读的文本 diff。快照测试最佳实践文档在Best Practices一节给出了三条核心准则配合示例说明如下。1. 像对待代码一样对待快照快照必须提交进版本库并作为日常 Code Review 的一部分被审阅。具体建议保持快照聚焦、简短、可读用工具强制风格约定除了 Jest 内置的 pretty-format 可读化外可以引入额外工具来督促提交短而精的断言例如eslint-plugin-jest的no-large-snapshots规则或snapshot-diff的组件快照对比功能目标是在 Pull Request 中轻松审阅快照并抵制套件红了就重新生成的坏习惯——应该先调查失败根因而不是无脑重录。2. 测试必须是确定性的同一组件在未变更的情况下反复运行必须产生完全一致的结果。这意味着你要负责确保生成的快照不包含平台相关或非确定性的数据。例如 examples/snapshot/Clock.js 是一个内部调用Date.now()的时钟组件直接快照会每次不同。仓库示例 examples/snapshot/tests/clock.test.js 用 fake timers 固定系统时间jest.useFakeTimers().setSystemTime(1_482_363_367_071); it(renders correctly, () { const {container} render(Clock /); try { expect(container.firstChild).toMatchSnapshot(); } finally { cleanup(); } });文档中给出的等价做法是直接 mock 掉Date.nowDate.now jest.fn(() 1_482_363_367_071);这样每次运行时Date.now()都稳定返回1482363367071快照自然也就稳定了。更系统的定时器控制方式可参考 docs/TimerMocks.md。3. 使用描述性的快照名称快照名称即测试名应当描述期望的快照内容而不是含糊的处理某个用例。对比以下两种命名exports[UserName / should handle some test case] null; exports[UserName / should handle some other test case] div Alan Turing /div ;改为描述输出本身后一旦快照错误会立刻暴露问题exports[UserName / should render null] null; exports[UserName / should render Alan Turing] div Alan Turing /div ;显然后者能准确告诉评审者期望的输出是什么任何人看到过期快照时也能立即判断这个旧值是不是正确行为。文档还给出一个反面示例——如果UserName / should render null的快照内容变成了divAlan Turing/div这种命名下错误一目了然。常见问题FAQCI 上会自动写入快照吗不会。自 Jest 20 起在 CI 系统中不显式传--updateSnapshot时Jest不会自动写入新快照。设计意图是所有快照都应作为被测代码的一部分进入 CI由于新快照自动通过若允许 CI 自动写入就无法拦截新增输出。因此官方强烈建议总是提交所有快照并纳入版本控制。如前文所述这一行为由SnapshotState.match的写入条件直接决定。快照文件应该提交吗应该。所有快照文件都应与其覆盖的模块和测试一起提交它们被视为测试的一部分等价于其他断言的值。快照代表了源码模块在某一时刻的状态——当源码被修改时Jest 能指出相对上一版本的变化这也能在 Code Review 中为评审者提供大量额外上下文。快照测试只适用于 React 组件吗不是。Reactdocs/TestingFrameworks.md和 React Nativedocs/TutorialReactNative.md组件是典型用例但快照可以捕获任意可序列化值只要目标是验证输出是否正确就可以用。Jest 仓库本身就是例子大量测试用于快照 Jest 自身的输出、断言库输出以及各模块的日志信息例如 e2e/tests/console.test.ts 就是对 CLI 控制台输出做快照。快照测试和视觉回归测试有什么区别两者是目的不同的 UI 测试手段视觉回归测试工具会截取网页截图并逐像素对比图片而快照测试把值序列化存入文本文件用 diff 算法比较。各有取舍快照更快、更轻、更适合结构化输出但感知不到纯视觉层面的变化。快照测试会取代单元测试吗不会。快照只是 Jest 内置 20 多种断言之一目标是提供额外价值、让测试更轻松而不是取代现有单元测试。在某些场景如 React 组件下快照确实可能让特定功能不再需要手写单元测试但二者完全可以协同工作。快照测试在速度和文件大小上的性能如何Jest 从设计上就重视性能快照测试也不例外。快照以纯文本存储因此测试快速且可靠每个调用toMatchSnapshot的测试文件会生成一个独立的.snap文件。文件体积很小——作为参考Jest 自身代码库中所有快照文件合计不超过 300 KB。快照文件出现冲突怎么解决快照文件必须始终代表所覆盖模块的当前状态。合并分支时若在.snap文件上产生冲突可以手动解决冲突也可以重新运行 Jest 生成新快照并检查结果是否符合预期。能否用快照做测试驱动开发TDD虽然理论上可以手写快照文件但通常不可行。快照擅长的是检测输出是否变化而不是在写代码前指导代码设计因此它不适合作为 TDD 的主要工具。代码覆盖率与快照测试兼容吗兼容。快照测试与任何其他测试一样参与代码覆盖率统计。小结快照测试是 Jest 生态中低成本、高价值的输出回归手段toMatchSnapshot负责外部.snap文件toMatchInlineSnapshot把期望值内联进源码属性匹配器Property Matchers驯服动态字段--updateSnapshot/-u/ watch 交互模式覆盖所有更新路径。理解 packages/jest-snapshot 中何时写快照、如何序列化、如何写回的实现逻辑能帮助你在 CI 与本地环境中做出正确的快照策略而视快照为代码、保证确定性、使用描述性名称三大最佳实践则是让快照长期可维护的关键。动手实验可以从 examples/snapshot 开始把Link.js改一改、跑一跑就能直观感受完整的快照工作流。【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考