Mock
學習如何在 Bun 測試中建立和使用模擬函式、間諜以及模組模擬
模擬會使用受控的實現替換相依項。Bun 支援函式模擬、間諜以及模組模擬。
基礎函式模擬
使用 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);
});
Jest 相容性
你也可以像在 Jest 中一樣使用 jest.fn()。它的行為完全相同。
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() 傳回一個帶有附加屬性的新函式。
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) | 臨時更改實現 |
實際範例
基礎模擬用法
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 },
]);
});
動態模擬實現
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"); // 使用預設實現
});
非同步模擬
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()。
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);
});
高階間諜用法
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) 覆蓋模組的行為。
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() 會覆蓋模組,即使該模組已經被匯入。
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 會等待待定的模組模擬完成後再執行檔案中的測試。
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");
});
{ "foo": "baz" }
提升與預載入
為了確保模組在匯入之前已被模擬,請使用 --preload 在測試執行前載入模擬模組。
import { mock } from "bun:test";
mock.module("./module", () => {
return {
foo: "bar",
};
});
bun test --preload ./my-preload
為了避免每次執行測試時都輸入 --preload,請將其新增到 bunfig.toml 中:
[test]
# 在執行測試前載入這些模組。
preload = ["./my-preload"]
模組模擬最佳實踐
何時使用預載入
模擬已匯入的模組會更新模組快取,因此任何匯入該模組的內容都會獲取模擬版本。不過,Bun 已經執行了原始模組,因此其副作用也已經發生。
若要完全避免執行原始模組,請使用 --preload 在測試執行前載入模擬模組。
實際模組模擬範例
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");
});
模擬外部相依
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() 不同,它不會恢復原始實現:
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() 等方法設定的實現。它不會恢復間諜的原始實現:
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() 覆蓋的模組。
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 部分的別名:
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 模組會維護即時繫結,因此更改模擬內容會更新所有現有匯入。
高階模式
工廠函式
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 })),
};
條件式模擬
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");
}
});
模擬清理模式
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();
});
最佳實踐
保持模擬簡單
// 好:簡單、聚焦的模擬
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);
}
// ...很多複雜邏輯
});
使用型別安全的模擬
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("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,以便在執行環境覆蓋匯出值,並遞迴更新即時繫結。