模拟与桩(Mocks & Spies)
本教程共 34 篇 · 第 23 篇 · 更新于 2026-08-06
本节目标:
- 用
mock()创建函数桩,理解.mock.calls、.mock.results等调用记录的读取方式,以及mockImplementation、mockReturnValue等常用方法。- 掌握
jest.fn()与mock()的等价关系,理解 Bun 的「Jest 兼容」承诺在模拟层如何落地。- 用
spyOn()在不改变原实现的前提下监听真实函数调用,并能在需要时临时替换实现。- 用
mock.module()替换整个模块依赖(含--preload提升时机),并掌握全局清理clearAllMocks/resetAllMocks/restore()。- 用
jest.useFakeTimers()/setSystemTime()模拟定时器与系统时间,处理依赖setTimeout、Date.now的被测代码。
23.1 为什么需要模拟
真实代码几乎不会孤立运行:一个函数可能调用数据库、发 HTTP 请求、读系统时间、调用另一个模块。测试这类代码时,我们不想真的去连数据库或发请求(慢、不可控、还可能污染线上数据),而是用一个「可控的替身」替换掉真实依赖,只验证「我的代码以正确的参数调用了依赖、并对返回值做了正确的处理」。
Bun 把这类替身分成两类:
- 函数桩(mock):完全由你控制的函数,记录每一次调用,返回值由你设定。
- 间谍(spy):包裹一个真实函数,监听它的调用,但不改变其行为(除非你主动替换)。
Note所有这些 API 都从
"bun:test"导出,并且与 Jest 的jest.fn/jest.spyOn/jest.mock行为一致。从 Jest 迁移时,绝大多数涉及模拟的测试文件可以不改或仅改导入路径。
23.2 基础函数桩:mock()
用 mock() 创建一个空的函数桩,括号里可以传一个初始实现:
import { test, expect, mock } from "bun:test";
const random = mock(() => Math.random());
test("random", () => {
const val = random();
expect(val).toBeGreaterThan(0);
expect(random).toHaveBeenCalled();
expect(random).toHaveBeenCalledTimes(1);
});
mock() 返回的函数被装饰了一组额外属性,让你在断言里检查「它到底被怎么调用过」:
import { mock } from "bun:test";
const random = mock((multiplier: number) => multiplier * Math.random());
random(2);
random(10);
random.mock.calls;
// [[ 2 ], [ 10 ]]
random.mock.results;
// [
// { type: "return", value: 0.6533907460954099 },
// { type: "return", value: 0.6452713933037312 },
// ]
可用的属性与方法
| 属性 / 方法 | 说明 |
|---|---|
mockFn.mock.calls | 每次调用的参数数组 |
mockFn.mock.results | 每次调用的返回值({type, value}) |
mockFn.mock.instances | 用 new 创建出来的实例数组 |
mockFn.mock.contexts | 每次调用时的 this 上下文 |
mockFn.mock.lastCall | 最近一次调用的参数 |
mockFn.mockClear() | 清空调用记录 |
mockFn.mockReset() | 清空调用记录并移除实现 |
mockFn.mockRestore() | 还原原始实现(仅对 spy 有意义) |
mockFn.mockImplementation(fn) | 设置实现 |
mockFn.mockImplementationOnce(fn) | 仅下一次调用使用此实现 |
mockFn.mockReturnValue(value) | 固定返回值 |
mockFn.mockReturnValueOnce(value) | 仅下一次返回此值 |
mockFn.mockResolvedValue(value) | 返回一个已 resolve 的 Promise |
mockFn.mockRejectedValue(value) | 返回一个已 reject 的 Promise |
mockFn.mockReturnThis() | 返回 this |
mockFn.withImplementation(fn, cb) | 在回调执行期间临时替换实现 |
下面这个例子展示了 mockImplementationOnce 与默认实现的组合:
import { test, expect, mock } from "bun:test";
test("dynamic mock implementations", () => {
const mockFn = mock();
mockFn.mockImplementationOnce(() => "first");
mockFn.mockImplementationOnce(() => "second");
mockFn.mockImplementation(() => "default");
expect(mockFn()).toBe("first");
expect(mockFn()).toBe("second");
expect(mockFn()).toBe("default"); // 之后都走默认实现
});
异步桩也很直观——mockResolvedValue / mockRejectedValue 让你可以直接返回 Promise:
import { test, expect, mock } from "bun:test";
test("async mock functions", async () => {
const asyncMock = mock();
asyncMock.mockResolvedValueOnce("first result");
asyncMock.mockResolvedValue("default result");
expect(await asyncMock()).toBe("first result");
expect(await asyncMock()).toBe("default result");
const rejectMock = mock();
rejectMock.mockRejectedValue(new Error("Mock error"));
await expect(rejectMock()).rejects.toThrow("Mock error");
});
23.3 与 Jest 的等价:jest.fn()
Bun 提供 jest.fn(),行为与 mock() 完全相同。这是「Jest 兼容」最实在的一处体现:
import { test, expect, jest } from "bun:test";
const random = jest.fn(() => Math.random());
test("random", () => {
random();
expect(random).toHaveBeenCalledTimes(1);
});
Tip如果你的现有测试库大量使用
jest.fn(),不必批量改写,直接import { jest } from "bun:test"即可。反之,新代码用mock()更符合 Bun 的命名习惯,两者任选其一即可。
此外,Bun 还提供了 Vitest 风格的 vi 别名,方便从 Vitest 迁移:vi.fn、vi.spyOn、vi.mock、vi.clearAllMocks、vi.resetAllMocks、vi.restoreAllMocks 都可用。
23.4 间谍:spyOn()
spyOn() 监听一个对象上已有方法的调用,但不替换它。适用于「我想确认某方法被调用了,但不想改变它的行为」的场景:
import { test, expect, spyOn } from "bun:test";
const ringo = {
name: "Ringo",
sayHi() {
console.log(`Hello I'm ${this.name}`);
},
};
const spy = spyOn(ringo, "sayHi");
test("spyon", () => {
expect(spy).toHaveBeenCalledTimes(0);
ringo.sayHi();
expect(spy).toHaveBeenCalledTimes(1);
});
spyOn 也可以进一步链式地临时替换实现,用完由 jest.restoreAllMocks() / mock.restore() 还原:
import { test, expect, spyOn, afterEach } from "bun:test";
class UserService {
async getUser(id: string) {
return { id, name: `User ${id}` };
}
}
const userService = new UserService();
afterEach(() => {
jest.restoreAllMocks();
});
test("spy with mock implementation", async () => {
const getUserSpy = spyOn(userService, "getUser").mockResolvedValue({
id: "123",
name: "Mocked User",
});
const result = await userService.getUser("123");
expect(result.name).toBe("Mocked User");
expect(getUserSpy).toHaveBeenCalledWith("123");
});
23.5 模块替换:mock.module()
当依赖是「整个模块」而不是单个函数时,用 mock.module(path, factory) 把整块导出替换掉。它同时作用于 ESM 的 import 与 CommonJS 的 require:
import { test, expect, mock } from "bun:test";
mock.module("./module", () => {
return { foo: "bar" };
});
test("mock.module", async () => {
const esm = await import("./module");
expect(esm.foo).toBe("bar");
const cjs = require("./module");
expect(cjs.foo).toBe("bar");
});
一个更贴近实战的例子是替换外部依赖(如数据库驱动):
import { test, expect, mock } from "bun:test";
mock.module("pg", () => ({
Client: mock(function () {
return {
connect: mock(async () => {}),
query: mock(async (sql: string) => ({
rows: [{ id: 1, name: "Test User" }],
})),
end: mock(async () => {}),
};
}),
}));
提升时机与 —preload
mock.module() 即使模块已经被 import 过也能生效(Bun 会更新已有的模块缓存与 live bindings)。但要注意:如果模块在 mock.module() 调用之前已经被求值,它的副作用(顶层代码)已经执行过一次了。如果你希望「原始模块一次都不要被求值」,必须在测试文件加载前就完成 mock,做法是放到一个 preload 脚本里:
// my-preload.ts
import { mock } from "bun:test";
mock.module("./module", () => {
return { foo: "bar" };
});
bun test --preload ./my-preload
更省事的方式是写进 bunfig.toml,让每次运行都自动 preload:
[test]
# 在运行测试前加载这些模块
preload = ["./my-preload"]
WarningBun 不支持 Jest 的
__mocks__目录与自动 mock(auto-mocking)。如果你依赖这套机制,需要手动把模块替换改成mock.module()的写法。官方仓库有对应的特性请求 issue,可据此评估迁移工作量。
23.6 全局清理:clear / reset / restore
当一个文件里有多个 mock 时,逐个调用 mockFn.mockClear() 很麻烦。Bun 提供三个全局方法:
mock.clearAllMocks():清空所有 mock 的.mock.calls、.mock.results等记录,但保留实现。jest.resetAllMocks()(及vi.resetAllMocks()):在 clear 基础上,还会丢弃mockImplementation/mockReturnValue设进去的实现。mock.restore():还原所有 spy 的原始实现;不会还原mock.module()替换的模块。
import { expect, jest, test } from "bun:test";
const random = jest.fn(() => Math.random());
test("resetting all mocks", () => {
random();
expect(random).toHaveBeenCalledTimes(1);
jest.resetAllMocks();
expect(random).toHaveBeenCalledTimes(0);
// 与 clearAllMocks 不同,reset 之后实现也被清空
expect(random()).toBeUndefined();
});
常见的做法是把清理放进 afterEach,避免每个测试里重复写:
import { afterEach } from "bun:test";
afterEach(() => {
mock.restore();
mock.clearAllMocks();
});
23.7 定时器与系统时间模拟
很多代码依赖时间:setTimeout / setInterval 的回调、Date.now()、new Date()。如果测试要等真实时间流逝,会非常慢。Bun 提供两套机制来「冻结时间」。
改变系统时间:setSystemTime
最简单的是 setSystemTime,它直接把 Bun 内部的系统时间改成指定时刻,影响 Date.now、new Date()、Intl.DateTimeFormat:
import { setSystemTime, beforeAll, test, expect } from "bun:test";
beforeAll(() => {
setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
});
test("it is 2020", () => {
expect(new Date().getFullYear()).toBe(2020);
});
想还原时,setSystemTime() 不带参数即可。
与 Jest 兼容的假时钟
如果你已有用 jest.useFakeTimers() / jest.useRealTimers() 写的测试,Bun 同样支持:
test("just like in jest", () => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
expect(new Date().getFullYear()).toBe(2020);
jest.useRealTimers();
expect(new Date().getFullYear()).toBeGreaterThan(2020);
});
Note一个值得注意的兼容差异:在 Jest 里调用
useFakeTimers()后,Date构造函数本身会被替换(Date !== Date之前的值),这常常会引发诡异 bug;而 Bun 中Date构造函数不会被替换,行为上更可预测。
读取被 mock 的时间:jest.now()
当时间被 mock 后,用 jest.now() 读当前(被 mock 的)时间戳,无需新建 Date 对象:
test("get the current mocked time", () => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
expect(Date.now()).toBe(1577836800000); // 2020-01-01 的时间戳
expect(jest.now()).toBe(1577836800000);
jest.useRealTimers();
});
时区控制
bun test 默认以 UTC(Etc/UTC)运行。要换时区,传 TZ 环境变量即可:
TZ=America/Los_Angeles bun test
也可以运行时改:
test("Welcome to California!", () => {
process.env.TZ = "America/Los_Angeles";
expect(new Date().getTimezoneOffset()).toBe(420);
});
Tip与 Jest 不同,Bun 里可以在运行时多次修改
process.env.TZ且每次都会生效,这对需要跨时区对比的测试更友好。
23.8 小结与误区
mock()与jest.fn()完全等价,选哪个都行;新代码推荐mock()。spyOn默认不替换实现,只监听;要替换得链式.mockImplementation。- 模块替换用
mock.module();要阻止原始模块副作用,用--preload提前注册。 - 全局清理三兄弟:
clearAllMocks(清记录)、resetAllMocks(清记录+实现)、restore()(还原 spy,不动 module mock)。 - 时间模拟优先用
setSystemTime,需要控制定时器队列时再配合jest.useFakeTimers()。
Warning常见误区:把 mock 写得太「聪明」——给桩函数塞进一大段业务逻辑。mock 应当尽量简单,只返回被测代码需要的固定值;它的职责是「隔离依赖」,不是「重新实现一遍依赖」。测试价值在于验证被测代码本身,而不是验证 mock。