Skip to content

模擬對於測試至關重要,它允許你用受控的實現替換依賴項。Bun 提供全面的模擬功能,包括函數模擬、間諜和模塊模擬。

基本函數模擬

使用 mock 函數創建模擬。

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.fn() 函數,就像在 Jest 中一樣。它的行為完全相同。

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() 的結果是一個新函數,它已裝飾了一些額外的屬性。

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每次調用的 this 上下文數組
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)臨時更改實現

實用示例

基本模擬用法

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

test("模擬函數行為", () => {
  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 },
  ]);
});

動態模擬實現

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

test("動態模擬實現", () => {
  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"); // 使用默認實現
});

異步模擬

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

test("異步模擬函數", 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()

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

高級間諜用法

ts
import { test, expect, spyOn, afterEach } 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("間諜跟蹤服務方法", 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("使用模擬實現的間諜", 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) 來模擬模塊。

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 的其余部分一樣,模塊模擬支持 importrequire

覆蓋已導入的模塊

如果你需要覆蓋已經導入的模塊,你不需要做任何特殊的事情。只需調用 mock.module(),模塊就會被覆蓋。

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

提升和預加載

如果你需要確保在導入模塊之前模擬模塊,你應該使用 --preload 在測試運行之前加載模擬。

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

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

為了讓你的生活更輕松,你可以將 preload 放在 bunfig.toml 中:

toml
[test]
# 在運行測試之前加載這些模塊。
preload = ["./my-preload"]

模塊模擬最佳實踐

何時使用預加載

如果我模擬已經導入的模塊會發生什麼?

如果你模擬已經導入的模塊,模塊將在模塊緩存中更新。這意味著任何導入該模塊的模塊都將獲得模擬版本,但原始模塊仍將已被評估。這意味著原始模塊的任何副作用仍將發生。

如果你想防止原始模塊被評估,你應該使用 --preload 在測試運行之前加載模擬。

實用模塊模擬示例

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("使用模擬 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");
});

模擬外部依賴

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("數據庫操作", 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");
});

全局模擬函數

清除所有模擬

重置所有模擬函數狀態(調用、結果等),而不恢復其原始實現:

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

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

test("清除所有模擬", () => {
  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");
});

這會重置所有模擬的 .mock.calls.mock.instances.mock.contexts.mock.results 屬性,但與 mock.restore() 不同,它不會恢復原始實現。

恢復所有模擬

與其使用 mockFn.mockRestore() 手動恢復每個模擬,不如通過調用 mock.restore() 一次性恢復所有模擬。這樣做不會恢復使用 mock.module() 覆蓋的模塊的值。

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(fooSpy).toBe("foo");
  expect(barSpy).toBe("bar");
  expect(bazSpy).toBe("baz");

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

  expect(fooSpy()).toBe(42);
  expect(barSpy()).toBe(43);
  expect(bazSpy()).toBe(44);

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

  expect(fooSpy()).toBe("foo");
  expect(barSpy()).toBe("bar");
  expect(bazSpy()).toBe("baz");
});

使用 mock.restore() 可以通過在每個測試文件的 afterEach 塊中甚至測試預加載代碼中添加它來減少測試中的代碼量。

Vitest 兼容性

為了與為 Vitest 編寫的測試更好地兼容,Bun 提供 vi 全局對象作為 Jest 模擬 API 部分的別名:

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

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

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

  // vi 對象上提供以下函數:
  // vi.fn
  // vi.spyOn
  // vi.mock
  // vi.restoreAllMocks
  // vi.clearAllMocks
});

這使得將測試從 Vitest 移植到 Bun 更容易,而不必重寫所有模擬。

實現細節

了解 mock.module() 的工作原理有助於你更有效地使用它:

緩存交互

模塊模擬與 ESM 和 CommonJS 模塊緩存交互。

延遲求值

模擬工廠回調僅在模塊實際導入或需要時求值。

路徑解析

Bun 自動解析模塊說明符,就像你進行導入一樣,支持:

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

導入時序效果

  • 首次導入前模擬:不會發生原始模塊的副作用
  • 導入後模擬:原始模塊的副作用已經發生

因此,對於需要防止副作用的模擬,建議使用 --preload

實時綁定

模擬的 ESM 模塊維護實時綁定,因此更改模擬將更新所有現有導入。

高級模式

工廠函數

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

條件模擬

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("條件 API 使用", async () => {
  const { fetchData } = await import("./api");
  const result = await fetchData();

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

模擬清理模式

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

beforeEach(() => {
  // 設置常見模擬
  mock.module("./logger", () => ({
    log: mock(() => {}),
    error: mock(() => {}),
    warn: mock(() => {}),
  }));
});

afterEach(() => {
  // 清理所有模擬
  mock.restore();
  mock.clearAllMocks();
});

最佳實踐

保持模擬簡單

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);
  }
  // ... 大量復雜邏輯
});

使用類型安全的模擬

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

測試模擬行為

ts
test("服務正確調用 API", 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);
});

注意事項

自動模擬

__mocks__ 目錄和自動模擬尚不支持。如果這阻礙了你切換到 Bun,請 提交問題

ESM 與 CommonJS

模塊模擬對 ESM 和 CommonJS 模塊有不同的實現。對於 ES 模塊,Bun 已向 JavaScriptCore 添加了補丁,允許 Bun 在運行時覆蓋導出值並遞歸更新實時綁定。

Bun學習網由www.bunjs.com.cn整理維護