bun test 入门
本教程共 34 篇 · 第 21 篇 · 更新于 2026-08-06
本节目标:
- 用
bun:test写出并运行第一个测试,理解「零配置即可跑 TypeScript 测试」是怎么做到的。- 掌握测试运行器的文件发现规则,知道哪些文件会被当作测试执行、哪些会被跳过。
- 熟练使用
describe/test/it组织用例,并掌握.skip、.todo、.only、.each等常用修饰符。- 掌握
bun test常用命令行参数:按文件过滤、按用例名过滤、超时、bail、重试、随机顺序。- 客观理解
bun test与 Jest 的兼容边界,知道迁移时需要动哪些地方。
21.1 测试运行器在 Bun 中的位置
Bun 的四大能力是运行时、打包器、包管理器和测试运行器。测试运行器不是一个需要额外安装的包,而是随 Bun 二进制一起分发的子命令 bun test。这意味着一个全新的目录里,只要装好了 Bun,写一个 .test.ts 文件就能直接跑测试:不需要 jest、ts-jest、babel-jest、@swc/jest 这类转译链,也不需要 jest.config.js。
这一点对 TypeScript 项目尤其省事。传统 Node.js 生态里,“让测试跑起来”这件事本身往往就要花掉半天:选一个测试框架,再选一个 TypeScript 转译方案,再处理 ESM/CJS 的模块格式冲突。而 bun test 直接在 Bun 运行时里执行测试文件,TypeScript 与 JSX 由 Bun 内置的转译器就地处理,ESM 与 CommonJS 都可以直接 import 或 require。
官方对这套 API 的定位很明确:Jest 兼容。测试函数、断言、生命周期钩子、mock,绝大多数写法与 Jest 一致。需要客观说明的是,Bun 的目标是完整的 Jest 兼容性,但目前并未 100% 实现,官方在仓库中维护着一个兼容性跟踪 issue(oven-sh/bun#1825)。所以从 Jest 迁移时,通常大部分文件可以原样跑通,少数用到冷门 API 的用例需要调整。
Note本章及后续四章的所有命令与 API 均以 Bun v1.3.14 为基线。Bun 的 1.3.x 小版本迭代很快,测试运行器几乎每个版本都有新增能力,遇到差异时以
bun --version对应的官方文档为准。
21.2 第一个测试
新建一个空目录,创建 math.test.ts:
import { expect, test } from "bun:test";
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});
运行:
bun test
输出大致如下:
bun test v1.3.14
math.test.ts:
✓ 2 + 2 [0.35ms]
1 pass
0 fail
1 expect() calls
Ran 1 test across 1 file. [42.00ms]
三个细节值得留意。
第一,导入来自 bun:test,这是 Bun 的内置模块前缀,和 bun:sqlite 同一套命名约定。它不在 node_modules 里,不需要安装,也不会出现在 package.json 的依赖列表中。
第二,测试文件是 .ts,我们没有做任何编译配置。Bun 在加载时完成 TypeScript 的类型擦除并直接执行,因此测试代码可以自由使用类型注解、泛型、satisfies 等语法。
第三,输出末尾统计了 expect() calls。这是 Bun 特有的一行信息,用于快速发现”测试跑了但一个断言都没执行”的空壳用例。
也可以不写 import
test、describe、expect、beforeAll 等函数同时以全局变量的形式注入到测试环境中,这与 Jest 的默认行为一致。因此下面这个文件同样可以运行:
test("全局函数也能用", () => {
expect([1, 2, 3]).toHaveLength(3);
});
显式 import 与依赖全局注入两种风格都被支持。推荐显式导入:一方面编辑器能给出准确的类型提示与跳转,另一方面阅读代码时能立刻看出这是 bun:test 而不是别的框架。如果项目里大量存量代码依赖全局注入(例如从 Jest 迁移过来),只需在项目中任意一个被 TypeScript 收录的文件里加一行三斜线指令,就能补齐全局函数的类型:
/// <reference types="bun-types/test-globals" />
通常把它放进项目根目录的 global.d.ts,或者放进测试的 preload 脚本里,全项目只需要写一次。
Tip从 Jest 迁移时,
import { test, expect } from "@jest/globals"这样的写法不必修改。Bun 会在内部把@jest/globals的导入重写为等价的bun:test导入。
21.3 测试文件是怎么被找到的
执行 bun test 时(不带任何参数),Bun 会从当前工作目录开始递归扫描,把路径匹配以下四类模式的文件当作测试文件:
*.test.{js|jsx|ts|tsx|mjs|cjs|mts|cts}*_test.{js|jsx|ts|tsx|mjs|cjs|mts|cts}*.spec.{js|jsx|ts|tsx|mjs|cjs|mts|cts}*_spec.{js|jsx|ts|tsx|mjs|cjs|mts|cts}
也就是说 user.test.ts、user_test.js、user.spec.tsx、user_spec.mts 都会被执行,而 user.ts、tests/helpers.ts 不会。
扫描时默认忽略三类路径:node_modules 目录、以点号开头的隐藏目录、以及扩展名不属于 JavaScript 家族(没有对应加载器)的文件。
如果项目里存在不希望被当作测试跑的 *.test.ts(例如 git 子模块、vendor 目录、专门存放的测试夹具),可以在 bunfig.toml 里配置 pathIgnorePatterns,Bun 在扫描阶段就会整棵目录剪枝,不会进去遍历:
[test]
pathIgnorePatterns = ["vendor/**", "submodules/**", "fixtures/**"]
还可以用 root 把扫描起点限制到某个子目录:
[test]
root = "src"
Warning命令行传入的
--path-ignore-patterns会整体覆盖bunfig.toml中的同名配置,两者不会合并。混用时容易出现”以为叠加了,其实丢了配置”的问题。
21.4 过滤要跑的测试
日常开发很少需要跑全量测试。bun test 提供了两个维度的过滤。
按文件路径过滤:位置参数被当作路径的子串匹配(不是 glob)。
# 跑路径中包含 utils 的所有测试文件
bun test utils
# 跑某个确切文件:路径要以 ./ 或 / 开头,否则会被当成过滤词
bun test ./test/specific-file.test.ts
按用例名过滤:用 -t / --test-name-pattern,值是正则。
bun test --test-name-pattern addition
bun test -t "^用户注册"
匹配的目标字符串是「所有外层 describe 标签 + 用例名」用空格拼接的结果。例如:
import { describe, test, expect } from "bun:test";
describe("Math", () => {
describe("operations", () => {
test("should add correctly", () => {
expect(1 + 1).toBe(2);
});
});
});
这个用例参与匹配的名字是 Math operations should add correctly,所以 -t operations 和 -t "add correctly" 都能命中它。
21.5 用 describe 组织测试
describe 把相关用例组成一个套件,可以任意嵌套。它带来三个实际收益:输出层级更清晰、名字匹配更好用(见上一节)、生命周期钩子可以按套件划定作用域(见第 25 章)。
import { describe, expect, test } from "bun:test";
function slugify(input: string) {
return input.trim().toLowerCase().replace(/\s+/g, "-");
}
describe("slugify", () => {
describe("正常输入", () => {
test("空格转短横线", () => {
expect(slugify("Hello Bun World")).toBe("hello-bun-world");
});
test("首尾空白被裁剪", () => {
expect(slugify(" Hello Bun ")).toBe("hello-bun");
});
});
describe("边界输入", () => {
test("空字符串返回空字符串", () => {
expect(slugify("")).toBe("");
});
test("连续空格折叠为一个短横线", () => {
expect(slugify("a b")).toBe("a-b");
});
});
});
it 是 test 的别名,两者完全等价。喜欢 BDD 风格(describe("slugify") + it("should ..."))的团队可以直接用 it,Bun 不做任何区分。
import { describe, it, expect } from "bun:test";
describe("slugify", () => {
it("should convert spaces to dashes", () => {
expect(slugify("a b")).toBe("a-b");
});
});
同一文件内,测试按定义顺序依次执行;文件之间默认在同一个进程、同一个全局环境里顺序执行。这个”单进程共享全局”的设计是 bun test 启动快的原因之一,代价是测试之间的隔离性弱于每文件一个环境的方案——若某个文件污染了全局状态,可能影响后续文件。需要更强隔离或想用满 CPU 核心时,可以加 --parallel,Bun 会把文件分发到多个 worker 进程,每个文件拿到一个全新的全局环境。
21.6 测试修饰符
修饰符挂在 test 或 describe 上,用来控制某个用例或整个套件是否执行、以什么方式执行。
跳过与待办:
import { test, expect } from "bun:test";
test.skip("暂时不修的用例", () => {
expect(0.1 + 0.2).toEqual(0.3);
});
test.todo("还没实现:批量导入", () => {
// 函数体可以留空,也可以先写着
});
test.skip 与 test.todo 都不会执行。区别在语义:skip 表示”这个用例暂时不跑”,todo 表示”这个功能还没做”。加上 --todo 参数可以把 todo 用例也跑一遍,如果某个 todo 用例居然通过了,Bun 会把它标记为失败并提示你去掉 .todo:
bun test --todo
只跑某几个:
test("test #1", () => {}); // 不跑
test.only("test #2", () => {}); // 跑
describe.only("only", () => {
test("test #3", () => {}); // 跑
});
配合 bun test --only 使用,只有被 .only 标记的用例会执行。
条件执行:test.if(cond) 条件成立才跑,test.skipIf(cond) 条件成立就跳过,test.todoIf(cond) 条件成立就标为待办。这三个修饰符在 describe 上同样可用,作用于整个套件。
import { test, describe } from "bun:test";
const isMacOS = process.platform === "darwin";
test.skipIf(process.platform === "win32")("依赖 POSIX 权限位", () => {
// Windows 上跳过
});
describe.if(isMacOS)("macOS 专属能力", () => {
test("feature A", () => {});
});
skipIf 与 todoIf 的选择体现意图差异:前者是”这个平台上本来就不适用”,后者是”计划支持但还没做”。
预期失败:test.failing() 会把结果反转——测试失败则视为通过,测试通过反而报错并提示”它已经能过了,请移除 .failing”。适合用来跟踪已知 bug。
test.failing("浮点数精度问题(已知 bug)", () => {
expect(0.1 + 0.2).toBe(0.3);
});
参数化:test.each 用同一段逻辑跑多组数据。
import { test, describe, expect } from "bun:test";
test.each([
[1, 2, 3],
[3, 4, 7],
])("add(%i, %i) = %i", (a, b, expected) => {
expect(a + b).toBe(expected);
});
test.each([
{ a: 1, b: 2, expected: 3 },
{ a: 4, b: 5, expected: 9 },
])("add($a, $b) = $expected", data => {
expect(data.a + data.b).toBe(data.expected);
});
规则是:数据行是数组时,元素被展开成多个参数;数据行是对象时,整行作为单个参数传入。标题里的占位符支持 %p(pretty-format)、%s、%d、%i、%f、%j、%o、%#(用例序号)和 %%;对象行还能用 $key 引用字段。describe.each 同理,生成的是参数化套件。
修饰符可以串联,例如 test.failing.each([...])("...", fn)。
21.7 常用命令行参数
# 监听文件变化自动重跑
bun test --watch
# 单个用例超时时间(毫秒),默认 5000
bun test --timeout 20000
# 失败 1 个就中止;也可以指定阈值
bun test --bail
bun test --bail=10
# 失败自动重试,最多 3 次;某次通过即记为通过
bun test --retry 3
# 每个用例重复跑 100 次,用来抓不稳定的用例
bun test --rerun-each 100
# 随机执行顺序,暴露用例之间的隐式依赖
bun test --randomize
# 用上次输出的种子复现同一顺序(--seed 隐含 --randomize)
bun test --seed 123456
# 把测试文件分发到多个 CPU 核心
bun test --parallel
# 只跑 .only 标记的用例
bun test --only
# 简洁的点状输出,适合大型套件
bun test --dots
其中 --timeout、--retry、--rerun-each、--randomize、--seed 等也可以写进 bunfig.toml 的 [test] 段落,命令行参数优先级更高:
[test]
retry = 3
rerunEach = 3
randomize = true
smol = true
单个用例也可以覆盖全局设置。超时作为第三个参数传入,重试与重复次数通过选项对象传入:
import { test, expect } from "bun:test";
test("慢操作", async () => {
const data = await slowOperation();
expect(data).toBe(42);
}, 500); // 必须在 500ms 内完成
test("可能抖动的网络请求", async () => {
const res = await fetch("https://example.com/api");
expect(res.ok).toBe(true);
}, { retry: 3 });
test("稳定性压测", () => {
expect(Math.random()).toBeLessThan(1);
}, { repeats: 20 }); // 共跑 21 次:1 次初始 + 20 次重复
Warning
retry和repeats不能同时用在同一个用例上。另外--timeout 0(或Infinity)表示不限时,用在确实无法预估耗时的用例上,不要图省事全局关掉超时。
超时触发时,Bun 抛出的是不可捕获的异常,强制中止该用例;同时它会顺手杀掉这个用例里用 Bun.spawn、Bun.spawnSync 或 node:child_process 启动、且仍在运行的子进程,避免留下僵尸进程。
21.8 与 Jest 的客观对比
Bun 的测试 API 直接对标 Jest,因此绝大部分心智可以平移。差异集中在下面几个方面。
| 维度 | bun test | Jest |
|---|---|---|
| 安装 | 随 Bun 内置,无需依赖 | 需安装 jest 及配套转译器 |
| TypeScript | 内置转译,开箱即用 | 需 ts-jest / babel-jest / @swc/jest |
| 配置文件 | bunfig.toml 的 [test] 段,可选 | jest.config.js,通常必需 |
| 导入来源 | bun:test(也支持全局注入、@jest/globals 重写) | @jest/globals 或全局注入 |
| 执行模型 | 默认单进程共享全局,--parallel 切换到多 worker | 默认多 worker,每文件独立环境 |
| 匹配器 | 覆盖常用匹配器,仍在补齐 | 完整 |
| 自动 mock | 不支持 __mocks__ 目录与自动 mock | 支持 |
| 假定时器 | jest.useFakeTimers() 等,自 v1.3.4 起可用 | 支持 |
需要说明的是,Jest 与 Vitest 都是成熟且功能完整的方案,生态插件、自定义 reporter、快照序列化器等方面积累更深。bun test 的优势主要在启动速度和零配置,特别适合已经在用 Bun 的项目,或者希望摆脱转译链配置负担的 TypeScript 项目。选型时按项目实际依赖的能力来判断,而不是只看基准数字。
从 Jest 迁移时,几个配置项的对应关系是:bail → --bail,collectCoverage → --coverage,testTimeout → --timeout,testEnvironment: "jsdom" → 用 happy-dom 通过 preload 注入浏览器 API(第 24 章会讲)。transform、extensionsToTreatAsEsm、haste、watchman 这些配置在 Bun 里没有对应项,因为 Bun 原生处理了 TypeScript/JSX 与模块格式,--watch 直接内置。
TipBun 还提供
vi对象作为 Vitest mock API 的别名(vi.fn、vi.spyOn、vi.mock等),从 Vitest 迁移过来的项目通常也不需要重写 mock 代码。
21.9 让测试跑在 CI 里
bun test 在失败时以非零退出码结束,因此可以直接接进任何 CI。在 GitHub Actions 里它还会自动识别环境并输出 Actions 注解,不需要额外配置:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test
如果 CI 平台读取 JUnit XML(GitLab、Jenkins 等),加两个参数即可,控制台输出不受影响:
bun test --reporter=junit --reporter-outfile=./bun.xml
还有一个容易被忽略的行为:bun test 会追踪测试之外发生的未处理错误和未处理的 Promise rejection。即使所有用例都通过,只要出现了这类错误,整轮测试依然以非零退出码结束。这能帮你抓到”测试跑完了但后台有个 Promise 悄悄炸了”的问题。
21.10 小结与常见误区
本章建立了使用 bun test 的基本工作流:写 *.test.ts、bun test 跑起来、用 describe 分组、用修饰符控制执行、用 CLI 参数缩小范围。
几个高频误区:
- 文件名不符合发现模式。
utils.ts里写了test()是不会被执行的,必须是utils.test.ts这类名字。 - 把位置参数当 glob。
bun test "src/**/*.test.ts"不会按预期工作,位置参数是子串匹配;要指定确切文件请用./开头的路径。 - 忘了
.only需要配合--only。只写test.only而不传--only,其他用例照样会跑。 - 提交了
.only或.skip。CI 上很容易漏跑一大片用例,建议在代码审查或 lint 规则里拦一道。 - 依赖用例执行顺序。同文件内确实按定义顺序执行,但依赖这一点会让测试变脆;用
--randomize定期检查是否存在隐式依赖。
下一章进入 expect 的世界,系统梳理 Bun 支持的匹配器、异步断言写法以及断言计数等技巧。