Supabase 仓库中的 Vitest 快照测试实战:文件快照、内联快照与更新机制全解析
本文基于当前仓库中 features-snapshots.md 快照测试参考文档展开,系统讲解 Vitest 快照测试的四种形态(文件快照、内联快照、文件对比快照、错误快照)、对象形状匹配、自定义序列化器、快照更新与路径定制等完整能力,并结合本仓库中 apps/docs 的 GraphQL schema 快照与 packages/ai-commands 的 SQL 格式化快照等真实用例,帮助你在大型 Monorepo 中建立稳定、可审查的快照测试实践。
快照测试的定位与本仓库中的真实用例
快照测试的核心思路是:捕获一段输出(字符串、对象、HTML、错误信息等),将其与存储的基准参照进行比对,任何输出变化都会在下次运行时导致测试失败。它特别适合验证"输出结构稳定"的场景——GraphQL schema、SQL 格式化结果、渲染产物、报错文案等。
在当前的 Supabase 仓库中,快照测试已经有多处落地。从源码检索可以看到,packages/pg-meta 的测试目录(如 tables.test.ts、functions.test.ts、views.test.ts 等)以及 apps/docs/app/api/graphql/route.test.ts、packages/ai-commands/src/sql/functions.test.ts 均使用了 toMatchSnapshot 等快照断言。仓库中实际存在两个已提交的 .snap 快照文件:
- route.test.ts.snap —— 锁定 docs 站 GraphQL API 的完整 schema 结构;
- functions.test.ts.snap —— 锁定 SQL 格式化函数的输出。
两个文件首行均为 // Vitest Snapshot v1,说明该仓库的快照版本化管理方式:快照文件必须提交进版本库,任何 schema 或输出格式的变化都会体现为 .snap 文件的 diff,从而在 code review 中被人眼审视。
文件快照:toMatchSnapshot 与 .snap 文件
最基础的用法是 toMatchSnapshot():
import { expect, test } from 'vitest'
test('snapshot', () => {
const result = generateOutput()
expect(result).toMatchSnapshot()
})
首次运行后,Vitest 会生成 __snapshots__/test.spec.ts.snap 文件,形如:
// __snapshots__/test.spec.ts.snap
exports['snapshot 1'] = `
{
"id": 1,
"name": "test"
}
`
后续每次运行都会把当前输出与该文件中的基准值比对,不一致即测试失败。
本仓库中这一模式最典型的用例是 route.test.ts 中的 /api/graphql schema snapshot 测试(第 80 行起):
describe('/api/graphql schema snapshot', () => {
it('should match snapshot', async () => {
const schemaQuery = `
query {
schema
}
`
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: schemaQuery }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors).toBeUndefined()
const {
data: { schema },
} = json
expect(schema).toMatchSnapshot()
})
})
该测试通过真实调用 Next.js 路由处理函数 POST,执行一个 { schema } 自省查询,然后把完整 schema 的字符串表示交给快照断言。对应的 route.test.ts.snap 文件有 255 行,完整记录了 Guide、SearchResult、SubsectionCollection 等类型定义及其字段文档注释。这意味着:任何人修改 docs 站 GraphQL schema(增删字段、调整类型、改动注释),CI 中的快照测试就会失败,迫使提交者在 review 阶段解释 schema 变更——这是对 API 契约的强约束。
同样基于 toMatchSnapshot 的还有 functions.test.ts(第 31、44 行对 formatSql(sql) 的结果做快照),其快照文件 functions.test.ts.snap 记录的内容是格式化后的 SQL 文本,例如:
exports[`debug > fix typos 1`] = `
"select
*
from
employees;"
`;
这里测试的是 ai-commands 包中"修复 SQL 拼写/格式化"相关函数,快照把期望的规范化输出固化下来,保证 AI 命令链路的 SQL 输出格式不会悄悄漂移。该包的运行环境配置可见 vitest.config.ts:environment: 'node'、testTimeout: 30000 并加载了 vitest.setup.ts。
内联快照:toMatchInlineSnapshot
当基准值与测试代码强相关、且体量不大时,可以把快照直接写进测试文件:
test('inline snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchInlineSnapshot()
})
首次运行(或按 u 更新)后,Vitest 会自动改写测试文件本身:
test('inline snapshot', () => {
const data = { foo: 'bar' }
expect(data).toMatchInlineSnapshot(`
{
"foo": "bar",
}
`)
})
内联快照的优点是"所见即所得":断言期望值就在测试代码旁边,code review 时不需要跳转到另一个 .snap 文件。适合小型、稳定的输出(如枚举值、配置对象);对于像 schema 这样的大体量输出,则更适合文件快照。
文件快照:toMatchFileSnapshot
当输出本身就是一个独立文件(HTML、JSON 等),或者你希望基准文件使用真实扩展名以便用对应工具查看时,可以使用 toMatchFileSnapshot 与显式文件比对:
test('render html', async () => {
const html = renderComponent()
await expect(html).toMatchFileSnapshot('./expected/component.html')
})
注意这是异步 API(await expect(...)),因为涉及文件读写。快照参考文档在"Key Points"中也明确建议:对大输出(HTML、JSON)优先使用 toMatchFileSnapshot,避免让 .snap 文件里挤满难以审查的巨型字符串。
快照提示(Hints):一个测试多个快照
同一测试中多次调用 toMatchSnapshot() 时,基准条目默认以 snapshot 1、snapshot 2 编号区分,可读性差。传入第一个参数即可加描述性提示:
test('multiple snapshots', () => {
expect(header).toMatchSnapshot('header')
expect(body).toMatchSnapshot('body content')
expect(footer).toMatchSnapshot('footer')
})
此时 .snap 文件中的键会变成 exports['multiple snapshots header 1'] 这样的形式,diff 时能直接看出是哪个部分变了。参考文档的 Key Points 亦强调:一个测试中有多份快照时,务必使用 hints。
对象形状匹配:快照 + 非对称匹配器组合
快照要求精确匹配,但输出中往往有随机值(id、时间戳)无法稳定复现。Vitest 允许在 toMatchSnapshot 中传入"部分结构"参数,对不稳定字段改用非对称匹配器:
test('shape snapshot', () => {
const data = {
id: Math.random(),
created: new Date(),
name: 'test'
}
expect(data).toMatchSnapshot({
id: expect.any(Number),
created: expect.any(Date),
})
})
含义是:id 只要求是 Number、created 只要求是 Date 实例,其余字段(如 name)仍与 .snap 基准精确比对。这是"既要结构精确、又要容忍随机值"的标准解法。
错误快照
错误对象同样可以快照化,断言抛错的文案格式稳定:
test('error message', () => {
expect(() => {
throw new Error('Something went wrong')
}).toThrowErrorMatchingSnapshot()
})
test('inline error', () => {
expect(() => {
throw new Error('Bad input')
}).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`)
})
第二个示例展示内联错误快照的存储形式 [Error: Bad input]。适合用于锁定用户可见的错误提示文案——例如 API 层的报错消息一旦写进快照,后续改动就必须在 review 中被显式确认。
更新快照:-u 与 watch 模式
快照基准更新只有两种官方途径:
# 更新所有快照
vitest -u
vitest --update
# 在 watch 模式下,按 'u' 键更新失败的快照
- 命令行
vitest -u:将当前所有失败/缺失的快照一次性写回基准文件,通常用于"确认输出变化是预期行为"之后; - watch 模式下按
u:只更新当前失败的快照,粒度更细,适合本地迭代。
配套纪律来自参考文档的 Key Points:快照文件必须提交版本库,且快照变更要在 code review 中被专门审视。-u 本身不产生测试价值,它只是把"人确认过的变化"固化为新基准。
自定义序列化器:snapshotSerializers
默认序列化对大多数 JS 值够用,但仓库里若有自定义类(带 toJSON、Date 包装、自定义类实例等),可以在序列化器里接管打印方式:
expect.addSnapshotSerializer({
test(val) {
return val && typeof val.toJSON === 'function'
},
serialize(val, config, indentation, depth, refs, printer) {
return printer(val.toJSON(), config, indentation, depth, refs)
},
})
test 判断该值是否走此序列化器,serialize 负责实际输出(可复用 printer 处理嵌套)。也可以在 Vitest 配置中按文件注册,对全项目生效:
// vitest.config.ts
defineConfig({
test: {
snapshotSerializers: ['./my-serializer.ts'],
},
})
快照格式选项:snapshotFormat
全局调整快照的打印风格:
defineConfig({
test: {
snapshotFormat: {
printBasicPrototype: false, // 不打印 Array/Object 原型
escapeString: false,
},
},
})
printBasicPrototype 控制是否输出 Array [] / Object {} 这类原型标注,关掉后快照更紧凑;escapeString 控制字符串中的特殊字符转义方式。修改这类配置会让既有快照全部"失真",需要同步跑一次 vitest -u 重建基准——这也是它应写在配置里而不是散落在测试中的原因:全项目格式统一。
并发测试与上下文 expect
在并发(concurrent)测试中,全局 expect 的状态可能互相干扰,参考文档建议改用测试上下文注入的 expect:
test.concurrent('concurrent 1', async ({ expect }) => {
expect(await getData()).toMatchSnapshot()
})
test.concurrent('concurrent 2', async ({ expect }) => {
expect(await getOther()).toMatchSnapshot()
})
这与 Vitest 的测试上下文(fixtures / context)机制一致:从 test 的第二个参数解构出的 expect 绑定到当前测试实例,避免并发调度下的快照状态串扰。
快照文件位置:resolveSnapshotPath
默认位置是 __snapshots__/<测试文件名>.snap,本仓库两个快照文件(route.test.ts.snap、functions.test.ts.snap)均遵循该默认约定。若目录结构不同,可以用 resolveSnapshotPath 重写:
defineConfig({
test: {
resolveSnapshotPath: (testPath, snapExtension) => {
return testPath.replace('__tests__', '__snapshots__') + snapExtension
},
},
})
实践要点汇总
综合参考文档的 Key Points 与本仓库的落地方式,快照测试的完整实践清单如下:
- 提交快照文件进版本库——
.snap是测试基准的一部分,不能生成后丢弃; - 快照 diff 必须进 code review 流程——像 route.test.ts.snap 这样的 schema 快照,diff 即 API 变更说明;
- 一个测试多个快照时加 hints,让
.snap键名自解释; - 大输出用
toMatchFileSnapshot,保持.snap可审查; - 内联快照自动回写测试文件,适合小而稳的输出;
- 不稳定字段用形状匹配(
expect.any(Number)等)而非整体放宽; - 并发测试使用上下文
expect; - 更新走
vitest -u或 watch 模式u键,且必须在人确认变更合理之后执行。
本文全部内容以 .agents/skills/vitest/references/features-snapshots.md 为骨架,源码佐证均来自当前仓库内的实际测试文件与快照文件,可逐一打开核验。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00