Bun Test Runner Deep Dive: Matchers, Snapshots, Mocks, and Watch Mode
Bun ships a built-in test runner (bun:test) that is Jest-compatible by design. It runs TypeScript natively, starts in milliseconds, and covers the full testing surface — matchers, snapshots, mocks, module mocking, and watch mode — without any additional dependencies.
Key Takeaways
bun:test is Jest-compatible. Most Jest tests migrate with zero changes. The expect, describe, it, beforeEach, afterAll globals work identically.
Snapshot testing works out of the box. toMatchSnapshot() and toMatchInlineSnapshot() are built in — no jest-snapshot package needed.
mock.module() replaces jest.mock(). Module-level mocking uses mock.module() hoisted to the top of the test file, just like Jest's jest.mock().
Watch mode restarts only changed test files. bun test --watch tracks file dependencies and reruns the minimum set of tests on each save, keeping feedback loops tight.
TypeScript runs without compilation. Bun strips types natively — no ts-jest, no @babel/preset-typescript, no tsconfig gymnastics required.
Why bun:test Matters
Bun's test runner is not an afterthought. It is designed to be the fastest way to run TypeScript tests on a modern JavaScript runtime. There are no devDependencies to install — bun:test ships inside the bun binary. The tradeoff you get: start time measured in tens of milliseconds instead of seconds, and a Jest API surface large enough that most existing test suites run without modification.
This post covers the complete bun:test API with realistic TypeScript examples, focusing on the parts that differ from Jest or require specific Bun idioms.
Running Tests
The most basic invocation:
bun test <span class="hljs-comment"># run all *.test.ts / *.spec.ts files
bun <span class="hljs-built_in">test src/utils <span class="hljs-comment"># run tests in a directory
bun <span class="hljs-built_in">test --watch <span class="hljs-comment"># watch mode
bun <span class="hljs-built_in">test --coverage <span class="hljs-comment"># line/branch/function coverage
bun <span class="hljs-built_in">test --bail <span class="hljs-comment"># stop after first failure
bun <span class="hljs-built_in">test --<span class="hljs-built_in">timeout 10000 <span class="hljs-comment"># per-test timeout in ms (default: 5000)Bun discovers test files by looking for *.test.{ts,tsx,js,jsx} and *.spec.{ts,tsx,js,jsx} patterns recursively from the working directory.
The Full Matcher API
Equality and Identity
import { expect, test } from "bun:test";
test("equality matchers", () => {
// strict equality (===)
expect(1 + 1).toBe(2);
expect("hello").toBe("hello");
// deep equality (recursive)
expect({ a: 1, b: [2, 3] }).toEqual({ a: 1, b: [2, 3] });
// subset matching — object must contain these keys
expect({ a: 1, b: 2, c: 3 }).toMatchObject({ a: 1, b: 2 });
// reference equality
const obj = { x: 1 };
expect(obj).toBe(obj);
expect(obj).not.toBe({ x: 1 }); // different reference
});Type and Existence Checks
test("type matchers", () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect("hello").toBeDefined();
expect(true).toBeTruthy();
expect(0).toBeFalsy();
expect([1, 2]).toBeInstanceOf(Array);
expect(typeof "hello").toBe("string");
});Number Matchers
test("number matchers", () => {
expect(5).toBeGreaterThan(4);
expect(5).toBeGreaterThanOrEqual(5);
expect(3).toBeLessThan(4);
expect(3).toBeLessThanOrEqual(3);
// floating point — avoid toBe for floats
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 5 decimal places
});String and Array Matchers
test("string matchers", () => {
expect("Hello, World").toContain("World");
expect("foo@bar.com").toMatch(/^\w+@\w+\.\w+$/);
expect("hello").toHaveLength(5);
});
test("array matchers", () => {
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 3]));
});Error Matchers
function divide(a: number, b: number): number {
if (b === 0) throw new RangeError("Cannot divide by zero");
return a / b;
}
test("error matchers", () => {
// synchronous throw
expect(() => divide(1, 0)).toThrow();
expect(() => divide(1, 0)).toThrow("Cannot divide by zero");
expect(() => divide(1, 0)).toThrow(RangeError);
// async throw
const failingAsync = async () => {
throw new TypeError("async failure");
};
await expect(failingAsync()).rejects.toThrow(TypeError);
await expect(failingAsync()).rejects.toThrow("async failure");
// promise rejection
await expect(Promise.reject(new Error("oops"))).rejects.toThrow("oops");
});Snapshot Testing
File-Based Snapshots
Bun writes snapshots to a __snapshots__ directory next to the test file, in .snap files that are identical in format to Jest's.
import { expect, test } from "bun:test";
interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
function formatUser(user: User): string {
return `${user.name} <${user.email}>`;
}
test("user formatting snapshot", () => {
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
createdAt: "2026-01-01",
};
// first run: writes the snapshot
// subsequent runs: compares against it
expect(formatUser(user)).toMatchSnapshot();
expect(user).toMatchSnapshot();
});Update snapshots with bun test --update-snapshots.
Inline Snapshots
Inline snapshots are written directly into the test file on first run:
test("inline snapshot", () => {
const result = { status: "ok", count: 42 };
// bun writes the value here on first run:
expect(result).toMatchInlineSnapshot(`
{
"count": 42,
"status": "ok",
}
`);
});Inline snapshots are useful for small objects where you want the expected value visible in the test without jumping to a .snap file.
The Mock API
Function Mocks with mock()
import { expect, test, mock } from "bun:test";
test("mock a function", () => {
const add = mock((a: number, b: number) => a + b);
expect(add(2, 3)).toBe(5);
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith(2, 3);
expect(add).toHaveReturnedWith(5);
});spyOn()
spyOn wraps an existing method on an object, recording calls while preserving (or replacing) the implementation:
import { expect, test, spyOn } from "bun:test";
const logger = {
log: (msg: string) => console.log(`[LOG] ${msg}`),
};
test("spyOn existing method", () => {
const spy = spyOn(logger, "log");
logger.log("hello");
logger.log("world");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(1, "hello");
expect(spy).toHaveBeenLastCalledWith("world");
spy.mockRestore(); // restore original implementation
});mockImplementation() and mockReturnValue()
import { expect, test, mock } from "bun:test";
test("mockImplementation", () => {
const fetchUser = mock(() => ({ id: 1, name: "Alice" }));
fetchUser.mockImplementation(() => ({ id: 2, name: "Bob" }));
expect(fetchUser()).toEqual({ id: 2, name: "Bob" });
fetchUser.mockImplementationOnce(() => ({ id: 3, name: "Carol" }));
expect(fetchUser()).toEqual({ id: 3, name: "Carol" });
expect(fetchUser()).toEqual({ id: 2, name: "Bob" }); // reverts
fetchUser.mockReturnValue({ id: 99, name: "Static" });
expect(fetchUser()).toEqual({ id: 99, name: "Static" });
});Module Mocking
Module mocking in bun:test uses mock.module(). Unlike mock(), it replaces the entire module resolution for the test file.
import { expect, test, mock } from "bun:test";
// mock.module must be called before the import it affects
mock.module("./sendEmail", () => ({
sendEmail: mock(async () => ({ success: true })),
}));
import { sendEmail } from "./sendEmail";
import { registerUser } from "./registerUser";
test("registerUser calls sendEmail", async () => {
await registerUser({ email: "test@example.com", name: "Test" });
expect(sendEmail).toHaveBeenCalledTimes(1);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: "test@example.com" })
);
});For third-party packages, use the package name:
mock.module("node-fetch", () => ({
default: mock(async () => ({
ok: true,
json: async () => ({ data: "mocked" }),
})),
}));Watch Mode
bun test --watch re-runs affected test files on every file save. Bun tracks the dependency graph between test files and their imports, so saving utils.ts reruns only the tests that import it — not the entire suite.
bun test --watch <span class="hljs-comment"># watch all test files
bun <span class="hljs-built_in">test --watch src/utils <span class="hljs-comment"># watch only tests in src/utilsThe watch output shows which files triggered the rerun and how many tests passed or failed in that cycle.
Migrating from Jest
Most Jest test suites run under bun:test without changes. The main incompatibilities:
| Jest API | bun:test equivalent | Notes |
|---|---|---|
jest.fn() |
mock() |
Same interface |
jest.spyOn() |
spyOn() |
Same interface |
jest.mock('module') |
mock.module('module', factory) |
Factory is required in bun:test |
jest.useFakeTimers() |
jest.useFakeTimers() (partial) |
Fake timers support is limited |
jest.resetAllMocks() |
mock.restore() per mock |
No global reset yet |
TypeScript configuration is simpler — remove ts-jest and any transform config from your Jest config. Bun handles TypeScript natively.
Test Lifecycle Hooks
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
describe("database tests", () => {
let db: SomeDatabase;
beforeAll(async () => {
db = await SomeDatabase.connect(":memory:");
await db.migrate();
});
afterAll(async () => {
await db.close();
});
beforeEach(async () => {
await db.seed(testFixtures);
});
afterEach(async () => {
await db.truncate();
});
test("inserts a record", async () => {
const id = await db.insert({ name: "Alice" });
const row = await db.findById(id);
expect(row).toMatchObject({ name: "Alice" });
});
});What to Test vs. What to Skip
Test:
- Pure functions — any function with inputs and outputs is trivial to unit test
- Error branches —
toThrowandrejects.toThrowmake this straightforward - Module contracts — when two modules integrate, test the boundary with module mocking
- Snapshot stability — format functions, serializers, and template generators benefit from snapshot tests
- Async flows —
bun:testhandles async/await natively with no extra config
Skip (or defer to integration tests):
- Internal implementation details — mock the boundary, not the internals of the function under test
- Framework internals — don't test that Bun's
fetchworks; test your code that calls it - Snapshot-every-object — snapshots become a maintenance burden when overused; reserve them for stable output shapes
- Fake timers for non-timing code — if your logic doesn't depend on
Date.now()orsetTimeout, don't introduce fake timers
bun:test gives you a fast, dependency-free foundation for any TypeScript project. The Jest compatibility means you can adopt it incrementally — run bun test in a project with an existing Jest suite and fix the handful of incompatibilities rather than rewriting tests from scratch.