Mocks
Learn how to create and use mock functions, spies, and module mocks in Bun tests
Mocking replaces a dependency with a controlled implementation. Bun supports function mocks, spies, and module mocks.
Basic Function Mocks#
Create mocks with the mock function.
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 Compatibility#
You can also use jest.fn(), as in Jest. It behaves identically.
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 Function Properties#
mock() returns a new function decorated with additional properties.
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 }
// ]Available Properties and Methods#
Mock functions implement the following properties and methods:
| Property/Method | Description |
|---|---|
mockFn.getMockName() | Returns the mock name |
mockFn.getMockImplementation() | Returns the implementation as a function |
mockFn.mock.calls | Array of call arguments for each invocation |
mockFn.mock.results | Array of return values for each invocation |
mockFn.mock.settledResults | Resolved or rejected value for each invocation |
mockFn.mock.instances | this for each call (the instance for new) |
mockFn.mock.contexts | Array of this contexts for each invocation |
mockFn.mock.lastCall | Arguments of the most recent call |
mockFn.mockClear() | Clears call history |
mockFn.mockReset() | Clears call history and removes implementation. After vi.fn() and vi.spyOn(), goes back to the original |
mockFn.mockRestore() | Restores original implementation |
mockFn.mockImplementation(fn) | Sets a new implementation |
mockFn.mockImplementationOnce(fn) | Sets implementation for next call only |
mockFn.mockName(name) | Sets the mock name |
mockFn.mockReturnThis() | Sets the return value to this |
mockFn.mockReturnValue(value) | Sets a return value |
mockFn.mockReturnValueOnce(value) | Sets return value for next call only |
mockFn.mockResolvedValue(value) | Sets a resolved Promise value |
mockFn.mockResolvedValueOnce(value) | Sets resolved Promise for next call only |
mockFn.mockRejectedValue(value) | Sets a rejected Promise value |
mockFn.mockRejectedValueOnce(value) | Sets rejected Promise for next call only |
mockFn.mockThrow(value) | Throws value on every call |
mockFn.mockThrowOnce(value) | Throws value on the next call only |
mockFn.withImplementation(fn, callback) | Temporarily changes implementation |
Practical Examples#
Basic Mock Usage#
import { test, expect, mock } from "bun:test";
test("mock function behavior", () => {
const mockFn = mock((x: number) => x * 2);
// Call the mock
const result1 = mockFn(5);
const result2 = mockFn(10);
// Verify calls
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith(5);
expect(mockFn).toHaveBeenLastCalledWith(10);
// Check results
expect(result1).toBe(10);
expect(result2).toBe(20);
// Inspect call history
expect(mockFn.mock.calls).toEqual([[5], [10]]);
expect(mockFn.mock.results).toEqual([
{ type: "return", value: 10 },
{ type: "return", value: 20 },
]);
});Dynamic Mock Implementations#
import { test, expect, mock } from "bun:test";
test("dynamic mock implementations", () => {
const mockFn = mock();
// Set different implementations
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"); // Uses default implementation
});Async Mocks#
import { test, expect, mock } from "bun:test";
test("async mock functions", async () => {
const asyncMock = mock();
// Mock resolved values
asyncMock.mockResolvedValueOnce("first result");
asyncMock.mockResolvedValue("default result");
expect(await asyncMock()).toBe("first result");
expect(await asyncMock()).toBe("default result");
// Mock rejected values
const rejectMock = mock();
rejectMock.mockRejectedValue(new Error("Mock error"));
await expect(rejectMock()).rejects.toThrow("Mock error");
});Spies with spyOn()#
Use spyOn() to track calls to a function without replacing it with a mock. You can pass spies to .toHaveBeenCalled() and .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);
});Advanced Spy Usage#
import { test, expect, spyOn, afterEach, jest } from "bun:test";
class UserService {
async getUser(id: string) {
// Original implementation
return { id, name: `User ${id}` };
}
async saveUser(user: any) {
// Original implementation
return { ...user, saved: true };
}
}
const userService = new UserService();
afterEach(() => {
// Restore all spies after each test
jest.restoreAllMocks();
});
test("spy on service methods", async () => {
// Spy without changing implementation
const getUserSpy = spyOn(userService, "getUser");
const saveUserSpy = spyOn(userService, "saveUser");
// Use the service normally
const user = await userService.getUser("123");
await userService.saveUser(user);
// Verify calls
expect(getUserSpy).toHaveBeenCalledWith("123");
expect(saveUserSpy).toHaveBeenCalledWith(user);
});
test("spy with mock implementation", async () => {
// Spy and override implementation
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");
});Getters and Setters#
Pass "get" or "set" as the third argument to spy on a property's getter or setter. The spy calls the original accessor until you give it another implementation. mockRestore() restores the original property.
import { test, expect, spyOn } from "bun:test";
const config = {
get mode() {
return "production";
},
};
test("spy on a getter", () => {
const mode = spyOn(config, "mode", "get").mockReturnValue("test");
expect(config.mode).toBe("test");
expect(mode).toHaveBeenCalledTimes(1);
mode.mockRestore();
expect(config.mode).toBe("production");
});This also works on plain properties, such as spyOn(process, "platform", "get"), and on accessors inherited from a class.
Classes#
Calling a mock with new constructs its implementation, so you can mock or spy on a class the same way as a function.
import { test, expect, mock } from "bun:test";
const Dog = mock(
class {
name: string;
constructor(name: string) {
this.name = name;
}
speak() {
return "woof";
}
},
);
test("mock a class", () => {
const dog = new Dog("Rex");
expect(dog.speak()).toBe("woof");
expect(Dog).toHaveBeenCalledWith("Rex");
expect(Dog.mock.instances[0]).toBe(dog);
});Mock an Object with vi.mockObject()#
vi.mockObject(value) returns a deep copy of value with every function and class replaced by a mock that returns undefined. Arrays become empty. Primitives and built-in objects like Date and Map are kept.
import { test, expect, vi } from "bun:test";
test("mock an object", () => {
const api = vi.mockObject({
version: 2,
fetchUser: (id: string) => ({ id }),
});
expect(api.fetchUser("1")).toBeUndefined();
expect(api.fetchUser).toHaveBeenCalledWith("1");
expect(api.version).toBe(2);
});With { spy: true }, functions keep their implementation, and value is modified in place instead of copied.
Module Mocks with mock.module()#
Use mock.module(path: string, callback: () => Object) to override the behavior of a module.
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");
});Like the rest of Bun, module mocks support both import and require. require() can't wait for an async factory. If the factory hasn't been called yet, require() of the mocked module returns the original, and require() of a module that imports it throws.
Overriding Already Imported Modules#
Calling mock.module() overrides the module even if it has already been imported.
import { test, expect, mock } from "bun:test";
// The module we're going to mock is here:
import { foo } from "./module";
test("mock.module", async () => {
const cjs = require("./module");
expect(foo).toBe("bar");
expect(cjs.foo).toBe("bar");
// We update it here:
mock.module("./module", () => {
return {
foo: "baz",
};
});
// And the live bindings are updated.
expect(foo).toBe("baz");
// The module is also updated for CJS.
expect(cjs.foo).toBe("baz");
});When the factory returns a promise that is still pending, Bun updates the already-imported module once the promise settles. In that case mock.module() returns a promise that resolves after the update, and otherwise it returns undefined. await mock.module(...) inside a test or hook covers both. At the top level of a test file, Bun waits for pending module mocks before it runs the file's tests.
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" }Keeping Part of the Original Module#
The factory is passed a function that loads the original module. Spread its result to replace only some exports.
import { test, expect, vi } from "bun:test";
import { add, subtract } from "./math";
vi.mock("./math", async importOriginal => ({
...(await importOriginal()),
add: vi.fn(() => 42),
}));
test("partial mock", () => {
expect(add(1, 2)).toBe(42);
expect(subtract(2, 1)).toBe(1);
});Outside a factory, use await vi.importActual("./math") or jest.requireActual("./math") to load the original module. That doesn't call the factory.
This also works when the mocked module is part of an import cycle. The modules that import it still get the mock.
If the factory loads another module with import(), require() or vi.importActual(), and that module imports the one being mocked, it gets the original instead of waiting for the mock. That copy is private to the factory. The rest of the program loads the module again and gets the mock.
When one import leads to several mocked modules, Bun calls their factories one at a time, in the order the modules are imported, as Vitest does. A factory that imports another mocked module gets that mock, whose factory is called first if it hasn't been.
Getters and Missing Exports#
Bun reads an export from the object the factory returned every time it's used, so getters stay live. When a mock updates a loaded module in place, each getter is read once, at that moment.
import { test, expect, vi } from "bun:test";
import { enabled } from "./flags";
const state = vi.hoisted(() => ({ enabled: false }));
vi.mock("./flags", () => ({
get enabled() {
return state.enabled;
},
}));
test("getter", () => {
expect(enabled).toBe(false);
state.enabled = true;
expect(enabled).toBe(true);
});You can also write to the mocked module, whether you got it from import * as, import() or vi.importMock(). The write changes the object the factory returned, so every importer sees it.
const flags = await vi.importMock("./flags");
flags.enabled = true;A mock made by a --preload script is shared by every test file, so a write to it is still there in the next file. With --isolate, each file gets a fresh one.
If a module imports a name the factory didn't return, the module still loads. With vi.mock() and vi.doMock(), using that name throws an error that says which export is missing from which mock, as in Vitest. With mock.module(), jest.mock() and jest.doMock(), the name is undefined. As in Jest, a default import of a jest.mock() or jest.doMock() factory's object is the object itself, unless it has __esModule: true. A name that reaches the module through another module's export * from or export { name } from is undefined instead, as in Vitest.
Mocking a Package#
vi.mock() and vi.doMock() mock a package for your own code only, as in Vitest. A module inside node_modules that imports the package gets the real one. jest.mock() and mock.module() mock it for every importer, as in Jest.
The exception is a .js file that uses import or export in a package without "type": "module". Node would read it as CommonJS, so Vitest loads it like your own code, and it gets the mock.
vi.mock("dep", () => ({ default: fake })); // a library that requires "dep" still gets the real "dep"
jest.mock("dep", () => fake); // the library gets the fake tooIf the package is already loaded, what imported it keeps the real one, and the next import() or require() from your own code calls the factory. Modules that an earlier test file loaded are loaded again and get the mock, because in Vitest each file starts with nothing loaded. The real package is never loaded a second time: vi.importActual() returns the loaded one, and vi.doUnmock() brings it back.
The exception is a vi.mock() call in a module that is imported with a static import (see "Hoisting & Preloading" below). By the time that module runs, its importer's other imports are already linked to the real package. Bun updates the loaded package in place, so the test file and the modules it imports get the mock. A module inside node_modules that imports the package gets the mock too. So it does when a --preload script mocks a package that an earlier preload has loaded.
A mocked builtin such as node:fs is mocked for every importer with all of them.
Mocking Without a Factory#
You can call vi.mock(path) and jest.mock(path) without a factory. mock.module() always needs one.
- If a
__mocks__directory has a file with the same name, Bun loads that file instead. For a file, that's__mocks__/<file>in the same directory. For a package or a builtin likefs, it's__mocks__/<package>in the directory wherebun testruns. - Otherwise, Bun loads the original module and mocks its exports. Functions and classes become mocks that return
undefined, arrays become empty, and other values are kept. vi.mock(path, { spy: true })keeps every function's implementation and records its calls.
import { test, expect, vi } from "bun:test";
import { add } from "./math";
vi.mock("./math");
test("automock", () => {
expect(add(1, 2)).toBeUndefined();
expect(add).toHaveBeenCalledWith(1, 2);
});vi.importMock(path), jest.requireMock(path) and jest.createMockFromModule(path) return the same kind of mock without mocking the module.
Removing a Mock#
| Function | What it does |
|---|---|
vi.unmock, vi.doUnmock, jest.unmock, jest.dontMock | Removes the module's mock |
using _ = vi.doMock(path, factory) | Removes the mock at the end of the block |
vi.resetModules, jest.resetModules | Re-evaluates each module on its next import() or require() |
Mocked modules stay mocked after resetModules(). After jest.resetModules(), the next import also calls the factory again. Existing imports keep their values.
As in Vitest, vi.resetModules() leaves packages in node_modules loaded, so a module imported again still shares its copy of react with the rest of the test. It does load the .js files again that Vitest loads like your own code (see Mocking a Package). As in Jest, jest.resetModules() loads them again too.
mock.restore() doesn't remove module mocks.
Module Mocks Are Removed After Each Test File#
Module mocks are removed when the test file ends. The next file gets the real module, and modules that imported the mock are re-evaluated. So is a module that made a mock, so a helper that calls vi.mock() mocks for every test file that imports it. Other modules stay cached between files.
Mocks made in a --preload script apply to every test file.
A test file can mock a module that the preload script already mocked. With vi.mock() and vi.doMock(), only the modules the test file imports get the file's mock. Modules the preload script imported keep what they imported, as in Vitest. With mock.module() and jest.mock(), the modules the preload script imported get the file's mock too.
Hoisting & Preloading#
Top-level vi.mock(), vi.unmock(), vi.hoisted(), jest.mock() and jest.unmock() calls are hoisted above the file's imports, as in Vitest and Jest. This way, a module is mocked before the file or anything it imports loads it.
A hoisted factory can't use the file's top-level variables, because they aren't initialized yet. Declare what the factory needs with vi.hoisted(). A const or let that only holds strings, numbers or other primitive literals is hoisted along with the calls, as in Jest.
import { test, expect, vi } from "bun:test";
import { add } from "./math";
const mocks = vi.hoisted(() => ({ add: vi.fn(() => 42) }));
vi.mock("./math", () => ({ add: mocks.add }));
test("hoisted", () => {
expect(add(1, 2)).toBe(42);
expect(mocks.add).toHaveBeenCalledWith(1, 2);
});mock.module(), vi.doMock() and jest.doMock() aren't hoisted, and neither are calls inside a block or a function. They run where they're written.
As in Vitest, vi.doMock() leaves a module that's already imported as it is. Whatever imported it keeps the real module. The next import() calls the factory and gets the mock. After vi.doUnmock(), an import() gets the module that was loaded before. mock.module() and jest.doMock() update the loaded module instead, so its importers get the mock too.
In a file with hoisted calls, Bun turns the other imports into await import() and reads them through the module namespace, so they stay live. The exceptions are imports from bun:test, vitest, @jest/globals and bun, imports with attributes, import defer, export ... from, and imports the file re-exports. Those stay static.
Bun does the same in a test file or a --preload script that has no hoisted call, if it imports vitest or runs with globals = "vitest". Vitest runs each import before it looks at the next one, so a module that the file imports first can mock what the next import loads:
import "./mocks"; // calls vi.mock("./tax", ...)
import { total } from "./cart"; // imports "./tax", and gets the mockAs in Vitest, an import of a name that the module doesn't export is undefined in such a file, and not a SyntaxError.
To mock a module for every test file, use --preload to load your mocks before your tests run.
import { mock } from "bun:test";
mock.module("./module", () => {
return {
foo: "bar",
};
});bun test --preload ./my-preloadTo avoid typing --preload every time you run tests, add it to your bunfig.toml:
[test]
# Load these modules before running tests.
preload = ["./my-preload"]Module Mock Best Practices#
When to Use Preload#
Mocking a module that's already been imported updates the module cache, so anything that imports it gets the mocked version. Bun has already evaluated the original module, though, so its side effects have already happened.
To prevent the original module from being evaluated at all, mock it before it's imported, with a factory that doesn't declare importOriginal (see Import Timing Effects). Use vi.mock() or jest.mock(), which are hoisted, or mock it in a --preload script.
Practical Module Mock Examples#
import { test, expect, mock, beforeEach } from "bun:test";
// Mock the API client module
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");
});Mocking External Dependencies#
import { test, expect, mock } from "bun:test";
// Mock external database library
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");
});Global Mock Functions#
Clear All Mocks#
mock.clearAllMocks() resets the .mock.calls, .mock.instances, .mock.contexts, and .mock.results properties of every mock. Unlike mock.restore(), it does not restore the original implementation:
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);
// Note: implementations are preserved
expect(typeof random1()).toBe("number");
expect(typeof random2()).toBe("number");
});Reset All Mocks#
jest.resetAllMocks() (and its vi.resetAllMocks() alias) calls mockFn.mockReset() on every mock. On top of what clearAllMocks() does, it drops the implementations set by mockImplementation(), mockReturnValue() and friends. It does not restore the original implementation of a spy:
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);
// unlike clearAllMocks(), the implementation is gone
expect(random()).toBeUndefined();
});Mocks made with vi follow Vitest instead. A vi.fn(implementation) mock goes back to implementation, and a vi.spyOn() spy goes back to the original method.
Restore All Mocks#
mock.restore() restores every mock at once, instead of calling mockFn.mockRestore() on each one. It does not reset modules overridden with 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");
// Original implementations still work
expect(fooModule.foo()).toBe("foo");
expect(barModule.bar()).toBe("bar");
expect(bazModule.baz()).toBe("baz");
// Mock implementations
fooSpy.mockImplementation(() => 42);
barSpy.mockImplementation(() => 43);
bazSpy.mockImplementation(() => 44);
expect(fooModule.foo()).toBe(42);
expect(barModule.bar()).toBe(43);
expect(bazModule.baz()).toBe(44);
// Restore all
mock.restore();
expect(fooModule.foo()).toBe("foo");
expect(barModule.bar()).toBe("bar");
expect(bazModule.baz()).toBe("baz");
});Call mock.restore() in an afterEach block, or in your test preload script, instead of repeating cleanup in every test.
Cleanup After Each Test File#
When a test file ends, Bun restores any spies that are still active and clears every mock's recorded calls. Return values and implementations set with mockReturnValue(), mockImplementation() and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next.
Spies and mocks made in a --preload script, or in one of its beforeAll() hooks, aren't restored or cleared. They stay the same for every test file.
Vitest Compatibility#
Bun provides the vi object for tests written for Vitest. Where Vitest and Jest differ, mocks made with vi.fn() and vi.spyOn() behave as in Vitest:
mockReset()goes back to the original implementation.vi.restoreAllMocks()keeps the recorded calls of the spies it restores.fn.mockis still the same object aftermockClear().getMockImplementation()returns what the next call runs, so amockReturnValueOnce()that is still waiting comes first.
import { test, expect, vi } from "bun:test";
// Using the 'vi' alias similar to Vitest
test("vitest compatibility", () => {
const mockFn = vi.fn(() => 42);
mockFn();
expect(mockFn).toHaveBeenCalled();
});vi.mocked(fn) returns fn typed as a mock. vi.isMockFunction(fn) returns whether fn is a mock.
For module mocks, vi has mock, doMock, unmock, doUnmock, importActual, importMock, resetModules and hoisted. jest has mock, doMock, unmock, dontMock, requireActual, requireMock, createMockFromModule and resetModules.
Stubs and Waiting#
import { test, expect, vi, afterEach } from "bun:test";
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
test("stubs", () => {
vi.stubEnv("API_URL", "http://localhost:3000");
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("mocked")),
);
});
test("waits", async () => {
let ready = false;
setTimeout(() => (ready = true), 50);
await vi.waitFor(() => expect(ready).toBe(true), { timeout: 500, interval: 20 });
const value = await vi.waitUntil(() => ready && "ready");
});
test("lazy import", async () => {
import("./lazy.ts").then(({ render }) => render());
await vi.dynamicImportSettled();
});- Stubs that are still in place are undone when the test file ends. Stubs made by a
--preloadscript apply to every file. vi.waitFor()andvi.waitUntil()wait in real time. If fake timers are on, they also advance the fake clock byintervalbefore each call to the callback.
Differences from Vitest#
- A
vi.mock()call that isn't hoisted, such as one inside a function, updates a module of your own that's already imported in place. Its existing exports change, exports that only the mock has aren't added, and getters are read once. A module that onlyvi.importActual()has loaded stays the original, and the next import calls the factory. - To update a module in place, Bun calls the factory when you mock the module, not at the next import. If the factory returns a promise, an
import()of the module doesn't wait for it. Await whatvi.mock()returns instead.vi.doMock()does the same for a builtin module such asnode:fs. - After
vi.doUnmock(), a module that imported the mock is evaluated again at its next import, and gets the real module. After a secondvi.doMock()of the same module, it gets the new mock. In Vitest it stays cached with the first mock untilvi.resetModules(). - Without
--isolate, test files share the modules they load. Forvi.doMock(), a module that an earlier file loaded counts as not loaded, even if this file imports it too.vi.importActual()andimportOriginalevaluate it again. - Only test files and
--preloadscripts run their imports one at a time. Any other module's imports are all linked before the first one runs, as are those of a test file that usesbun:testand has no hoisted call. If one of them callsvi.mock()for a module that another one imports, that module is updated in place. - If a module that is imported with a static import calls
vi.mock()with an async factory, a module that is imported after it can run before the factory settles. What that module reads from the mocked module while it loads is the original. What it reads later is the mock. - Where a loaded package is updated in place (see Mocking a Package), a module inside
node_modulesthat imports it gets the mock. In Vitest it gets the real package. - If a module that is imported with a static import calls
vi.mock()for a module that is waiting for an import with an async factory, the original still runs, and assigns itsconst,letandclassexports. A module that reads one of those while it loads gets the original. What it reads later is the mock, and so are thefunctionexports all along. - Every module outside
node_modulesthat imports the mocked module gets the mock. In Vitest, a module that's first loaded while the factory runs gets the original and keeps it. - If two factories import each other's modules, the one that asks last gets the original. Vitest calls one of the two factories a second time instead.
- A factory that uses
importOriginalis called after the original module is evaluated, not before, so after the factories that don't use it. A factory that declares the parameter counts as using it, so the original is evaluated even if the factory never calls it. export * froma mocked module re-exports the original's export names if the factory usesimportOriginal. Names the factory adds aren't re-exported, and names it leaves out are re-exported asundefined.- If two files import each other and both have hoisted calls, they wait on each other and the run fails with an error that names both files. Keep the hoisted calls in only one of them.
Implementation Details#
Cache Interaction#
Module mocks interact with both ESM and CommonJS module caches.
Lazy Evaluation#
Bun calls the factory at the next import or require() that gets the mock. If the mock updates a loaded module in place, Bun calls it when you mock the module instead. mock.module(), jest.mock() and jest.doMock() do that with a loaded module, and so do some vi.mock() and vi.doMock() calls (see Differences from Vitest).
Path Resolution#
Bun resolves the module specifier the same way it resolves an import, supporting:
- Relative paths (
'./module') - Absolute paths (
'/path/to/module') - Package names (
'lodash')
Import Timing Effects#
- When mocking before first import: The original module isn't evaluated, unless something still needs it, such as a factory that declares
importOriginal,vi.importActual(), a mock that has neither a factory nor a__mocks__file, or a module innode_modulesthat imports a package you mock withvi.mock() - When mocking after import: The original module's side effects have already happened
For this reason, use vi.mock(), jest.mock() or --preload for mocks that need to prevent side effects.
Live Bindings#
Mocked ESM modules keep live bindings, so mock.module(), jest.mock() and jest.doMock() update what the existing imports see. For vi.mock() and vi.doMock() it depends on the module and on who loaded it: see Mocking a Package and Differences from Vitest.
Advanced Patterns#
Factory Functions#
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 })),
};Conditional Mocking#
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");
}
});Mock Cleanup Patterns#
import { afterEach, beforeEach, mock } from "bun:test";
beforeEach(() => {
// Set up common mocks
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();
});Best Practices#
Keep Mocks Simple#
// Good: Simple, focused mock
const mockUserApi = {
getUser: mock(async id => ({ id, name: "Test User" })),
};
// Avoid: Overly complex mock behavior
const complexMock = mock(input => {
if (input.type === "A") {
return processTypeA(input);
} else if (input.type === "B") {
return processTypeB(input);
}
// ... lots of complex logic
});Use Type-Safe Mocks#
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 Mock Behavior#
test("service calls API correctly", async () => {
const mockApi = { fetchUser: mock(async () => ({ id: "1" })) };
const service = new UserService(mockApi);
await service.getUser("123");
// Verify the mock was called correctly
expect(mockApi.fetchUser).toHaveBeenCalledWith("123");
expect(mockApi.fetchUser).toHaveBeenCalledTimes(1);
});Notes#
ESM vs CommonJS#
Module mocks have different implementations for ESM and CommonJS modules. For ES modules, Bun patches JavaScriptCore so it can override export values at runtime and update live bindings recursively.