模擬對於測試至關重要,它允許你用受控的實現替換依賴項。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.fn() 函數,就像在 Jest 中一樣。它的行為完全相同。
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 | 每次調用的 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) | 臨時更改實現 |
實用示例
基本模擬用法
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 },
]);
});動態模擬實現
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"); // 使用默認實現
});異步模擬
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()。
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 } 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) 來模擬模塊。
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");
});提升和預加載
如果你需要確保在導入模塊之前模擬模塊,你應該使用 --preload 在測試運行之前加載模擬。
import { mock } from "bun:test";
mock.module("./module", () => {
return {
foo: "bar",
};
});bun test --preload ./my-preload為了讓你的生活更輕松,你可以將 preload 放在 bunfig.toml 中:
[test]
# 在運行測試之前加載這些模塊。
preload = ["./my-preload"]模塊模擬最佳實踐
何時使用預加載
如果我模擬已經導入的模塊會發生什麼?
如果你模擬已經導入的模塊,模塊將在模塊緩存中更新。這意味著任何導入該模塊的模塊都將獲得模擬版本,但原始模塊仍將已被評估。這意味著原始模塊的任何副作用仍將發生。
如果你想防止原始模塊被評估,你應該使用 --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("使用模擬 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("數據庫操作", 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");
});全局模擬函數
清除所有模擬
重置所有模擬函數狀態(調用、結果等),而不恢復其原始實現:
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() 覆蓋的模塊的值。
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 部分的別名:
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 模塊維護實時綁定,因此更改模擬將更新所有現有導入。
高級模式
工廠函數
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("條件 API 使用", async () => {
const { fetchData } = await import("./api");
const result = await fetchData();
if (shouldUseMockApi) {
expect(result.data).toBe("mocked");
}
});模擬清理模式
import { afterEach, beforeEach } from "bun:test";
beforeEach(() => {
// 設置常見模擬
mock.module("./logger", () => ({
log: mock(() => {}),
error: mock(() => {}),
warn: mock(() => {}),
}));
});
afterEach(() => {
// 清理所有模擬
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("服務正確調用 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 在運行時覆蓋導出值並遞歸更新實時綁定。