Snapshot 测试:从一次 CI 失败说起
最近给 Biome 提了个 PR,改了几个文件,本地测试跑过了,push 上去,CI 挂了。
报错信息是这样的:
---- specs::nursery::no_undeclared_env_vars::invalid_js stdout ----
thread 'specs::nursery::no_undeclared_env_vars::invalid_js' panicked at
crates/biome_js_analyze/tests/spec_tests.rs:19:5:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Snapshot file: crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap
Snapshot: invalid_js
Source: crates/biome_js_analyze/tests/spec_tests.rs:19
-old snapshot
+new results
────────────────────────────────────────────────────────────────────────────────
一脸懵。我改的是 --stdin-file-path 的逻辑,跟 noUndeclaredEnvVars 这个 lint 规则有什么关系?
后来搞明白了:我的分支落后于 main,rebase 之后把上游的改动合进来了。上游给这个规则加了一行提示信息,但快照文件还是旧的,所以测试失败。
这就是 snapshot 测试。
什么是 snapshot 测试
Snapshot,直译就是"快照"。生活中的快照是某个时刻的照片,代码里的 snapshot 是某个时刻程序输出的"照片"。
传统测试是这样写的:
// 你得手动写出期望的输出
assert_eq!(format("let x=1"), "let x = 1;\n");
Snapshot 测试换了个思路:你不用写期望值,让程序自己记录。
第一次跑测试:
输入 → 程序 → 输出 → 自动保存成 .snap 文件(这就是"快照")
后续跑测试:
输入 → 程序 → 输出 → 跟 .snap 文件比对
│
├── 一样 → 通过
└── 不一样 → 失败,显示 diff
第一次运行时,测试框架会问你:"这个输出对吗?"你确认后,它就把输出存成 .snap 文件。之后每次运行,都拿新输出跟这个文件比。
为什么叫"快照"?
因为它记录的是某个时间点的状态。就像你给代码的输出拍了张照片,以后每次都拿新输出跟照片对比,看有没有变化。
Snapshot 文件存什么?
就是纯文本,记录程序的输出。比如格式化工具的 snapshot 存的是格式化后的代码,CLI 工具的 snapshot 存的是命令行输出,React 组件的 snapshot 存的是渲染出来的 DOM 结构。
我遇到的情况就是:上游改了规则的输出(多了一行 "This rule belongs to the nursery group"),但 .snap 文件还是旧的,新输出跟旧快照不一样,所以测试失败。
怎么修
Biome 用的是 Rust 生态的 insta 库做 snapshot 测试。修起来很简单:
# 先本地跑一遍测试,确认是 snapshot 的问题
cargo test
# 接受新的 snapshot
cargo insta accept
# 或者用交互模式,逐个确认
cargo insta review
跑完 cargo insta accept,它会自动更新 .snap 文件。commit 进去,push,CI 就过了。
快照文件长什么样
看一眼 Biome 里的快照文件:
---
source: crates/biome_cli/tests/snap_test.rs
expression: content
---
## `biome.json`
```json
{ "files": { "includes": ["apps/**"] } }
Emitted Messages
function f() {
return {};
}
就是把测试的输入(配置文件)和输出(格式化结果)都记下来。下次跑测试,输出变了就能发现。
## 为什么 Biome 要用 snapshot 测试
先想一个问题:Biome 是做什么的?
代码格式化和 lint。输入一段代码,输出格式化后的代码,或者输出一堆 lint 警告。
这类工具有个特点:**输出又长又复杂,而且经常变。**
比如格式化 `function f(){return{}}` 这段代码,输出是:
```javascript
function f() {
return {};
}
如果用传统测试,你得这样写:
assert_eq!(
format_code("function f(){return{}}"),
"function f() {\n\treturn {};\n}\n"
);
问题来了:
- 输出太长:这还是最简单的例子,真实场景可能是几十上百行代码
- 转义字符地狱:
\n、\t一多,根本没法读 - 改动频繁:格式化规则经常调整,比如"函数前要不要空行",一改就得手动更新几百个测试
Biome 有几千个测试用例,如果每个都手写期望值,维护成本太高了。
Snapshot 测试解决了这个问题:
// 不用写期望值,框架自动存
assert_snapshot!(format_code("function f(){return{}}"));
输出自动存到 .snap 文件里,下次运行自动比对。改了格式化规则?跑一遍 cargo insta accept,几千个快照一起更新。
哪些项目在用
不只是 Biome,很多知名项目都重度依赖 snapshot 测试:
| 项目 | 类型 | 为什么用 snapshot |
|---|---|---|
| Prettier | 代码格式化 | 输出是格式化后的代码,手写太累 |
| Babel | JS 编译器 | 输出是转换后的代码 |
| TypeScript | 类型检查 | 错误信息、类型推断结果 |
| Rome/Biome | 格式化+Lint | 格式化结果、诊断信息 |
| Jest | 测试框架 | React 组件渲染结果 |
| Rust Analyzer | IDE 支持 | 代码补全、悬停提示 |
| SWC | JS/TS 编译器 | AST、转换结果 |
这些项目有个共同点:输出是结构化的文本,而且量大。
用 snapshot 测试的好处:
- 防止回归:改了一行代码,所有相关输出的变化都能看到
- 代码审查友好:PR 里能直接看到输出变化的 diff
- 文档作用:快照文件本身就是"这个输入应该产生什么输出"的文档
- 维护成本低:输出变了,一条命令更新,不用手动改几百个测试
一个真实的例子
我这次改的 PR 加了一个测试用例,测试 stdin 输入能不能正常格式化。
测试代码很简单:
#[test]
fn format_stdin_formats_virtual_path_outside_includes() {
// 准备输入
console.in_buffer.push("function f() {return{}}".to_string());
// 运行格式化
let result = run_cli(
Args::from(["format", "--stdin-file-path", "mock.js"]),
);
// 快照测试
assert_cli_snapshot!(...);
}
生成的快照文件:
---
source: crates/biome_cli/tests/snap_test.rs
expression: content
---
## `biome.json`
{ "files": { "includes": ["apps/**"] } }
# Emitted Messages
function f() {
return {};
}
以后如果有人改了格式化逻辑,导致输出变了,测试就会失败,提醒你检查这个变化是不是预期的。
什么时候用
适合的场景:
| 场景 | 例子 |
|---|---|
| CLI 工具 | 帮助信息、错误消息、格式化输出 |
| 编译器/格式化器 | 代码转换结果、AST 输出 |
| UI 组件 | React/Vue 组件的渲染结果 |
| 序列化 | JSON、YAML 的输出 |
不太适合的场景:
- 简单断言(
1 + 1 == 2) - 随机/时间相关的输出
- 业务逻辑验证(snapshot 只能告诉你"变了",不能告诉你"对不对")
容易踩的坑
1. 无脑 accept
测试挂了,看都不看直接 cargo insta accept。这样 snapshot 测试就失去意义了——万一是真的 bug 呢?
我这次的情况比较明确,是 rebase 带进来的改动,所以直接 accept 没问题。但平时还是要看一眼 diff。
2. 忘了提交快照文件
本地跑测试没问题,CI 挂了。因为 .snap 文件没 commit。
3. 快照太大
有些项目喜欢把整个页面的渲染结果存成快照,几千行。每次 review 都是折磨,最后大家都无脑 accept 了。
建议拆小一点,每个快照保持在几十行内。
JavaScript 生态
JS 这边常用的是 Jest 的 snapshot 功能:
test('Button 渲染正确', () => {
const tree = renderer.create(<Button label="点击" />).toJSON();
expect(tree).toMatchSnapshot();
});
更新快照:
jest --updateSnapshot
# 或者
jest -u
原理一样,就是 API 不同。
小结
Snapshot 测试本质上是把"写期望值"这件事自动化了。对于输出复杂的场景(CLI、格式化器、UI 组件),能省不少事。
但记住:更新快照前要看一眼 diff,确认变化是你想要的。不然就跟我一样,rebase 完无脑 push,CI 一样会挂。
如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:
Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):
- code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则(详细介绍)
- 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
- first-principles-skill - 第一性原理思考,适合架构设计和技术选型
全栈项目(适合学习现代技术栈):
- prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
- chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB