首页 / Bun 入门教程 / 断言与匹配器

Bun 入门教程

断言与匹配器

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

Bunbun testexpect匹配器异步测试断言

本节目标:

  • 分清 toBe / toEqual / toStrictEqual 三种相等性判定的差别,避免最常见的断言误用。
  • 掌握字符串、数组、对象、数值四类常用匹配器,并知道什么时候该用哪一个。
  • 熟练编写异步断言:await expect(...).resolves / .rejectstoThrow 与异常断言。
  • 会用 expect.any / expect.objectContaining 等非对称匹配器处理”部分匹配”场景。
  • 了解断言计数(expect.assertions)、自定义匹配器(expect.extend)与类型断言(expectTypeOf)。

22.1 expect 的基本形态

bun:test 的断言入口只有一个函数:expect(实际值),它返回一个对象,上面挂满了以 to 开头的匹配器方法。所有匹配器前面都可以加 .not 取反。

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

test("expect 的基本形态", () => {
  expect(2 + 2).toBe(4);
  expect(2 + 2).not.toBe(5);
});

这套 API 与 Jest 完全同名同义,所以从 Jest 或 Vitest 迁移过来的断言语句几乎不需要改。Bun 已经实现了绝大多数常用匹配器,完整的兼容状态由官方在 oven-sh/bun#1825 中跟踪;目前明确标记为「未实现」的只有 expect.addSnapshotSerializer()(自定义快照序列化器)。

断言失败时,Bun 会打印期望值与实际值的差异(diff)。这也是应当用具体匹配器而不是”把判断塞进 toBe(true)”的原因——后者失败时你只知道”期望 true 得到 false”,前者能直接告诉你哪个字段不一样。

// 推荐:失败信息里能看到实际长度和实际邮箱
expect(users).toHaveLength(3);
expect(user.email).toContain("@");
expect(response.status).toBeGreaterThanOrEqual(200);

// 不推荐:失败时只有 "expected true, received false"
expect(users.length === 3).toBe(true);
expect(user.email.includes("@")).toBe(true);
expect(response.status >= 200).toBe(true);

22.2 相等性:toBe、toEqual、toStrictEqual

这三个匹配器是使用频率最高、也最容易搞混的一组。

toBe 使用 Object.is 做同一性比较。对于原始值(数字、字符串、布尔、null、undefined、symbol、bigint)它就是”值相等”;对于对象和数组,它要求是同一个引用

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

test("toBe 比较引用", () => {
  expect(1 + 1).toBe(2);
  expect("bun").toBe("bun");

  const a = { x: 1 };
  const b = { x: 1 };
  expect(a).not.toBe(b); // 结构相同但不是同一个对象
  expect(a).toBe(a);
});

toEqual 递归比较结构。对象比较可枚举属性,数组逐项比较。它有一个重要特性:值为 undefined 的属性会被忽略

test("toEqual 递归比较结构", () => {
  expect({ x: 1, y: [2, 3] }).toEqual({ x: 1, y: [2, 3] });

  // undefined 属性被忽略
  expect({ a: 1, b: undefined }).toEqual({ a: 1 });
});

toStrictEqualtoEqual 的基础上更严格:不忽略 undefined 属性,并且比较对象的类(构造函数)。

class Point {
  constructor(public x: number, public y: number) {}
}

test("toStrictEqual 更严格", () => {
  expect({ a: 1, b: undefined }).not.toStrictEqual({ a: 1 });

  const literal = { x: 1, y: 2 };
  const instance = new Point(1, 2);
  expect(instance).toEqual(literal);
  expect(instance).not.toStrictEqual(literal); // 类型不同
});

选择原则很简单:比较原始值用 toBe;比较普通数据结构用 toEqual;需要连”有没有显式写 undefined”和”是不是同一个类的实例”都卡死时用 toStrictEqual

Warning

别用 toBe 比较浮点计算结果。expect(0.1 + 0.2).toBe(0.3) 一定失败,正确写法是 expect(0.1 + 0.2).toBeCloseTo(0.3)

22.3 真值性与空值匹配器

这一组用来判断”有没有值”,都不带参数:

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

test("真值性与空值", () => {
  expect(null).toBeNull();
  expect(undefined).toBeUndefined();
  expect(0 / 0).toBeNaN();

  expect("something").toBeDefined();
  expect(null).toBeDefined(); // null 是"已定义"的

  expect("").toBeFalsy();
  expect(0).toBeFalsy();
  expect([]).toBeTruthy(); // 空数组在 JS 里是真值
  expect({}).toBeTruthy();
});

toBeDefined() 只检查”不是 undefined”,null 会通过。如果要断言”既不是 null 也不是 undefined”,写 expect(v).not.toBeNil() 不可靠(Bun 不保证提供该匹配器),更稳妥的是分开断言或用 expect(v ?? null).not.toBeNull()

22.4 字符串与数组匹配器

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

test("字符串匹配器", () => {
  expect("hello bun").toContain("bun");        // 子串
  expect("hello bun").toMatch(/^hello/);        // 正则
  expect("hello bun").toMatch("bun");           // 字符串也可以
  expect("hello").toHaveLength(5);
});

test("数组匹配器", () => {
  const tags = ["bun", "test", "runtime"];

  expect(tags).toContain("test");               // 用 === 逐项比较
  expect(tags).toHaveLength(3);

  const users = [{ id: 1 }, { id: 2 }];
  expect(users).toContainEqual({ id: 2 });      // 用深比较逐项匹配
  expect(users).not.toContain({ id: 2 });       // 引用不同,toContain 失败
});

toContaintoContainEqual 的区别经常被忽略:前者用严格相等逐项比较,只适合原始值数组;后者做深比较,才能匹配对象元素。

22.5 对象匹配器

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

const user = {
  id: 42,
  name: "Ada",
  profile: { city: "London", tags: ["math", "code"] },
};

test("对象匹配器", () => {
  expect(user).toHaveProperty("name");
  expect(user).toHaveProperty("name", "Ada");
  expect(user).toHaveProperty("profile.city", "London"); // 支持路径

  // 部分匹配:只校验关心的字段,多余字段不影响
  expect(user).toMatchObject({
    name: "Ada",
    profile: { city: "London" },
  });
});

toMatchObject 是接口返回值断言的主力:后端加了新字段不会让测试挂掉,但你关心的字段一旦变了立刻能发现。

Bun 还实现了一组来自 jest-extended 风格的键值匹配器:

test("键值匹配器", () => {
  const config = { host: "localhost", port: 3000, debug: true };

  expect(config).toContainAllKeys(["host", "port", "debug"]);
  expect(config).toContainValue(3000);
  expect(config).toContainValues([3000, true]);
  expect(config).toContainAllValues(["localhost", 3000, true]);
  expect(config).toContainAnyValues([9999, "localhost"]);
});

22.6 数值匹配器

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

test("数值匹配器", () => {
  expect(10).toBeGreaterThan(9);
  expect(10).toBeGreaterThanOrEqual(10);
  expect(10).toBeLessThan(11);
  expect(10).toBeLessThanOrEqual(10);

  // 浮点比较:第二个参数是小数位数,默认 2
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect(0.1 + 0.2).toBeCloseTo(0.30000001, 5);
});

22.7 异常断言

同步函数抛错用 toThrow。注意:必须传入函数本身,而不是调用结果,否则异常在 expect 之前就抛出来了。

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

function validateEmail(input: string) {
  if (!input.includes("@")) {
    throw new TypeError("Invalid email format");
  }
  return input;
}

test("异常断言", () => {
  // 正确:传函数
  expect(() => validateEmail("nope")).toThrow();

  // 匹配消息子串
  expect(() => validateEmail("nope")).toThrow("Invalid email");

  // 匹配正则
  expect(() => validateEmail("nope")).toThrow(/^Invalid/);

  // 匹配错误类型
  expect(() => validateEmail("nope")).toThrow(TypeError);

  // 匹配具体错误实例
  expect(() => validateEmail("nope")).toThrow(new TypeError("Invalid email format"));

  // 断言不抛错
  expect(() => validateEmail("ok@example.com")).not.toThrow();
});

传字符串时是子串匹配而不是全等,所以 toThrow("Invalid email") 能匹配 "Invalid email format"

类型断言用 toBeInstanceOf

test("实例断言", () => {
  expect(new Date()).toBeInstanceOf(Date);
  expect([]).toBeInstanceOf(Array);
  expect(new TypeError("x")).toBeInstanceOf(Error);
});

22.8 异步测试

异步是测试里最容易写错的部分。bun:test 支持三种写法。

写法一:async 函数 + await(推荐)

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

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

测试函数返回 Promise 时,Bun 会等它 settle 再判定结果。这是最直观、最不容易出错的写法。

写法二:.resolves / .rejects 匹配器

async function fetchUser(id: string) {
  if (id === "invalid-id") throw new Error("User not found");
  return { id, name: `User ${id}` };
}

test("resolves 与 rejects", async () => {
  // 断言 resolve 的值
  await expect(fetchUser("123")).resolves.toEqual({ id: "123", name: "User 123" });
  await expect(fetchUser("123")).resolves.toHaveProperty("name");

  // 断言 reject
  await expect(fetchUser("invalid-id")).rejects.toThrow("User not found");
  await expect(fetchUser("invalid-id")).rejects.toBeInstanceOf(Error);
});

对于返回 Promise 的异步函数,也可以把 async 函数本身交给 .rejects

test("异步异常", async () => {
  await expect(async () => {
    await fetchUser("invalid-id");
  }).rejects.toThrow("User not found");
});
Warning

.resolves / .rejects 前面必须加 await(或 return)。漏掉的话断言会变成一个游离的 Promise,测试即使断言失败也会显示通过——这是异步测试里最隐蔽的坑。

写法三:done 回调

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

只要测试函数声明了 done 参数,Bun 就会等你调用它。忘记调用会一直挂到超时。这种写法主要用于事件回调式 API,新代码建议优先用 async/await。

每个用例默认超时 5000 毫秒,超时即判失败。可以按用例覆盖:

test("慢查询", async () => {
  const rows = await slowQuery();
  expect(rows.length).toBeGreaterThan(0);
}, 20000);

22.9 非对称匹配器

当你只关心值的”形状”而不关心具体内容(例如自动生成的 ID、时间戳)时,用非对称匹配器。它们可以嵌套在 toEqualtoMatchObjecttoHaveBeenCalledWith 等匹配器的参数里。

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

test("非对称匹配器", () => {
  const record = {
    id: crypto.randomUUID(),
    name: "Ada",
    createdAt: new Date().toISOString(),
    scores: [90, 95, 88],
    meta: { source: "api", version: 3 },
  };

  expect(record).toEqual({
    id: expect.any(String),               // 任意字符串
    name: "Ada",
    createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
    scores: expect.arrayContaining([90]), // 至少包含这些元素
    meta: expect.objectContaining({ source: "api" }), // 至少包含这些字段
  });

  expect(record.name).toEqual(expect.stringContaining("Ad"));
  expect(record.id).toEqual(expect.anything()); // 只要不是 null/undefined
});

常用的几个:

匹配器含义
expect.any(Constructor)任意该类型的值(StringNumberDate、自定义类都可以)
expect.anything()任意非 null、非 undefined 的值
expect.stringContaining(s)包含指定子串的字符串
expect.stringMatching(re)匹配正则的字符串
expect.arrayContaining([...])至少包含列出元素的数组
expect.objectContaining({...})至少包含列出字段的对象
expect.closeTo(n, digits?)近似相等的数字(用于嵌套在结构里)

22.10 断言计数

异步代码里最怕的是”断言压根没执行,测试却过了”。bun:test 提供两个函数来兜底。

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

test("至少执行了一次断言", async () => {
  expect.hasAssertions();

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

test("恰好执行两次断言", () => {
  expect.assertions(2);

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

expect.hasAssertions() 要求本用例至少跑过一个断言;expect.assertions(n) 要求恰好 n 个。在有多条分支的异步逻辑里特别有用——比如一个 try/catch,你希望确认走到的是 catch 分支里的断言:

test("确认走到了异常分支", async () => {
  expect.assertions(1);

  try {
    await fetchUser("invalid-id");
  } catch (err) {
    expect((err as Error).message).toBe("User not found");
  }
});

如果 fetchUser 哪天不抛错了,断言数变成 0,测试就会失败,而不是悄悄”通过”。

22.11 自定义匹配器

expect.extend 注册自己的匹配器。匹配器函数接收实际值和参数,返回 { pass, message }

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

expect.extend({
  toBeWithinRange(received: number, min: number, max: number) {
    const pass = received >= min && received <= max;
    return {
      pass,
      message: () =>
        pass
          ? `期望 ${received} 不在 ${min} ~ ${max} 范围内`
          : `期望 ${received} 在 ${min} ~ ${max} 范围内`,
    };
  },
});

test("自定义匹配器", () => {
  expect(7).toBeWithinRange(1, 10);
  expect(42).not.toBeWithinRange(1, 10);
});

message 是一个函数,只有断言失败(或 .not 情况下意外通过)时才会被调用来生成错误信息。

TypeScript 项目里需要做声明合并,匹配器才有类型提示。把下面的内容放进一个 .d.ts 文件:

import { Matchers, AsymmetricMatchers } from "bun:test";

declare module "bun:test" {
  interface Matchers<T> {
    toBeWithinRange(min: number, max: number): T;
  }
  interface AsymmetricMatchers {
    toBeWithinRange(min: number, max: number): void;
  }
}

这套机制也是接入第三方匹配器库的方式。例如 @testing-library/jest-dom 就是通过 expect.extend(matchers) 挂上 toBeInTheDocument 等 DOM 匹配器的,第 24 章会用到。

Tip

自定义匹配器最好放在 preload 脚本里统一注册(bunfig.toml[test] preload),避免每个测试文件重复 expect.extend

22.12 类型断言 expectTypeOf

Bun 内置了与 Vitest 兼容的 expectTypeOf,用来断言 TypeScript 类型。

import { expectTypeOf } from "bun:test";

function greet(name: string): string {
  return `Hello ${name}`;
}

expectTypeOf<string>().toEqualTypeOf<string>();
expectTypeOf(123).toBeNumber();
expectTypeOf("hello").toBeString();
expectTypeOf({ a: 1, b: "hello" }).toMatchObjectType<{ a: number }>();

expectTypeOf(greet).toBeFunction();
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>();
expectTypeOf(greet).returns.toEqualTypeOf<string>();

expectTypeOf([1, 2, 3]).items.toBeNumber();
expectTypeOf(Promise.resolve(42)).resolves.toBeNumber();
Warning

expectTypeOf 在运行时是空操作bun test 跑过去永远是绿的。要真正验证类型,必须单独执行类型检查:bunx tsc --noEmit。把这一步加进 CI,类型断言才有意义。

22.13 小结与常见误区

expect 的使用可以浓缩成几条经验:原始值用 toBe,结构用 toEqual,接口响应用 toMatchObject,不确定的字段用 expect.any 之类的非对称匹配器兜住;异步一律记得 await;能用具体匹配器就别把逻辑塞进 toBe(true)

高频误区清单:

  • .resolves / .rejects 前忘记 await,导致断言失效但测试显示通过。
  • toThrow 传了调用结果expect(fn()))而不是函数(expect(() => fn())),异常直接冒到测试外面。
  • toBe 比对象或浮点数,前者永远因引用不同失败,后者因精度失败。
  • toContain 匹配对象数组,应该用 toContainEqual
  • 忘记 toEqual 会忽略 undefined 属性,需要严格区分时用 toStrictEqual
  • 以为 expectTypeOf 会在 bun test 里报错,实际必须配合 bunx tsc --noEmit

下一章处理测试里的另一半难题:当被测代码依赖外部模块、网络、时间时,如何用 mock 与 spy 把它们隔离掉。