Snapshot 测试:从一次 CI 失败说起
最近给 Biome 提了个 PR,改了几个文件,本地测试跑过了,push 上去,CI 挂了。
报错信息是这样的:
arduino
---- 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 是某个时刻程序输出的"照片"。
传统测试是这样写的:
rust
// 你得手动写出期望的输出
assert_eq!(format("let x=1"), "let x = 1;\n");
Snapshot 测试换了个思路:你不用写期望值,让程序自己记录。
arduino
第一次跑测试:
输入 → 程序 → 输出 → 自动保存成 .snap 文件(这就是"快照")
后续跑测试:
输入 → 程序 → 输出 → 跟 .snap 文件比对
│
├── 一样 → 通过
└── 不一样 → 失败,显示 diff
第一次运行时,测试框架会问你:"这个输出对吗?"你确认后,它就把输出存成 .snap 文件。之后每次运行,都拿新输出跟这个文件比。
为什么叫"快照"?
因为它记录的是某个时间点的状态。就像你给代码的输出拍了张照片,以后每次都拿新输出跟照片对比,看有没有变化。
Snapshot 文件存什么?
就是纯文本,记录程序的输出。比如格式化工具的 snapshot 存的是格式化后的代码,CLI 工具的 snapshot 存的是命令行输出,React 组件的 snapshot 存的是渲染出来的 DOM 结构。
我遇到的情况就是:上游改了规则的输出(多了一行 "This rule belongs to the nursery group"),但 .snap 文件还是旧的,新输出跟旧快照不一样,所以测试失败。
怎么修
Biome 用的是 Rust 生态的 insta 库做 snapshot 测试。修起来很简单:
bash
# 先本地跑一遍测试,确认是 snapshot 的问题
cargo test
# 接受新的 snapshot
cargo insta accept
# 或者用交互模式,逐个确认
cargo insta review
跑完 cargo insta accept,它会自动更新 .snap 文件。commit 进去,push,CI 就过了。
快照文件长什么样
看一眼 Biome 里的快照文件:
yaml
---
source: crates/biome_cli/tests/snap_test.rs
expression: content
---
## `biome.json`
```json
{ "files": { "includes": ["apps/**"] } }
Emitted Messages
block
function f() {
return {};
}
csharp
就是把测试的输入(配置文件)和输出(格式化结果)都记下来。下次跑测试,输出变了就能发现。
## 为什么 Biome 要用 snapshot 测试
先想一个问题:Biome 是做什么的?
代码格式化和 lint。输入一段代码,输出格式化后的代码,或者输出一堆 lint 警告。
这类工具有个特点:**输出又长又复杂,而且经常变。**
比如格式化 `function f(){return{}}` 这段代码,输出是:
```javascript
function f() {
return {};
}
如果用传统测试,你得这样写:
rust
assert_eq!(
format_code("function f(){return{}}"),
"function f() {\n\treturn {};\n}\n"
);
问题来了:
- 输出太长:这还是最简单的例子,真实场景可能是几十上百行代码
- 转义字符地狱 :
\n、\t一多,根本没法读 - 改动频繁:格式化规则经常调整,比如"函数前要不要空行",一改就得手动更新几百个测试
Biome 有几千个测试用例,如果每个都手写期望值,维护成本太高了。
Snapshot 测试解决了这个问题:
rust
// 不用写期望值,框架自动存
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 输入能不能正常格式化。
测试代码很简单:
rust
#[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!(...);
}
生成的快照文件:
yaml
---
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 功能:
javascript
test('Button 渲染正确', () => {
const tree = renderer.create(<Button label="点击" />).toJSON();
expect(tree).toMatchSnapshot();
});
更新快照:
bash
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