← All articles
TESTING The Complete Guide to Bun's Built-in Test Runner 2026-02-15 · 7 min read · bun · testing · test-runner

The Complete Guide to Bun's Built-in Test Runner

Testing 2026-02-15 · 7 min read bun testing test-runner jest typescript javascript unit-testing tdd

If you're already using Bun as your runtime or package manager, you might not realize it ships with a full-featured test runner built in. No extra dependencies, no configuration files, no transpilation step. Just bun test and your TypeScript tests run natively at speeds that make Jest feel like it's stuck in traffic.

Bun logo

Why Use Bun's Test Runner?

The pitch is simple: Bun's test runner runs TypeScript natively (no ts-jest, no babel, no esbuild plugin), uses a Jest-compatible API (so most of your existing tests work unchanged), and is dramatically faster — typically 10-100x faster than Jest for TypeScript projects.

It's not just faster startup. Bun's test runner:

Getting Started

If you have Bun installed, you already have the test runner. Create a test file:

// math.test.ts
import { expect, test, describe } from "bun:test";

function add(a: number, b: number): number {
  return a + b;
}

describe("add", () => {
  test("adds two positive numbers", () => {
    expect(add(1, 2)).toBe(3);
  });

  test("handles negative numbers", () => {
    expect(add(-1, 1)).toBe(0);
  });

  test("handles zero", () => {
    expect(add(0, 0)).toBe(0);
  });
});

Run it:

bun test

Bun automatically finds test files matching these patterns:

Matchers

Bun implements the full Jest expect() API. Here are the most commonly used matchers:

import { expect, test } from "bun:test";

test("matchers showcase", () => {
  // Equality
  expect(1 + 1).toBe(2);
  expect({ a: 1 }).toEqual({ a: 1 });
  expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });

  // Truthiness
  expect(true).toBeTruthy();
  expect(null).toBeNull();
  expect(undefined).toBeUndefined();
  expect("hello").toBeDefined();

  // Numbers
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect(10).toBeGreaterThan(5);
  expect(3).toBeLessThanOrEqual(3);

  // Strings
  expect("hello world").toContain("world");
  expect("hello world").toMatch(/hello/);

  // Arrays
  expect([1, 2, 3]).toContain(2);
  expect([1, 2, 3]).toHaveLength(3);

  // Errors
  expect(() => { throw new Error("boom"); }).toThrow("boom");
  expect(() => { throw new Error("boom"); }).toThrow(/boom/);
});

Asymmetric Matchers

These are useful when you don't care about exact values in part of a comparison:

test("asymmetric matchers", () => {
  const user = { id: 1, name: "Alice", createdAt: new Date() };

  expect(user).toEqual({
    id: expect.any(Number),
    name: expect.stringContaining("Ali"),
    createdAt: expect.any(Date),
  });

  expect([1, "hello", true]).toEqual(
    expect.arrayContaining([1, true])
  );
});

Lifecycle Hooks

Control setup and teardown with the same hooks as Jest:

import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test";

describe("database tests", () => {
  let db: Database;

  beforeAll(async () => {
    db = await Database.connect();
    await db.migrate();
  });

  beforeEach(async () => {
    await db.beginTransaction();
  });

  afterEach(async () => {
    await db.rollback();
  });

  afterAll(async () => {
    await db.disconnect();
  });

  test("creates a user", async () => {
    const user = await db.users.create({ name: "Alice" });
    expect(user.id).toBeDefined();
  });
});

Hooks can be async and Bun will await them. They run in the expected order: beforeAll once before the suite, beforeEach before each test, afterEach after each test, afterAll once after all tests.

Mocking

Bun provides built-in mocking without needing external libraries.

Function Mocks

import { test, expect, mock } from "bun:test";

test("mock functions", () => {
  const fn = mock((x: number) => x * 2);

  fn(1);
  fn(2);
  fn(3);

  expect(fn).toHaveBeenCalledTimes(3);
  expect(fn).toHaveBeenCalledWith(2);
  expect(fn.mock.results[0].value).toBe(2);
});

Spying on Methods

import { test, expect, spyOn } from "bun:test";

test("spy on console.log", () => {
  const spy = spyOn(console, "log");

  console.log("hello");

  expect(spy).toHaveBeenCalledWith("hello");
  spy.mockRestore();
});

Module Mocking

Mock entire modules with mock.module():

import { test, expect, mock } from "bun:test";

mock.module("./api-client", () => ({
  fetchUser: mock(() => Promise.resolve({ id: 1, name: "Alice" })),
}));

// Now any import of ./api-client gets the mock
import { fetchUser } from "./api-client";

test("uses mocked API client", async () => {
  const user = await fetchUser();
  expect(user.name).toBe("Alice");
});

Snapshot Testing

Snapshot testing works the same as in Jest:

import { test, expect } from "bun:test";

test("renders user card", () => {
  const html = renderUserCard({ name: "Alice", role: "admin" });
  expect(html).toMatchSnapshot();
});

On first run, Bun creates a __snapshots__ directory with .snap files. On subsequent runs, it compares against the saved snapshot.

Update snapshots when your output changes intentionally:

bun test --update-snapshots

Async Testing

Bun handles async tests naturally — return a promise or use async/await:

import { test, expect } from "bun:test";

test("async with await", async () => {
  const response = await fetch("https://api.example.com/health");
  expect(response.status).toBe(200);
});

test("async with promises", () => {
  return fetch("https://api.example.com/health")
    .then((res) => expect(res.status).toBe(200));
});

Timeouts

Set per-test timeouts (in milliseconds):

test("slow operation", async () => {
  const result = await slowOperation();
  expect(result).toBeDefined();
}, 10000); // 10 second timeout

Code Coverage

Generate coverage reports with the --coverage flag:

bun test --coverage

This outputs a summary to the terminal. For CI integration, generate an lcov report:

bun test --coverage --coverage-reporter=lcov

The lcov file is written to coverage/lcov.info, compatible with tools like Codecov, Coveralls, and SonarQube.

Watch Mode

Re-run tests automatically when files change:

bun test --watch

This watches your source and test files for changes and re-runs only the affected tests. It's fast enough that you can keep it running in a terminal while you code.

Filtering Tests

Run specific tests by pattern:

# Run tests matching a name pattern
bun test --test-name-pattern "user"

# Run a specific test file
bun test src/utils/format.test.ts

# Run tests in a specific directory
bun test src/utils/

Skip or focus tests in code:

test.skip("not ready yet", () => {
  // This test won't run
});

test.only("just this one", () => {
  // Only this test runs (in this file)
});

test.todo("write me later");

Configuration

Bun's test runner is configured through bunfig.toml (not a separate config file):

[test]

# Custom test file patterns
root = "./src"

# Preload scripts (setup files that run before tests)
preload = ["./test/setup.ts"]

# Coverage settings
coverage = true
coverageThreshold = { line = 80, function = 80, statement = 80 }

# Timeout in milliseconds
timeout = 5000

# Rerun tests on change
watch = false

Preload Scripts

Setup files that run before any test file — useful for global configuration:

// test/setup.ts
import { afterAll } from "bun:test";

// Global test database connection
globalThis.testDb = await createTestDatabase();

afterAll(async () => {
  await globalThis.testDb.destroy();
});

Migrating from Jest

Most Jest tests work with Bun's test runner with minimal changes:

  1. Change imports from @jest/globals or global jest to bun:test:
// Before (Jest)
import { describe, test, expect, jest } from "@jest/globals";
const mockFn = jest.fn();

// After (Bun)
import { describe, test, expect, mock } from "bun:test";
const mockFn = mock();
  1. Update module mockingjest.mock() becomes mock.module():
// Before (Jest)
jest.mock("./api-client");

// After (Bun)
mock.module("./api-client", () => ({ ... }));
  1. Remove transpilation config — Delete ts-jest config, babel.config.js test presets, and any test-specific tsconfig.json. Bun handles TypeScript natively.

  2. Update scripts in package.json:

{
  "scripts": {
    "test": "bun test",
    "test:watch": "bun test --watch",
    "test:coverage": "bun test --coverage"
  }
}

What Doesn't Work Yet

A few Jest features aren't available in Bun's test runner (as of early 2026):

Migrating from Vitest

Vitest migrations are even simpler since Vitest's API is already Jest-compatible:

// Before (Vitest)
import { describe, test, expect, vi } from "vitest";
const spy = vi.fn();

// After (Bun)
import { describe, test, expect, mock } from "bun:test";
const spy = mock();

The main difference is replacing vi with mock/spyOn from bun:test. Most Vitest tests work with a simple find-and-replace.

Performance Comparison

On a typical TypeScript project with 200 tests:

Runner Cold Start Warm Start Notes
Jest + ts-jest ~8-12s ~4-6s Transpiles every file
Jest + SWC ~3-5s ~2-3s Faster transpiler
Vitest ~2-4s ~1-2s Uses Vite's transform
Bun test ~0.2-0.5s ~0.1-0.3s No transpilation step

The speed difference is most dramatic with TypeScript projects because Bun doesn't need a separate transpilation step — it understands TypeScript natively.

Best Practices

Organize tests next to source filesutils.ts alongside utils.test.ts makes imports simpler and keeps related code together.

Use describe blocks for grouping — Group related tests by feature or function. This makes test output readable and lets you share setup logic via beforeEach.

Prefer toEqual for objectstoBe uses Object.is() (reference equality), which fails for object comparisons. Use toEqual for deep equality checks.

Mock at boundaries — Mock external services, file system calls, and network requests. Don't mock internal functions unless you're testing interaction patterns.

Keep tests fast — Bun's test runner is fast, but slow tests are still slow. Mock expensive operations, use in-memory databases for unit tests, and save real database tests for integration suites.

Conclusion

Bun's test runner eliminates the most frustrating parts of JavaScript testing: slow startup, transpilation configuration, and dependency installation. If you're already using Bun (or considering it), there's very little reason to use a separate test runner. The Jest-compatible API means migration is straightforward, and the speed improvement is immediately noticeable. For new projects, bun test is one of the strongest arguments for adopting Bun as your primary JavaScript runtime.