给前端明星开源项目Biome提 PR,被 Snapshot 测试坑了一把

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"
);

问题来了:

  1. 输出太长:这还是最简单的例子,真实场景可能是几十上百行代码
  2. 转义字符地狱\n\t 一多,根本没法读
  3. 改动频繁:格式化规则经常调整,比如"函数前要不要空行",一改就得手动更新几百个测试

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 测试的好处:

  1. 防止回归:改了一行代码,所有相关输出的变化都能看到
  2. 代码审查友好:PR 里能直接看到输出变化的 diff
  3. 文档作用:快照文件本身就是"这个输入应该产生什么输出"的文档
  4. 维护成本低:输出变了,一条命令更新,不用手动改几百个测试

一个真实的例子

我这次改的 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,介绍文章):

全栈项目(适合学习现代技术栈):

  • 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
相关推荐
SimonKing4 小时前
JetBrains 重大变革:IDEA 2025.3 统一发行版发布,告别选择困难
java·后端·程序员
૮・ﻌ・4 小时前
小兔鲜电商项目(一):项目准备、Layout模块、Home模块
前端·javascript·vue
dy17174 小时前
vue左右栏布局可拖拽
前端·css·html
zhougl9964 小时前
AJAX本质与核心概念
前端·javascript·ajax
GISer_Jing5 小时前
Taro跨端开发实战:核心原理与关键差异解析
前端·javascript·taro
无心使然云中漫步5 小时前
vant实现自定义日期时间选择器(年月日时分秒)
前端·vue
Rookie_explorers5 小时前
go私有仓库athens搭建
开发语言·后端·golang
极客先躯5 小时前
EasyUI + jQuery 自定义组件封装规范与项目结构最佳实践
前端·jquery·easyui
曲莫终5 小时前
spring.main.lazy-initialization配置的实现机制
java·后端·spring