运行时包管理器打包器测试运行器代码检查格式化指南参考博客反馈

编写测试

使用 Bun 兼容 Jest 的 API 编写测试,包括异步测试、超时和测试修饰符

使用从内置 bun:test 模块导入的类 Jest API 定义测试。从长远来看,Bun 的目标是完全兼容 Jest。

基本用法

定义测试:

math.test.ts
import { expect, test } from "bun:test";

test("2 + 2", () => {
  expect(2 + 2).toBe(4);
});

测试分组

使用 describe 将测试分组到测试套件中。

math.test.ts
import { expect, test, describe } from "bun:test";

describe("算术", () => {
  test("2 + 2", () => {
    expect(2 + 2).toBe(4);
  });

  test("2 * 2", () => {
    expect(2 * 2).toBe(4);
  });
});

异步测试

测试可以是异步的。

math.test.ts
import { expect, test } from "bun:test";

test("2 * 2", async () => {
  const result = await Promise.resolve(2 * 2);
  expect(result).toEqual(4);
});

或者,使用 done 回调来表示完成。如果测试函数接收 done 参数,则必须调用它,否则测试会一直挂起。

math.test.ts
import { expect, test } from "bun:test";

test("2 * 2", done => {
  Promise.resolve(2 * 2).then(result => {
    expect(result).toEqual(4);
    done();
  });
});

超时

可通过给 test 传入第三个参数数字来指定单个测试的超时时间(毫秒)。

math.test.ts
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 选项可在不稳定的测试失败时自动重试。测试在指定的尝试次数内成功即可通过。

example.test.ts
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 次重复运行)。

example.test.ts
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 不会运行这些测试。

math.test.ts
import { expect, test } from "bun:test";

test.skip("wat", () => {
  // TODO: 修复此处
  expect(0.1 + 0.2).toEqual(0.3);
});

test.todo

使用 test.todo 将测试标记为待办事项。Bun 不会运行这些测试。

math.test.ts
import { expect, test } from "bun:test";

test.todo("修复此处", () => {
  myTestFunction();
});

要运行待办测试并找出其中通过的测试,请使用 bun test --todo。当 myTestFunction() 正常工作后,Bun 会将上面的测试报告为失败。

terminal
bun test --todo
Code
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()。

example.test.ts
import { test, describe } from "bun:test";

test("测试 #1", () => {
  // 不运行
});

test.only("测试 #2", () => {
  // 运行
});

describe.only("仅此", () => {
  test("测试 #3", () => {
    // 运行
  });
});

以下命令只运行测试 #2 和 #3。

terminal
bun test --only

test.if

要有条件地运行测试,请使用 test.if()。当条件为真值时,测试才会运行。对于仅应在特定架构或操作系统上运行的测试,可以使用它。

example.test.ts
test.if(Math.random() > 0.5)("有一半概率运行", () => {
  // ...
});

const macOS = process.platform === "darwin";
test.if(macOS)("仅在 macOS 运行", () => {
  // 仅在 macOS 运行
});

test.skipIf

条件跳过测试,使用 test.skipIf() 或 describe.skipIf()。

example.test.ts
const macOS = process.platform === "darwin";

test.skipIf(macOS)("仅在非 macOS 运行", () => {
  // 在非 macOS 运行
});

test.todoIf

要改为将测试标记为待办事项,请使用 test.todoIf() 或 describe.todoIf()。选择 skipIf 还是 todoIf 表明了不同意图:“对该目标无效”与“计划实现但尚未实现”。

example.test.ts
const macOS = process.platform === "darwin";

// TODO: 目前只在 Linux 上实现。
test.todoIf(macOS)("运行在 posix 系统上", () => {
  // 在非 macOS 运行
});

test.failing

当你知道某个测试会失败,但希望跟踪它并在它开始通过时收到通知,请使用 test.failing()。这会反转测试结果:

  • 标记了 .failing() 的失败测试会通过
  • 标记了 .failing() 的通过测试会失败,并显示它现在已经通过、应当进行修复的消息
math.test.ts
// 因测试故意失败,这里会通过
test.failing("数学有问题", () => {
  expect(0.1 + 0.2).toBe(0.3); // 由于浮点精度失败
});

// 测试通过,但期望失败,因此测试失败并提示修复
test.failing("修复了 bug", () => {
  expect(1 + 1).toBe(2); // 通过,但我们预期失败
});

可以使用它跟踪计划稍后修复的已知 bug,或用于测试驱动开发。

describe 块中的条件测试

条件修饰符 .if()、.skipIf() 和 .todoIf() 同样适用于 describe 块,会影响套件中的所有测试:

example.test.ts
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 可针对多个数据集运行相同测试。每个测试用例都会运行一次。

math.test.ts
const cases = [
  [1, 2, 3],
  [3, 4, 7],
];

test.each(cases)("%p + %p 应该是 %p", (a, b, expected) => {
  expect(a + b).toBe(expected);
});

describe.each 创建一个参数化测试套件,每个测试用例运行一次:

sum.test.ts
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 会将其作为单个参数传递
example.test.ts
// 数组项作为单独参数传入
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);
});

格式化占位符

使用以下格式说明符来格式化测试标题:

占位符描述
%ppretty-format 美化输出
%s字符串
%d数字
%i整数
%f浮点数
%jJSON
%o对象
%#测试用例索引
%%单个百分号 (%)

示例

example.test.ts
// 基本占位符
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() 验证测试中至少调用过一次断言:

example.test.ts
test("异步工作调用断言", async () => {
  expect.hasAssertions(); // 如果没有断言调用,测试失败

  const data = await fetchData();
  expect(data).toBeDefined();
});

expect.hasAssertions() 在异步测试中特别有用,可确保断言确实运行。

expect.assertions(count)

使用 expect.assertions(count) 验证测试中调用了指定数量的断言:

example.test.ts
test("恰好两个断言", () => {
  expect.assertions(2); // 如果断言不是精确2个则失败

  expect(1 + 1).toBe(2);
  expect("hello").toContain("ell");
});

expect.assertions(count) 有助于确保所有断言都运行,尤其是在包含多个代码路径的复杂异步代码中。

类型测试

Bun 包含用于测试 TypeScript 类型的 expectTypeOf,兼容 Vitest。

expectTypeOf

expectTypeOf 函数提供类型级断言,由 TypeScript 类型检查器验证。要测试类型:

  1. 使用 expectTypeOf 编写类型断言
  2. 运行 bunx tsc --noEmit 检查类型是否正确
example.test.ts
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()

最佳实践

使用描述性的测试名称

example.test.ts
// 好的示例
test("应计算多个商品含税总价", () => {
  // 测试实现
});

// 避免
test("价格计算", () => {
  // 测试实现
});

分组相关测试

auth.test.ts
describe("用户认证", () => {
  describe("有效凭证时", () => {
    test("应返回用户数据", () => {
      // 测试实现
    });

    test("应设置认证令牌", () => {
      // 测试实现
    });
  });

  describe("无效凭证时", () => {
    test("应抛出认证错误", () => {
      // 测试实现
    });
  });
});

使用合适的匹配器

auth.test.ts
// 好的示例:使用具体匹配器
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);

测试错误情况

example.test.ts
test("无效输入应抛出错误", () => {
  expect(() => {
    validateEmail("not-an-email");
  }).toThrow("邮箱格式无效");
});

test("应处理异步错误", async () => {
  await expect(async () => {
    await fetchUser("invalid-id");
  }).rejects.toThrow("未找到用户");
});

使用初始化和清理

example.test.ts
import { beforeEach, afterEach, test } from "bun:test";

let testUser;

beforeEach(() => {
  testUser = createTestUser();
});

afterEach(() => {
  cleanupTestUser(testUser);
});

test("应更新用户资料", () => {
  // 在测试中使用 testUser
});