首页 / Bun 入门教程 / 快照与 DOM 测试

Bun 入门教程

快照与 DOM 测试

本教程共 34 篇 · 第 24 篇 · 更新于 2026-08-06

Bunbun test测试运行器snapshotDOMhappy-domReact Testing Library

本节目标:

  • 理解快照测试的原理:第一次运行写盘、之后运行做对比,并掌握 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`]。同一个用例里连续写多个快照,会按调用顺序编号为 12……;一旦你改名或删除某个用例,旧条目就变成「孤立快照」,需要手动删掉,或再跑一次 —update-snapshots` 重建。

在 CI 里建议加 --ci:它让 bun test 遇到「还没有快照的新用例」时直接失败,而不是默默新建快照。这能防止有人漏提交 .snap 文件、却靠 CI 自动生成蒙混过关。

两个容易被忽略的坑:

  • 对象键顺序:快照比对的是序列化后的字符串。如果被测对象用 Map、或依赖插入顺序拼装,键顺序一变快照就红。稳定起见,先排序或转成固定结构的纯对象再快照。
  • 巨型快照难审阅:对整棵组件树一拍了之,别人 review 时根本看不出「这次改了什么」。遵循「小而美」:只快照一个明确的输出(如格式化后的字符串、某个子树的 HTML),其余交互逻辑用显式 expect 断言兜底。

24.5 DOM 测试:用 happy-dom 提供浏览器环境

很多前端代码依赖 documentwindowcustomElements 等浏览器 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 提供了一组语义化匹配器(toHaveTextContenttoBeInTheDocument 等),让断言读起来更像自然语言。@testing-library/reactrender 会把组件挂到 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 组件测试的整体思路

把前面的能力串起来,一个可落地的组件测试套路是:

  1. 环境层:用 preload 注册 happy-dom,并在 preload 里补上项目需要但 happy-dom 未默认提供的全局(如 ResizeObservermatchMedia),同时引入 @testing-library/jest-dom
  2. 清理层:在 preload 或全局 afterEach 里调用 RTL 的 cleanup() 并重置 document.body.innerHTML,保证每个测试之间 DOM 干净。
  3. 测试层:用 render + screen 渲染并查询,用 fireEvent / userEvent 模拟交互,用 expect 做断言;需要锁定结构时用 toMatchSnapshot,需要隔离依赖时用 mock() / spyOn / mock.module()
  4. 异步层:组件里的数据请求、定时器用前面章节讲过的异步断言与 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 HTMLElementcustomElements.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 断言。