编写测试
使用 Bun 兼容 Jest 的 API 编写测试,包括异步测试、超时和测试修饰符
使用从内置 bun:test 模块导入的类 Jest API 定义测试。从长远来看,Bun 的目标是完全兼容 Jest。
基本用法
定义测试:
import { expect, test } from "bun:test";
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});
测试分组
使用 describe 将测试分组到测试套件中。
import { expect, test, describe } from "bun:test";
describe("算术", () => {
test("2 + 2", () => {
expect(2 + 2).toBe(4);
});
test("2 * 2", () => {
expect(2 * 2).toBe(4);
});
});
异步测试
测试可以是异步的。
import { expect, test } from "bun:test";
test("2 * 2", async () => {
const result = await Promise.resolve(2 * 2);
expect(result).toEqual(4);
});
或者,使用 done 回调来表示完成。如果测试函数接收 done 参数,则必须调用它,否则测试会一直挂起。
import { expect, test } from "bun:test";
test("2 * 2", done => {
Promise.resolve(2 * 2).then(result => {
expect(result).toEqual(4);
done();
});
});
超时
可通过给 test 传入第三个参数数字来指定单个测试的超时时间(毫秒)。
import { expect, test } from "bun:test";
test("wat", async () => {
const data = await slowOperation();
expect(data).toBe(42);
}, 500); // 测试必须在 <500ms 内完成
在 bun:test 中,超时会抛出无法捕获的异常,以强制测试停止运行并失败。Bun 还会终止测试中生成的所有子进程,因此它们不会作为僵尸进程残留。
除非使用此超时选项或 jest.setTimeout() 覆盖,否则每个测试的默认超时时间为 5000ms(5 秒)。
重试和重复执行
test.retry
使用 retry 选项可在不稳定的测试失败时自动重试。测试在指定的尝试次数内成功即可通过。
import { expect, test } from "bun:test";
test(
"不稳定的网络请求",
async () => {
const response = await fetch("https://example.com/api");
expect(response.ok).toBe(true);
},
{ retry: 3 }, // 失败时最多重试3次
);
test.repeats
使用 repeats 选项可多次运行测试,而不论测试通过还是失败;如果任何一次迭代失败,测试就会失败。可使用此选项检测不稳定的测试或进行压力测试。repeats: N 总共运行测试 N+1 次(1 次初始运行 + N 次重复运行)。
import { expect, test } from "bun:test";
test(
"确保测试稳定",
() => {
expect(Math.random()).toBeLessThan(1);
},
{ repeats: 20 }, // 总计运行21次(1次初始 + 20次重复)
);
🧟 僵尸进程清理器
当测试超时时,Bun 会终止测试使用 Bun.spawn、Bun.spawnSync 或 node:child_process 生成的所有仍在运行的进程,并在控制台中记录一条消息。这可以防止超时测试结束后留下僵尸进程。
测试修饰符
test.skip
使用 test.skip 跳过单个测试。Bun 不会运行这些测试。
import { expect, test } from "bun:test";
test.skip("wat", () => {
// TODO: 修复此处
expect(0.1 + 0.2).toEqual(0.3);
});
test.todo
使用 test.todo 将测试标记为待办事项。Bun 不会运行这些测试。
import { expect, test } from "bun:test";
test.todo("修复此处", () => {
myTestFunction();
});
要运行待办测试并找出其中通过的测试,请使用 bun test --todo。当 myTestFunction() 正常工作后,Bun 会将上面的测试报告为失败。
bun test --todo
math.test.ts:
✗ fix this
^ this test is marked as todo but passes. Remove `.todo` if tested behavior now works
0 pass
1 fail
使用此标志时,失败的待办测试不会导致错误,但 Bun 会将通过的待办测试标记为失败,以便你移除待办标记或修复测试。
test.only
要运行特定测试或测试套件,请使用 test.only() 或 describe.only()。
import { test, describe } from "bun:test";
test("测试 #1", () => {
// 不运行
});
test.only("测试 #2", () => {
// 运行
});
describe.only("仅此", () => {
test("测试 #3", () => {
// 运行
});
});
以下命令只运行测试 #2 和 #3。
bun test --only
test.if
要有条件地运行测试,请使用 test.if()。当条件为真值时,测试才会运行。对于仅应在特定架构或操作系统上运行的测试,可以使用它。
test.if(Math.random() > 0.5)("有一半概率运行", () => {
// ...
});
const macOS = process.platform === "darwin";
test.if(macOS)("仅在 macOS 运行", () => {
// 仅在 macOS 运行
});
test.skipIf
条件跳过测试,使用 test.skipIf() 或 describe.skipIf()。
const macOS = process.platform === "darwin";
test.skipIf(macOS)("仅在非 macOS 运行", () => {
// 在非 macOS 运行
});
test.todoIf
要改为将测试标记为待办事项,请使用 test.todoIf() 或 describe.todoIf()。选择 skipIf 还是 todoIf 表明了不同意图:“对该目标无效”与“计划实现但尚未实现”。
const macOS = process.platform === "darwin";
// TODO: 目前只在 Linux 上实现。
test.todoIf(macOS)("运行在 posix 系统上", () => {
// 在非 macOS 运行
});
test.failing
当你知道某个测试会失败,但希望跟踪它并在它开始通过时收到通知,请使用 test.failing()。这会反转测试结果:
- 标记了
.failing()的失败测试会通过 - 标记了
.failing()的通过测试会失败,并显示它现在已经通过、应当进行修复的消息
// 因测试故意失败,这里会通过
test.failing("数学有问题", () => {
expect(0.1 + 0.2).toBe(0.3); // 由于浮点精度失败
});
// 测试通过,但期望失败,因此测试失败并提示修复
test.failing("修复了 bug", () => {
expect(1 + 1).toBe(2); // 通过,但我们预期失败
});
可以使用它跟踪计划稍后修复的已知 bug,或用于测试驱动开发。
describe 块中的条件测试
条件修饰符 .if()、.skipIf() 和 .todoIf() 同样适用于 describe 块,会影响套件中的所有测试:
const isMacOS = process.platform === "darwin";
// 仅在 macOS 上运行整个套件
describe.if(isMacOS)("macOS 特有功能", () => {
test("特性 A", () => {
// 仅在 macOS 运行
});
test("特性 B", () => {
// 仅在 macOS 运行
});
});
// 在 Windows 上跳过整个套件
describe.skipIf(process.platform === "win32")("Unix 功能", () => {
test("特性 C", () => {
// Windows 上跳过
});
});
// 在 Linux 上将整个套件标记为 TODO
describe.todoIf(process.platform === "linux")("即将支持 Linux", () => {
test("特性 D", () => {
// Linux 上标记为 TODO
});
});
参数化测试
test.each 和 describe.each
使用 test.each 可针对多个数据集运行相同测试。每个测试用例都会运行一次。
const cases = [
[1, 2, 3],
[3, 4, 7],
];
test.each(cases)("%p + %p 应该是 %p", (a, b, expected) => {
expect(a + b).toBe(expected);
});
describe.each 创建一个参数化测试套件,每个测试用例运行一次:
describe.each([
[1, 2, 3],
[3, 4, 7],
])("add(%i, %i)", (a, b, expected) => {
test(`返回 ${expected}`, () => {
expect(a + b).toBe(expected);
});
test(`结果大于每个值`, () => {
expect(a + b).toBeGreaterThan(a);
expect(a + b).toBeGreaterThan(b);
});
});
参数传递
Bun 如何向测试函数传递参数,取决于测试用例的结构:
- 如果表格行是数组(例如
[1, 2, 3]),Bun 会将每个元素作为单独的参数传递 - 如果行不是数组(例如对象),Bun 会将其作为单个参数传递
// 数组项作为单独参数传入
test.each([
[1, 2, 3],
[4, 5, 9],
])("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 | JSON |
%o | 对象 |
%# | 测试用例索引 |
%% | 单个百分号 (%) |
示例
// 基本占位符
test.each([
["hello", 123],
["world", 456],
])("字符串: %s, 数字: %i", (str, num) => {
// "字符串: hello, 数字: 123"
// "字符串: world, 数字: 456"
});
// %p 用于 pretty-format 输出
test.each([
[{ name: "Alice" }, { a: 1, b: 2 }],
[{ name: "Bob" }, { x: 5, y: 10 }],
])("用户 %p,数据 %p", (user, data) => {
// "用户 { name: 'Alice' },数据 { a: 1, b: 2 }"
// "用户 { name: 'Bob' },数据 { x: 5, y: 10 }"
});
// %# 用于索引
test.each(["apple", "banana"])("水果 #%# 是 %s", fruit => {
// "水果 #0 是 apple"
// "水果 #1 是 banana"
});
断言计数
Bun 支持验证测试期间调用的断言数量:
expect.hasAssertions()
使用 expect.hasAssertions() 验证测试中至少调用过一次断言:
test("异步工作调用断言", async () => {
expect.hasAssertions(); // 如果没有断言调用,测试失败
const data = await fetchData();
expect(data).toBeDefined();
});
expect.hasAssertions() 在异步测试中特别有用,可确保断言确实运行。
expect.assertions(count)
使用 expect.assertions(count) 验证测试中调用了指定数量的断言:
test("恰好两个断言", () => {
expect.assertions(2); // 如果断言不是精确2个则失败
expect(1 + 1).toBe(2);
expect("hello").toContain("ell");
});
expect.assertions(count) 有助于确保所有断言都运行,尤其是在包含多个代码路径的复杂异步代码中。
类型测试
Bun 包含用于测试 TypeScript 类型的 expectTypeOf,兼容 Vitest。
expectTypeOf
expectTypeOf 函数提供类型级断言,由 TypeScript 类型检查器验证。要测试类型:
- 使用
expectTypeOf编写类型断言 - 运行
bunx tsc --noEmit检查类型是否正确
import { expectTypeOf } from "bun:test";
// 基本类型断言
expectTypeOf<string>().toEqualTypeOf<string>();
expectTypeOf(123).toBeNumber();
expectTypeOf("hello").toBeString();
// 对象类型匹配
expectTypeOf({ a: 1, b: "hello" }).toMatchObjectType<{ a: number }>();
// 函数类型
function greet(name: string): string {
return `Hello ${name}`;
}
expectTypeOf(greet).toBeFunction();
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>();
expectTypeOf(greet).returns.toEqualTypeOf<string>();
// 数组类型
expectTypeOf([1, 2, 3]).items.toBeNumber();
// Promise 类型
expectTypeOf(Promise.resolve(42)).resolves.toBeNumber();
有关 expectTypeOf 匹配器的完整文档,请参阅 API 参考。
匹配器
Bun 实现了以下匹配器。计划实现完整的 Jest 兼容性;请参阅跟踪 issue。
基本匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .not |
| ✅ | .toBe() |
| ✅ | .toEqual() |
| ✅ | .toBeNull() |
| ✅ | .toBeUndefined() |
| ✅ | .toBeNaN() |
| ✅ | .toBeDefined() |
| ✅ | .toBeFalsy() |
| ✅ | .toBeTruthy() |
| ✅ | .toStrictEqual() |
字符串和数组匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toContain() |
| ✅ | .toHaveLength() |
| ✅ | .toMatch() |
| ✅ | .toContainEqual() |
| ✅ | .stringContaining() |
| ✅ | .stringMatching() |
| ✅ | .arrayContaining() |
对象匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toHaveProperty() |
| ✅ | .toMatchObject() |
| ✅ | .toContainAllKeys() |
| ✅ | .toContainValue() |
| ✅ | .toContainValues() |
| ✅ | .toContainAllValues() |
| ✅ | .toContainAnyValues() |
| ✅ | .objectContaining() |
数字匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toBeCloseTo() |
| ✅ | .closeTo() |
| ✅ | .toBeGreaterThan() |
| ✅ | .toBeGreaterThanOrEqual() |
| ✅ | .toBeLessThan() |
| ✅ | .toBeLessThanOrEqual() |
函数和类匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toThrow() |
| ✅ | .toBeInstanceOf() |
Promise 匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .resolves() |
| ✅ | .rejects() |
模拟函数匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toHaveBeenCalled() |
| ✅ | .toHaveBeenCalledTimes() |
| ✅ | .toHaveBeenCalledWith() |
| ✅ | .toHaveBeenLastCalledWith() |
| ✅ | .toHaveBeenNthCalledWith() |
| ✅ | .toHaveReturned() |
| ✅ | .toHaveReturnedTimes() |
| ✅ | .toHaveReturnedWith() |
| ✅ | .toHaveLastReturnedWith() |
| ✅ | .toHaveNthReturnedWith() |
快照匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .toMatchSnapshot() |
| ✅ | .toMatchInlineSnapshot() |
| ✅ | .toThrowErrorMatchingSnapshot() |
| ✅ | .toThrowErrorMatchingInlineSnapshot() |
工具匹配器
| 状态 | 匹配器 |
|---|---|
| ✅ | .extend |
| ✅ | .anything() |
| ✅ | .any() |
| ✅ | .assertions() |
| ✅ | .hasAssertions() |
未实现
| 状态 | 匹配器 |
|---|---|
| ❌ | .addSnapshotSerializer() |
最佳实践
使用描述性的测试名称
// 好的示例
test("应计算多个商品含税总价", () => {
// 测试实现
});
// 避免
test("价格计算", () => {
// 测试实现
});
分组相关测试
describe("用户认证", () => {
describe("有效凭证时", () => {
test("应返回用户数据", () => {
// 测试实现
});
test("应设置认证令牌", () => {
// 测试实现
});
});
describe("无效凭证时", () => {
test("应抛出认证错误", () => {
// 测试实现
});
});
});
使用合适的匹配器
// 好的示例:使用具体匹配器
expect(users).toHaveLength(3);
expect(user.email).toContain("@");
expect(response.status).toBeGreaterThanOrEqual(200);
// 避免:将逻辑表达式用 toBe 包裹
expect(users.length === 3).toBe(true);
expect(user.email.includes("@")).toBe(true);
expect(response.status >= 200).toBe(true);
测试错误情况
test("无效输入应抛出错误", () => {
expect(() => {
validateEmail("not-an-email");
}).toThrow("邮箱格式无效");
});
test("应处理异步错误", async () => {
await expect(async () => {
await fetchUser("invalid-id");
}).rejects.toThrow("未找到用户");
});
使用初始化和清理
import { beforeEach, afterEach, test } from "bun:test";
let testUser;
beforeEach(() => {
testUser = createTestUser();
});
afterEach(() => {
cleanupTestUser(testUser);
});
test("应更新用户资料", () => {
// 在测试中使用 testUser
});