模拟对于测试至关重要,它允许你用受控的实现替换依赖项。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 在运行时覆盖导出值并递归更新实时绑定。