首页 / Bun 入门教程 / bun test 入门

Bun 入门教程

bun test 入门

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

Bunbun test测试运行器Jest 兼容describeTypeScript

本节目标:

  • 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 文件就能直接跑测试:不需要 jestts-jestbabel-jest@swc/jest 这类转译链,也不需要 jest.config.js

这一点对 TypeScript 项目尤其省事。传统 Node.js 生态里,“让测试跑起来”这件事本身往往就要花掉半天:选一个测试框架,再选一个 TypeScript 转译方案,再处理 ESM/CJS 的模块格式冲突。而 bun test 直接在 Bun 运行时里执行测试文件,TypeScript 与 JSX 由 Bun 内置的转译器就地处理,ESM 与 CommonJS 都可以直接 importrequire

官方对这套 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

testdescribeexpectbeforeAll 等函数同时以全局变量的形式注入到测试环境中,这与 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.tsuser_test.jsuser.spec.tsxuser_spec.mts 都会被执行,而 user.tstests/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");
    });
  });
});

ittest 的别名,两者完全等价。喜欢 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 测试修饰符

修饰符挂在 testdescribe 上,用来控制某个用例或整个套件是否执行、以什么方式执行。

跳过与待办

import { test, expect } from "bun:test";

test.skip("暂时不修的用例", () => {
  expect(0.1 + 0.2).toEqual(0.3);
});

test.todo("还没实现:批量导入", () => {
  // 函数体可以留空,也可以先写着
});

test.skiptest.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", () => {});
});

skipIftodoIf 的选择体现意图差异:前者是”这个平台上本来就不适用”,后者是”计划支持但还没做”。

预期失败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

retryrepeats 不能同时用在同一个用例上。另外 --timeout 0(或 Infinity)表示不限时,用在确实无法预估耗时的用例上,不要图省事全局关掉超时。

超时触发时,Bun 抛出的是不可捕获的异常,强制中止该用例;同时它会顺手杀掉这个用例里用 Bun.spawnBun.spawnSyncnode:child_process 启动、且仍在运行的子进程,避免留下僵尸进程。

21.8 与 Jest 的客观对比

Bun 的测试 API 直接对标 Jest,因此绝大部分心智可以平移。差异集中在下面几个方面。

维度bun testJest
安装随 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--bailcollectCoverage--coveragetestTimeout--timeouttestEnvironment: "jsdom" → 用 happy-dom 通过 preload 注入浏览器 API(第 24 章会讲)。transformextensionsToTreatAsEsmhastewatchman 这些配置在 Bun 里没有对应项,因为 Bun 原生处理了 TypeScript/JSX 与模块格式,--watch 直接内置。

Tip

Bun 还提供 vi 对象作为 Vitest mock API 的别名(vi.fnvi.spyOnvi.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.tsbun test 跑起来、用 describe 分组、用修饰符控制执行、用 CLI 参数缩小范围。

几个高频误区:

  • 文件名不符合发现模式utils.ts 里写了 test() 是不会被执行的,必须是 utils.test.ts 这类名字。
  • 把位置参数当 globbun test "src/**/*.test.ts" 不会按预期工作,位置参数是子串匹配;要指定确切文件请用 ./ 开头的路径。
  • 忘了 .only 需要配合 --only。只写 test.only 而不传 --only,其他用例照样会跑。
  • 提交了 .only.skip。CI 上很容易漏跑一大片用例,建议在代码审查或 lint 规则里拦一道。
  • 依赖用例执行顺序。同文件内确实按定义顺序执行,但依赖这一点会让测试变脆;用 --randomize 定期检查是否存在隐式依赖。

下一章进入 expect 的世界,系统梳理 Bun 支持的匹配器、异步断言写法以及断言计数等技巧。