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整理维护