Bun Test Runner Deep Dive: Matchers, Snapshots, Mocks, and Watch Mode

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/utils

The 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 — toThrow and rejects.toThrow make 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:test handles 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 fetch works; 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() or setTimeout, 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.

Read more