断言与匹配器
本教程共 34 篇 · 第 22 篇 · 更新于 2026-08-06
本节目标:
- 分清
toBe/toEqual/toStrictEqual三种相等性判定的差别,避免最常见的断言误用。- 掌握字符串、数组、对象、数值四类常用匹配器,并知道什么时候该用哪一个。
- 熟练编写异步断言:
await expect(...).resolves/.rejects、toThrow与异常断言。- 会用
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 });
});
toStrictEqual 在 toEqual 的基础上更严格:不忽略 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 失败
});
toContain 与 toContainEqual 的区别经常被忽略:前者用严格相等逐项比较,只适合原始值数组;后者做深比较,才能匹配对象元素。
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、时间戳)时,用非对称匹配器。它们可以嵌套在 toEqual、toMatchObject、toHaveBeenCalledWith 等匹配器的参数里。
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) | 任意该类型的值(String、Number、Date、自定义类都可以) |
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 把它们隔离掉。