快照与 DOM 测试
本教程共 34 篇 · 第 24 篇 · 更新于 2026-08-06
本节目标:
- 理解快照测试的原理:第一次运行写盘、之后运行做对比,并掌握
toMatchSnapshot/toMatchInlineSnapshot的更新方式。- 会用属性匹配器(
expect.any等)处理快照里随时间/随机变化的值,避免快照频繁「误报」。- 用
@happy-dom/global-registrator在测试里获得document/window等浏览器 API,并用 preload 统一启用。- 配合
/// <reference lib="dom" />解决 DOM 相关 TypeScript 报错。- 用 React Testing Library 做组件渲染与断言,形成可落地的组件测试思路。
24.1 什么是快照测试
快照测试的核心思路很朴素:把某个值的序列化结果保存下来,下次再跑时和磁盘上的那份做对比。它特别适合「输出结构稳定、但不方便逐字段手写断言」的场景——比如 UI 组件的 HTML 结构、复杂的嵌套对象、一段序列化后的报告。
Bun 的快照与 Jest 用法一致,统一通过 expect(value).toMatchSnapshot() 触发。
import { test, expect } from "bun:test";
test("snap", () => {
expect("foo").toMatchSnapshot();
});
第一次运行时,Bun 把参数序列化后写入测试文件同级的 __snapshots__ 目录:
your-project/
├── snap.test.ts
└── __snapshots__/
└── snap.test.ts.snap
快照文件内容形如:
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`snap 1`] = `"foo"`;
之后每次运行,Bun 都会把当前值跟磁盘上的快照比对,不一致就报错并给出 diff。
Note快照文件是普通文本,应当提交进版本控制。这样团队里任何人拉到代码后,都能立刻发现「这次改动是否无意间改变了某个组件的输出结构」。
24.2 更新快照
当你有意改变了输出(比如组件改版、新增字段),需要主动更新快照,而不是让测试一直红着:
bun test --update-snapshots
Warning永远不要在没看 diff 的情况下盲目
--update-snapshots。快照失败的真正原因往往是「代码被改坏了」而不是「快照过期」。正确流程是:先看git diff __snapshots__/理解变化,确认是有意为之再更新并提交。
24.3 内联快照
对较小的值,可以用 toMatchInlineSnapshot(),快照直接写进测试文件本身,无需单独的 .snap 文件:
import { test, expect } from "bun:test";
test("inline snapshot", () => {
// 第一次运行后,Bun 会自动把快照插入下面这行
expect({ hello: "world" }).toMatchInlineSnapshot();
});
运行一次后,Bun 自动把文件改成:
test("inline snapshot", () => {
expect({ hello: "world" }).toMatchInlineSnapshot(`
{
"hello": "world",
}
`);
});
内联快照的好处是「断言和期望值离得近,阅读成本低」,缺点是大对象会让测试文件变得冗长——此时更适合用独立的 .snap 文件。
24.4 错误快照与属性匹配器
错误也可以用快照锁定——toThrowErrorMatchingSnapshot() / ...InlineSnapshot():
test("error snapshot", () => {
expect(() => {
throw new Error("Something went wrong");
}).toThrowErrorMatchingSnapshot();
});
处理变化的值
快照最怕「每次都变的值」,比如时间戳、Math.random() 产生的 id。把这些值原样快照,会导致每次运行都失败。解决办法是属性匹配器(asymmetric matcher):
test("snapshot with dynamic values", () => {
const user = {
id: Math.random(), // 每次都变
name: "John",
createdAt: new Date().toISOString(), // 每次都变
};
expect(user).toMatchSnapshot({
id: expect.any(Number),
createdAt: expect.any(String),
});
});
快照里对应位置会被记成 Any<Number> / Any<String>,只校验「类型对得上」,不校验具体值。另一种做法是先手动把变化值替换成固定占位符再快照:
test("API response format", () => {
const response = getApiResponse();
expect(response).toMatchSnapshot({
timestamp: expect.any(Number),
requestId: expect.any(String),
});
});
Tip快照「小而美」最好:聚焦一个明确的输出(如「格式化后的货币字符串」),不要对整个页面或整棵组件树一拍了之。巨型快照既难审阅,也极易因为无关改动而大范围失败。
快照命名、CI 与常见坑
每个 toMatchSnapshot() 调用会在 .snap 文件里生成一个以「用例名 + 序号」为键的条目,例如前面看到的 exports[\snap 1`]。同一个用例里连续写多个快照,会按调用顺序编号为 1、2……;一旦你改名或删除某个用例,旧条目就变成「孤立快照」,需要手动删掉,或再跑一次 —update-snapshots` 重建。
在 CI 里建议加 --ci:它让 bun test 遇到「还没有快照的新用例」时直接失败,而不是默默新建快照。这能防止有人漏提交 .snap 文件、却靠 CI 自动生成蒙混过关。
两个容易被忽略的坑:
- 对象键顺序:快照比对的是序列化后的字符串。如果被测对象用
Map、或依赖插入顺序拼装,键顺序一变快照就红。稳定起见,先排序或转成固定结构的纯对象再快照。 - 巨型快照难审阅:对整棵组件树一拍了之,别人 review 时根本看不出「这次改了什么」。遵循「小而美」:只快照一个明确的输出(如格式化后的字符串、某个子树的 HTML),其余交互逻辑用显式
expect断言兜底。
24.5 DOM 测试:用 happy-dom 提供浏览器环境
很多前端代码依赖 document、window、customElements 等浏览器 API。Bun 作为服务端运行时本身没有这些全局对象,因此需要引入一个「无头 DOM 实现」。Bun 官方推荐 happy-dom,它用纯 JavaScript 实现了相当完整的 HTML / DOM API。
先装为开发依赖:
bun add -d @happy-dom/global-registrator
然后在项目根目录建一个 happydom.ts,把 happy-dom 的全局对象注册进测试环境:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
通过 preload 让它在测试运行前生效(避免每个文件重复写):
[test]
preload = ["./happydom.ts"]
之后测试里就能直接用 document:
import { test, expect } from "bun:test";
test("dom test", () => {
document.body.innerHTML = `<button>My button</button>`;
const button = document.querySelector("button");
expect(button?.innerText).toEqual("My button");
});
解决 TypeScript 报错
如果你的 tsconfig.json 没包含 DOM 库,编辑器可能会对 document 报「找不到名称」。在测试文件顶部加一行三斜杠指令即可:
/// <reference lib="dom" />
import { test, expect } from "bun:test";
test("dom test", () => {
document.body.innerHTML = `<button>My button</button>`;
const button = document.querySelector("button");
expect(button?.innerText).toEqual("My button");
});
24.6 组件测试:React Testing Library
happy-dom 解决的是「浏览器环境」,而 React Testing Library(RTL)解决的是「如何自然地渲染与查询组件」。两者配合是 Bun 下做组件测试的主流组合。
bun add -d @testing-library/react @testing-library/jest-dom
/// <reference lib="dom" />
import { test, expect } from "bun:test";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import "@testing-library/jest-dom";
function Button({ children }: { children: ReactNode }) {
return <button>{children}</button>;
}
test("renders button", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button")).toHaveTextContent("Click me");
});
@testing-library/jest-dom 提供了一组语义化匹配器(toHaveTextContent、toBeInTheDocument 等),让断言读起来更像自然语言。@testing-library/react 的 render 会把组件挂到 happy-dom 提供的 document 上,再用 screen 按角色、文本、标签等查询节点——这种「以用户视角查询」的方式,比直接断言 innerHTML 更稳健,因为组件内部 DOM 结构的微小改动通常不会影响「按钮上的文字」这一用户可见行为。
一个更完整的组件快照示例
import { test, expect } from "bun:test";
import { render } from "@testing-library/react";
function Button({ children, variant = "primary" }) {
return <button className={`btn btn-${variant}`}>{children}</button>;
}
test("Button component snapshots", () => {
const { container: primary } = render(<Button>Click me</Button>);
const { container: secondary } = render(<Button variant="secondary">Cancel</Button>);
expect(primary.innerHTML).toMatchSnapshot();
expect(secondary.innerHTML).toMatchSnapshot();
});
24.7 组件测试的整体思路
把前面的能力串起来,一个可落地的组件测试套路是:
- 环境层:用
preload注册 happy-dom,并在 preload 里补上项目需要但 happy-dom 未默认提供的全局(如ResizeObserver、matchMedia),同时引入@testing-library/jest-dom。 - 清理层:在 preload 或全局
afterEach里调用 RTL 的cleanup()并重置document.body.innerHTML,保证每个测试之间 DOM 干净。 - 测试层:用
render+screen渲染并查询,用fireEvent/userEvent模拟交互,用expect做断言;需要锁定结构时用toMatchSnapshot,需要隔离依赖时用mock()/spyOn/mock.module()。 - 异步层:组件里的数据请求、定时器用前面章节讲过的异步断言与
jest.useFakeTimers()控制。
常见问题
必须用 happy-dom 吗? 不一定。Bun 官方文档以 happy-dom 为推荐方案,因为它用纯 JS 实现、启动快。linkedom、jsdom 也能跑,但 jsdom 体积更大、更慢,通常只在 happy-dom 缺某个 API 时才退而求其次。
RTL 的 cleanup 一定要手动调吗? 多数情况下要。RTL 在真实浏览器里靠 afterEach 自动清理,而在 happy-dom 下没有这个自动钩子,所以把 cleanup() 放进 preload 的 afterEach 是最稳的做法,能避免「上一个测试的 DOM 泄漏到下一个测试」。
组件测到什么程度? 只测「用户可见的行为」:文字、角色、点击后的反馈。不要去断言内部 useState 的值或私有方法——那会让测试跟实现绑死,组件一重构就全红。
// test-setup.ts
import { afterEach } from "bun:test";
import { cleanup } from "@testing-library/react";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import "@testing-library/jest-dom";
GlobalRegistrator.register();
afterEach(() => {
cleanup();
document.body.innerHTML = "";
});
[test]
preload = ["./test-setup.ts"]
Tip自定义元素 / Web Component 也可以用同样的方式测:定义
class extends HTMLElement,customElements.define后渲染到document.body,再查询断言。happy-dom 对自定义元素的支持足以覆盖大多数交互逻辑测试。
24.8 小结与误区
- 快照适合「结构稳定、不便逐字段断言」的输出;更新前务必看 diff。
- 变化的值用属性匹配器(
expect.any(...))或先归一化再快照,别让它每次都「误报」。 - DOM 测试靠 happy-dom 提供浏览器 API,用
preload统一启用,用/// <reference lib="dom" />补类型。 - 组件测试 = happy-dom(环境)+ React Testing Library(渲染/查询)+ expect(断言)+ mock(隔离依赖)。
Warning不要过度依赖快照:当组件结构频繁变动时,巨型快照会成为维护负担,且「快照过了」并不等于「行为正确」——它只证明输出没变。关键交互逻辑仍应写明确的
expect断言。