Vitest 快照系统内核:@vitest/snapshot 的架构设计与实现详解
Vitest 快照系统内核vitest/snapshot 的架构设计与实现详解【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest本文以 Vitest 仓库中的vitest/snapshot包为核心深入剖析其作为独立快照引擎的设计从SnapshotClient、SnapshotManager、NodeSnapshotEnvironment三大组件的职责划分到更新模式、路径解析、序列化格式与文件 I/O 的底层实现。读完后你将理解toMatchSnapshot/toMatchInlineSnapshot/toMatchFileSnapshot背后的完整调用链并掌握将该引擎集成进任意测试运行器的方法。一、vitest/snapshot 是什么package.json 中对该包的描述是 “Vitest snapshot manager”官方定位见 README是 “Lightweight implementation of Jests snapshots”——一个轻量级的 Jest 快照实现。它不依赖 Vitest 运行时的任何具体逻辑只依赖vitest/pretty-format、vitest/utils、magic-string和pathe四个库因此可以脱离 Vitest 单独使用作为任何测试框架的快照引擎。该包的模块导出package.json 的exports字段分为三个入口入口实现文件职责.主入口src/index.ts导出SnapshotClient、SnapshotState、序列化器插件、内联快照处理等核心能力./environmentsrc/environment.ts导出NodeSnapshotEnvironment与SnapshotEnvironment类型./managersrc/manager.ts导出SnapshotManager用于汇总多个文件的快照结果主入口还导出了stripSnapshotIndentation内联快照缩进处理、addSerializer/getSerializerspretty-format 自定义序列化器、以及从 Jest 移植的SnapshotState文件头版权信息见 src/port/state.ts 第 1-6 行。port/目录名即暗示了这一点快照状态机是从 Jest 的snapshot包移植而来并做了面向 Vitest 场景的改造。二、核心组件总览SnapshotClient单文件级快照状态机SnapshotClient定义在 src/client.ts是面向测试运行时的入口类。它内部维护一个snapshotStateMap: Mapstring, SnapshotState以测试文件路径为键缓存各文件的SnapshotState源码第 72-75 行即一个测试文件对应一个快照状态实例。关键 API 包括setup(filepath, options)为指定测试文件创建SnapshotState源码第 77-88 行已存在则直接复用finish(filepath)对状态机执行pack()保存快照文件并返回SnapshotResult随后从 Map 中移除源码第 90-95 行match(options)/assert(options)执行单次断言。match返回MatchResultpass、message、actual、expected与expect.extend自定义 matcher 的同步结果结构一致源码第 60-66 行assert在断言失败时抛出带有actual/expected/diffOptions属性的ErrorcreateMismatchError源码第 7-28 行供上层生成 diffskipTest/clearTest分别处理测试被跳过将相关快照标记为已检查和测试重试clearTest回滚该测试 ID 已写入的快照值见 src/port/state.ts 第 162-180 行matchDomain/pollMatchDomain面向“域快照”domain snapshot的扩展断言下文详述。构造函数接收一个可选项interface SnapshotClientOptions { isEqual?: (received: unknown, expected: unknown) boolean }这个isEqual正是 README 强调 “you need to provide your own equality check implementation if you use it” 的原因——它仅在toMatchSnapshot(properties)这种带属性子集校验的调用中使用src/client.ts 第 156-182 行先做isEqual(received, properties)属性匹配失败则返回properties mismatched通过后deepMergeSnapshot合并再走整体快照对比。Vitest 自身的实现位于 packages/vitest/src/integrations/snapshot/chai.ts使用vitest/expect的equals配合iterableEquality、subsetEquality构造器并以单例getSnapshotClient()全局复用。AssertOptions一次断言的全部输入match/assert接收的AssertOptionssrc/client.ts 第 30-47 行定义了断言的完整输入面interface AssertOptions { received: unknown // 实际值 filepath: string // 测试文件路径缺失会直接抛 “Snapshot cannot be used outside of test” name: string // 测试名suite 名 test 名 testId?: string // 默认等于 nameretry 场景需要真实测试 ID message?: string // toMatchSnapshot(xxx) 的自定义消息最终 key 为 name message isInline?: boolean // false默认 文件快照 properties?: object // 子集属性校验 inlineSnapshot?: string // 内联快照已写入源码的字符串 error?: Error // 用于定位断言调用点的 Error 栈 errorMessage?: string rawSnapshot?: RawSnapshotInfo // toMatchFileSnapshot 的专用信息 assertionName?: string }几个值得注意的实现细节文件快照与快照文件路径冲突检测若rawSnapshot.file恰好等于当前测试的.snap文件路径会直接抛错源码第 138-145 行。源码注释说明这是 best-effort 检查跨测试文件的路径冲突无法全部捕获。key 的构造testName [name, ...(message ? [message] : [])].join( )源码第 146 行即suite test或suite test message与.snap文件中的键名格式一致。raw 快照不做 trim返回值中actual/expected仅在非rawSnapshot场景下.trim()源码第 198-199 行保证文件快照的换行语义完整保留。SnapshotState真正的状态机每个测试文件的状态由SnapshotStatesrc/port/state.ts维护它是从 Jest 移植的核心。构造时源码第 95-124 行会通过environment.resolvePath(testFilePath)解析出.snap文件路径优先调用environment.readSnapshotFileData环境可选提供的直接返回Recordstring, string的接口用于测试运行之外的场景否则readSnapshotFileevaluateSnapshotFile解析初始化 pretty-format 默认格式printBasicPrototype: false、escapeString: false、maxOutputLength: 2 ** 27128MB注释说明与 Node 的util.inspect安全上限一致比 pretty-format 默认的 1MB 更宽松因为用户可能把大快照专门存到独立文件。此外SnapshotState还暴露了added/matched/unmatched/updated四个 getter/setter源码注释明确这是为了兼容 jest-image-snapshot 风格插件src/port/state.ts 第 80-93 行。快照更新三态updateSnapshotSnapshotStateOptionssrc/types/index.ts 第 20-26 行定义了状态机的全部选项interface SnapshotStateOptions { updateSnapshot: SnapshotUpdateState // all | new | none snapshotEnvironment: SnapshotEnvironment expand?: boolean snapshotFormat?: PrettyFormatOptions resolveSnapshotPath?: (path: string, extension: string, context?: any) string }updateSnapshot的三种取值对应三种写入策略在SnapshotState构造时即决定初始脏标记const dirty (update all || update new) fileData ! nullsrc/port/state.ts 第 103-107 行即只有当快照文件已存在且处于all/new模式时才认为“可变更”。这与 CLI 的--update语义一致none只读比对、new允许新增条目、all允许覆写全部条目。三、SnapshotEnvironment可插拔的文件 I/O 抽象快照的读写被抽象为SnapshotEnvironment接口src/types/environment.tsexport interface SnapshotEnvironment { getVersion: () string getHeader: () string resolvePath: (filepath: string) Promisestring resolveRawPath: (testPath: string, rawPath: string) Promisestring saveSnapshotFile: (filepath: string, snapshot: string) Promisevoid readSnapshotFile: (filepath: string) Promisestring | null readSnapshotFileData?: (filepath: string) PromiseRecordstring, string | null removeSnapshotFile: (filepath: string) Promisevoid processStackTrace?: (stack: ParsedStack) ParsedStack }默认实现NodeSnapshotEnvironmentsrc/env/node.ts基于node:fs要点resolvePath默认把快照放到__snapshots__子目录join(dirname(filepath), __snapshots__, basename(filepath) .snap)且支持通过构造选项snapshotsDirName自定义目录名getHeader()返回// Snapshot v1即快照文件头部注释readSnapshotFile在文件不存在时返回null而非抛错使“首次运行写入新快照”成为自然行为prepareDirectory使用fs.mkdir(dirPath, { recursive: true })递归建目录。这个抽象的意义在 Vitest 的集成层体现得很清楚packages/vitest/src/integrations/snapshot/environments/node.ts 中的VitestNodeSnapshotEnvironment继承NodeSnapshotEnvironment仅重写了两个方法——export class VitestNodeSnapshotEnvironment extends NodeSnapshotEnvironment { getHeader(): string { return // Vitest Snapshot v${this.getVersion()}, https://vitest.dev/guide/snapshot.html } resolvePath(filepath: string): Promisestring { const rpc getWorkerState().rpc return rpc.resolveSnapshotPath(filepath) } }头部注释换成了带文档链接的 Vitest 版本而resolvePath通过 RPC 委托给主线程——因为resolveSnapshotPath是用户可在配置文件里自定义的钩子对应vitest.config.ts的snapshotOptions语义必须走主线程解析。这正是 README 中 “by default uses fs module, but you can provide your own implementation depending on the environment” 一句的工程化落地。四、SnapshotManager跨文件结果汇总SnapshotManagersrc/manager.ts负责把多个测试文件的SnapshotResult聚合成全局SnapshotSummary构造参数是去掉了snapshotEnvironment的SnapshotStateOptions。它有两个值得关注的点resolvePath的默认回退逻辑若用户未提供resolveSnapshotPath则回退到标准的__snapshots__/文件名.snap规则源码第 26-37 行resolveRawPath处理toMatchFileSnapshot的相对路径绝对路径原样返回相对路径相对测试文件目录解析源码第 39-41 行。emptySummary的字段语义源码第 44-64 行added/matched/unchecked/unmatched/updated/total六类快照计数加上filesAdded/filesRemoved/filesUpdated/filesUnmatched四个文件维度计数以及uncheckedKeysByFile各文件中未被任何断言命中的陈旧快照键和didUpdate当且仅当updateSnapshot all。这些字段最终就是测试报告里 “Snapshots X passed | Y written | Z obsolete” 一类输出的数据来源。addSnapshotResult源码第 66-97 行展示了聚合规则total added matched unmatched updatedunchecked键按文件分组推入uncheckedKeysByFile。五、完整使用示例把 vitest/snapshot 接入你的运行器下面是 README 给出的完整集成示例配合源码注释逐项解读import { SnapshotClient } from vitest/snapshot import { NodeSnapshotEnvironment } from vitest/snapshot/environment import { SnapshotManager } from vitest/snapshot/manager const client new SnapshotClient({ // you need to provide your own equality check implementation if you use it // this function is called when .toMatchSnapshot({ property: 1 }) is called isEqual: (received, expected) equals(received, expected, [iterableEquality, subsetEquality]), }) // class that implements snapshot saving and reading // by default uses fs module, but you can provide your own implementation depending on the environment const environment new NodeSnapshotEnvironment() // you need to implement this yourselves, this depends on your runner function getCurrentFilepath() { return /file.spec.js } function getCurrentTestName() { return test1 } // example for inline snapshots, nothing is required to support regular snapshots, // just call assert with isInline: false function wrapper(received) { function __INLINE_SNAPSHOT__(inlineSnapshot, message) { client.assert({ received, message, isInline: true, inlineSnapshot, filepath: getCurrentFilepath(), name: getCurrentTestName(), }) } return { // the name is hard-coded, it should be inside another function, so Vitest can find // the actual test file where it was called (parses call stack trace 2) // you can override this behaviour in SnapshotStates _inferInlineSnapshotStack method // by providing your own SnapshotState to SnapshotClient constructor toMatchInlineSnapshot: (...args) __INLINE_SNAPSHOT__(...args), } } const options { updateSnapshot: new, snapshotEnvironment: environment, } await client.startCurrentRun( getCurrentFilepath(), getCurrentTestName(), options ) // this will save snapshot to a file which is returned by snapshotEnvironment.resolvePath client.assert({ received: some text, isInline: false, }) // uses pretty-format, so it requires quotes // also naming is hard-coded when parsing test files wrapper(text 1).toMatchInlineSnapshot() wrapper(text 2).toMatchInlineSnapshot(text 2) const result await client.finishCurrentRun() // this saves files and returns SnapshotResult // you can use manager to manage several clients const manager new SnapshotManager(options) manager.add(result) // do something, and then read the summary console.log(manager.summary)对示例中的几个要点逐一说明isInline: false即文件快照。普通快照无需任何额外配置保存位置由snapshotEnvironment.resolvePath决定默认__snapshots__/文件.snap内联快照的两个硬编码约定一是序列化使用 pretty-format 输出字符串必须带引号text 2二是内联快照回写源码时依赖调用栈解析定位行号parses call stack trace 2因为wrapper函数名是固定的实际断言位置要靠栈帧推断。这一行为可以通过SnapshotState的_inferInlineSnapshotStack方法覆盖startCurrentRun/finishCurrentRun的生命周期前者为当前测试文件建立SnapshotState内部即setup后者打包并落盘返回SnapshotResult含added/matched/unchecked/unmatched/updated/fileDeleted等字段定义见 src/types/index.ts 第 52-61 行。六、域快照Domain Snapshot面向模板匹配的扩展SnapshotClient除常规断言外还实现了matchDomain与pollMatchDomainsrc/client.ts 第 216-352 行配合DomainSnapshotAdapter接口src/domain.tsexport interface DomainSnapshotAdapterCaptured unknown, Expected unknown { name: string capture: (received: unknown) Captured // 原始值 - 域内捕获表示 render: (captured: Captured) string // 捕获表示 - 可序列化字符串 parseExpected: (input: string) Expected // 快照文本 - 模板表示 match: (captured: Captured, expected: Expected) DomainMatchResult }这套设计允许把“快照内容”从纯文本升级为带模板语法的结构例如包含正则占位符的模板DomainMatchResult的resolved字段表示“把实际值代入模板后的渲染结果”专门用于 diff 显示避免模板与字面量之间的噪音差异和--update时保留用户手写模式expected字段则是快照模板的重新渲染作为 diff 的期望侧src/domain.ts 第 4-29 行注释。pollMatchDomain进一步支持轮询稳定值场景配合expect.poll语义内部getStableSnapshot循环调用poll直到连续两次渲染结果一致才判定稳定且每次poll与间隔等待都与超时 Promise 竞速raceWith源码第 398-461 行默认timeout: 1000、interval: 50若已有期望快照且非all模式还会叠加match(captured)校验要求稳定值必须命中已有模板。这是把“异步值 模板匹配 超时”组合起来的完整状态机也解释了为什么pollMatchDomain是async而常规match是同步的。七、raw 快照与 toMatchFileSnapshotassertRawsrc/client.ts 第 354-381 行处理toMatchFileSnapshot场景当rawSnapshot.content null时通过environment.resolveRawPathenvironment.readSnapshotFile读取目标文件内容作为期望值并注释说明了 “save the filepath, so it dont lose even if the await make it out-of-context”——因为 await 之后运行器上下文可能已经切换。Vitest 仓库内的真实用例可参考 test/unit/test/snapshot-file.test.ts它用import.meta.glob读取input.json的原始内容经 CSS 生成函数转换后调用.toMatchFileSnapshot(...output.css)并覆盖了空文件场景expect().toMatchFileSnapshot(./fixtures/snapshot-empty.txt)。同目录下还有snapshot-inline.test.ts、snapshot-async.test.ts、snapshot-concurrent.test.ts、snapshot-custom-serializer.test.ts等一系列单测分别验证内联回写、异步快照、并发快照与自定义 pretty-format 序列化器可作为该引擎行为规格的“活文档”。八、小结vitest/snapshot的设计可以概括为三层解耦状态层SnapshotStatesrc/port/state.ts从 Jest 移植、负责快照键管理、计数器、内联/原始快照收集与更新三态策略客户端层SnapshotClientsrc/client.ts面向运行时 API处理断言匹配、diff 错误构造、域快照与轮询稳定化环境层SnapshotEnvironmentsrc/types/environment.ts可插拔的文件 I/O 与路径解析抽象NodeSnapshotEnvironment只是默认实现之一Vitest 通过 RPC 重写了resolvePath来支持用户自定义resolveSnapshotPath。对测试框架开发者而言这套接口给出了最小集成路径实现isEqual、准备当前文件与测试名、选择updateSnapshot模式、注入SnapshotEnvironment即可在自己的运行器中复刻出 Vitest 完整的快照能力对使用者而言理解updateSnapshot三态、__snapshots__目录约定与内联快照的栈定位机制也就能准确解释快照文件何时被写、何时被删、以及内联快照为何能精确回写到源码对应行。【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →