首页 / Bun 入门教程 / 模拟与桩(Mocks & Spies)

Bun 入门教程

模拟与桩(Mocks & Spies)

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

Bunbun test测试运行器mockspyjest.fn定时器

本节目标:

  • mock() 创建函数桩,理解 .mock.calls.mock.results 等调用记录的读取方式,以及 mockImplementationmockReturnValue 等常用方法。
  • 掌握 jest.fn()mock() 的等价关系,理解 Bun 的「Jest 兼容」承诺在模拟层如何落地。
  • spyOn() 在不改变原实现的前提下监听真实函数调用,并能在需要时临时替换实现。
  • mock.module() 替换整个模块依赖(含 --preload 提升时机),并掌握全局清理 clearAllMocks / resetAllMocks / restore()
  • jest.useFakeTimers() / setSystemTime() 模拟定时器与系统时间,处理依赖 setTimeoutDate.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.instancesnew 创建出来的实例数组
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.fnvi.spyOnvi.mockvi.clearAllMocksvi.resetAllMocksvi.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"]
Warning

Bun 不支持 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.nownew 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。