執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

Mock

學習如何在 Bun 測試中建立和使用模擬函式、間諜以及模組模擬

模擬會使用受控的實現替換相依項。Bun 支援函式模擬、間諜以及模組模擬。

基礎函式模擬

使用 mock 函式建立模擬。

test.ts
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);
});

Jest 相容性

你也可以像在 Jest 中一樣使用 jest.fn()。它的行為完全相同。

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

const random = jest.fn(() => Math.random());

test("random", () => {
  const val = random();
  expect(val).toBeGreaterThan(0);
  expect(random).toHaveBeenCalled();
  expect(random).toHaveBeenCalledTimes(1);
});

模擬函式屬性

mock() 傳回一個帶有附加屬性的新函式。

test.ts
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.getMockName()傳回模擬函式名稱
mockFn.mock.calls每次呼叫的引數陣列
mockFn.mock.results每次呼叫的傳回值陣列
mockFn.mock.instances使用 new 建立的例項陣列
mockFn.mock.contexts每次呼叫的 this 上下文陣列
mockFn.mock.lastCall最近一次呼叫的引數
mockFn.mockClear()清除呼叫歷史
mockFn.mockReset()清除呼叫歷史並移除實現
mockFn.mockRestore()恢復原始實現
mockFn.mockImplementation(fn)設定新的實現
mockFn.mockImplementationOnce(fn)僅為下一次呼叫設定實現
mockFn.mockName(name)設定模擬函式名稱
mockFn.mockReturnThis()將傳回值設定為 this
mockFn.mockReturnValue(value)設定傳回值
mockFn.mockReturnValueOnce(value)僅為下一次呼叫設定傳回值
mockFn.mockResolvedValue(value)設定已解決的 Promise 值
mockFn.mockResolvedValueOnce(value)僅為下一次呼叫設定已解決的 Promise
mockFn.mockRejectedValue(value)設定已拒絕的 Promise 值
mockFn.mockRejectedValueOnce(value)僅為下一次呼叫設定已拒絕的 Promise
mockFn.withImplementation(fn, callback)臨時更改實現

實際範例

基礎模擬用法

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

test("mock function behavior", () => {
  const mockFn = mock((x: number) => x * 2);

  // 呼叫模擬函式
  const result1 = mockFn(5);
  const result2 = mockFn(10);

  // 驗證呼叫情況
  expect(mockFn).toHaveBeenCalledTimes(2);
  expect(mockFn).toHaveBeenCalledWith(5);
  expect(mockFn).toHaveBeenLastCalledWith(10);

  // 檢查傳回結果
  expect(result1).toBe(10);
  expect(result2).toBe(20);

  // 檢視呼叫歷史
  expect(mockFn.mock.calls).toEqual([[5], [10]]);
  expect(mockFn.mock.results).toEqual([
    { type: "return", value: 10 },
    { type: "return", value: 20 },
  ]);
});

動態模擬實現

test.ts
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");
  expect(mockFn()).toBe("default"); // 使用預設實現
});

非同步模擬

test.ts
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");
});

使用 spyOn() 建立間諜

使用 spyOn() 跟蹤函式呼叫,而不將其替換為模擬函式。你可以將間諜傳給 .toHaveBeenCalled() 和 .toHaveBeenCalledTimes()。

test.ts
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);
});

高階間諜用法

test.ts
import { test, expect, spyOn, afterEach, jest } from "bun:test";

class UserService {
  async getUser(id: string) {
    // 原始實現
    return { id, name: `User ${id}` };
  }

  async saveUser(user: any) {
    // 原始實現
    return { ...user, saved: true };
  }
}

const userService = new UserService();

afterEach(() => {
  // 每個測試後恢復所有間諜
  jest.restoreAllMocks();
});

test("spy on service methods", async () => {
  // 監聽但不改變實現
  const getUserSpy = spyOn(userService, "getUser");
  const saveUserSpy = spyOn(userService, "saveUser");

  // 正常使用服務
  const user = await userService.getUser("123");
  await userService.saveUser(user);

  // 驗證呼叫
  expect(getUserSpy).toHaveBeenCalledWith("123");
  expect(saveUserSpy).toHaveBeenCalledWith(user);
});

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");
});

使用 mock.module() 進行模組模擬

使用 mock.module(path: string, callback: () => Object) 覆蓋模組的行為。

test.ts
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");
});

與 Bun 其他部分一樣,模組模擬同時支援 import 和 require。

重寫已匯入模組

呼叫 mock.module() 會覆蓋模組,即使該模組已經被匯入。

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

// 我們將模擬的模組:
import { foo } from "./module";

test("mock.module", async () => {
  const cjs = require("./module");
  expect(foo).toBe("bar");
  expect(cjs.foo).toBe("bar");

  // 這裡更新模擬:
  mock.module("./module", () => {
    return {
      foo: "baz",
    };
  });

  // 即時繫結會自動更新。
  expect(foo).toBe("baz");

  // 該模組對 CJS 也會更新。
  expect(cjs.foo).toBe("baz");
});

當工廠函式傳回一個仍處於待定狀態的 Promise 時,Bun 會在該 Promise 完成後更新已匯入的模組。在這種情況下,mock.module() 會傳回一個在更新完成後解析的 Promise;否則,它會傳回 undefined。在測試或鉤子中使用 await mock.module(...) 可以同時處理這兩種情況。在測試檔案的頂層,Bun 會等待待定的模組模擬完成後再執行檔案中的測試。

test.ts
import { test, expect, mock } from "bun:test";
import { foo } from "./module";

mock.module("./module", async () => {
  const { default: fixture } = await import("./fixture.json");
  return { foo: fixture.foo };
});

test("async factory", () => {
  expect(foo).toBe("baz");
});
fixture.json
{ "foo": "baz" }

提升與預載入

為了確保模組在匯入之前已被模擬,請使用 --preload 在測試執行前載入模擬模組。

my-preload.ts
import { mock } from "bun:test";

mock.module("./module", () => {
  return {
    foo: "bar",
  };
});
terminal
bun test --preload ./my-preload

為了避免每次執行測試時都輸入 --preload,請將其新增到 bunfig.toml 中:

bunfig.toml
[test]
# 在執行測試前載入這些模組。
preload = ["./my-preload"]

模組模擬最佳實踐

何時使用預載入

模擬已匯入的模組會更新模組快取,因此任何匯入該模組的內容都會獲取模擬版本。不過,Bun 已經執行了原始模組,因此其副作用也已經發生。

若要完全避免執行原始模組,請使用 --preload 在測試執行前載入模擬模組。

實際模組模擬範例

api-client.test.ts
import { test, expect, mock, beforeEach } from "bun:test";

// 模擬 API 客戶端模組
mock.module("./api-client", () => ({
  fetchUser: mock(async (id: string) => ({ id, name: `User ${id}` })),
  createUser: mock(async (user: any) => ({ ...user, id: "new-id" })),
  updateUser: mock(async (id: string, user: any) => ({ ...user, id })),
}));

test("user service with mocked API", async () => {
  const { fetchUser } = await import("./api-client");
  const { UserService } = await import("./user-service");

  const userService = new UserService();
  const user = await userService.getUser("123");

  expect(fetchUser).toHaveBeenCalledWith("123");
  expect(user.name).toBe("User 123");
});

模擬外部相依

database.test.ts
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 () => {}),
    };
  }),
}));

test("database operations", async () => {
  const { Database } = await import("./database");
  const db = new Database();

  const users = await db.getUsers();
  expect(users).toHaveLength(1);
  expect(users[0].name).toBe("Test User");
});

全域性模擬函式

清除所有模擬

mock.clearAllMocks() 會重置每個模擬的 .mock.calls、.mock.instances、.mock.contexts 和 .mock.results 屬性。與 mock.restore() 不同,它不會恢復原始實現:

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

const random1 = mock(() => Math.random());
const random2 = mock(() => Math.random());

test("clearing all mocks", () => {
  random1();
  random2();

  expect(random1).toHaveBeenCalledTimes(1);
  expect(random2).toHaveBeenCalledTimes(1);

  mock.clearAllMocks();

  expect(random1).toHaveBeenCalledTimes(0);
  expect(random2).toHaveBeenCalledTimes(0);

  // 注意:實現依然存在
  expect(typeof random1()).toBe("number");
  expect(typeof random2()).toBe("number");
});

重置所有模擬

jest.resetAllMocks()(及其別名 vi.resetAllMocks())會對每個模擬呼叫 mockFn.mockReset()。除了 clearAllMocks() 的操作外,它還會丟棄通過 mockImplementation()、mockReturnValue() 等方法設定的實現。它不會恢復間諜的原始實現:

test.ts
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() 不同,實現已被刪除
  expect(random()).toBeUndefined();
});

恢復所有模擬

mock.restore() 一次性恢復所有模擬,而不必分別對每個模擬呼叫 mockFn.mockRestore()。它不會重置通過 mock.module() 覆蓋的模組。

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

import * as fooModule from "./foo.ts";
import * as barModule from "./bar.ts";
import * as bazModule from "./baz.ts";

test("foo, bar, baz", () => {
  const fooSpy = spyOn(fooModule, "foo");
  const barSpy = spyOn(barModule, "bar");
  const bazSpy = spyOn(bazModule, "baz");

  // 原始實現仍可用
  expect(fooModule.foo()).toBe("foo");
  expect(barModule.bar()).toBe("bar");
  expect(bazModule.baz()).toBe("baz");

  // 模擬實現
  fooSpy.mockImplementation(() => 42);
  barSpy.mockImplementation(() => 43);
  bazSpy.mockImplementation(() => 44);

  expect(fooModule.foo()).toBe(42);
  expect(barModule.bar()).toBe(43);
  expect(bazModule.baz()).toBe(44);

  // 恢復所有
  mock.restore();

  expect(fooModule.foo()).toBe("foo");
  expect(barModule.bar()).toBe("bar");
  expect(bazModule.baz()).toBe("baz");
});

在 afterEach 塊或測試預載入指令碼中呼叫 mock.restore(),這樣就不必在每個測試中重複清理操作。

Vitest 相容性

為方便移植 Vitest 編寫的測試,Bun 提供了 vi 物件作為 Jest 模擬 API 部分的別名:

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

// 使用 'vi' 別名,類似 Vitest
test("vitest compatibility", () => {
  const mockFn = vi.fn(() => 42);

  mockFn();
  expect(mockFn).toHaveBeenCalled();

  // vi 物件支援以下函式:
  // vi.fn
  // vi.spyOn
  // vi.mock
  // vi.restoreAllMocks
  // vi.resetAllMocks
  // vi.clearAllMocks
});

你可以直接移植 Vitest 測試,無需重寫模擬函式。

實現細節

快取互動

模組模擬會與 ESM 和 CommonJS 模組快取互動。

惰性求值

Bun 僅在匯入或 require 模組時才會執行模擬工廠回撥。

路徑解析

Bun 以解析 import 的相同方式解析模組說明符,支援:

  • 相對路徑('./module')
  • 絕對路徑('/path/to/module')
  • 包名('lodash')

匯入時機影響

  • 在首次匯入前模擬:原模組不會執行副作用
  • 在匯入後模擬:原模組副作用已經發生

因此,對於需要阻止副作用的模擬,請使用 --preload。

即時繫結

被模擬的 ESM 模組會維護即時繫結,因此更改模擬內容會更新所有現有匯入。

高階模式

工廠函式

test.ts
import { mock } from "bun:test";

function createMockUser(overrides = {}) {
  return {
    id: "mock-id",
    name: "Mock User",
    email: "mock@example.com",
    ...overrides,
  };
}

const mockUserService = {
  getUser: mock(async (id: string) => createMockUser({ id })),
  createUser: mock(async (data: any) => createMockUser(data)),
  updateUser: mock(async (id: string, data: any) => createMockUser({ id, ...data })),
};

條件式模擬

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

const shouldUseMockApi = process.env.NODE_ENV === "test";

if (shouldUseMockApi) {
  mock.module("./api", () => ({
    fetchData: mock(async () => ({ data: "mocked" })),
  }));
}

test("conditional API usage", async () => {
  const { fetchData } = await import("./api");
  const result = await fetchData();

  if (shouldUseMockApi) {
    expect(result.data).toBe("mocked");
  }
});

模擬清理模式

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

beforeEach(() => {
  // 設定通用模擬
  mock.module("./logger", () => ({
    log: mock(() => {}),
    error: mock(() => {}),
    warn: mock(() => {}),
  }));
});

afterEach(() => {
  // Restore spies and clear call history; neither call resets the mock.module() override
  mock.restore();
  mock.clearAllMocks();
});

最佳實踐

保持模擬簡單

test.ts
// 好:簡單、聚焦的模擬
const mockUserApi = {
  getUser: mock(async id => ({ id, name: "Test User" })),
};

// 避免:過於複雜的模擬行為
const complexMock = mock(input => {
  if (input.type === "A") {
    return processTypeA(input);
  } else if (input.type === "B") {
    return processTypeB(input);
  }
  // ...很多複雜邏輯
});

使用型別安全的模擬

TypeScript
interface UserService {
  getUser(id: string): Promise<User>;
  createUser(data: CreateUserData): Promise<User>;
}

const mockUserService: UserService = {
  getUser: mock(async (id: string) => ({ id, name: "Test User" })),
  createUser: mock(async data => ({ id: "new-id", ...data })),
};

測試模擬行為

test.ts
test("service calls API correctly", async () => {
  const mockApi = { fetchUser: mock(async () => ({ id: "1" })) };

  const service = new UserService(mockApi);
  await service.getUser("123");

  // 驗證模擬被正確呼叫
  expect(mockApi.fetchUser).toHaveBeenCalledWith("123");
  expect(mockApi.fetchUser).toHaveBeenCalledTimes(1);
});

備註

自動模擬

Bun 不支援 __mocks__ 目錄或自動模擬。如果這阻礙了你遷移到 Bun,請提交 issue。

ESM 與 CommonJS

ESM 和 CommonJS 模組的模組模擬實現方式不同。對於 ES 模組,Bun 會修補 JavaScriptCore,以便在執行環境覆蓋匯出值,並遞迴更新即時繫結。