| | |

Bun 11 ⚡ Writing and Running Unit Tests with Bun’s Native Test Runner

⏱️ Estimated read: 11 min | |

Bun ships with a built-in test runner, and it is not a wrapper around Jest or Vitest. The bun:test module is implemented in the runtime and executed by Bun itself. This means TypeScript and JSX test files run without a transpilation step, the startup cost is eliminated, and the runner is significantly faster than Jest—benchmarks in the community consistently cite 3–10x improvements for equivalent suites . The API is deliberately Jest-compatible: describe, test, expect, beforeEach, and mock are the same names and largely the same semantics. For most projects, migrating from Jest is a matter of changing the runner command from npx jest to bun test .

Key point: bun test recursively discovers files matching *.test.{js,jsx,ts,tsx} and *.spec.{js,jsx,ts,tsx}. Tests are imported from bun:test, not from a global environment. The runner executes each file in the same process, sequentially by default, with a default timeout of 5000 milliseconds per test.


Why Bun’s test runner exists

The configuration problem. Jest requires a configuration file, a transform pipeline (Babel, ts-jest, or swc), and a module resolution setup. Vitest requires a Vite configuration. Bun requires neither. The runner reads TypeScript and JSX natively, resolves modules using Bun’s own resolver, and discovers test files by convention. A project can run bun test without any test configuration at all .

The speed problem. Jest runs each test file in a separate worker process, which incurs startup overhead per file. Vitest is faster but still relies on Vite’s transform pipeline. Bun runs all test files in a single process. The startup cost is paid once, and the TypeScript transpilation is the same in-memory transpiler that bun run uses . For a suite of a hundred test files, the difference is measurable.

The compatibility problem. A new test runner is useless if it cannot run existing tests. Bun’s bun:test module implements the Jest API surface that most projects use: describe, test, expect, the common matchers (toBe, toEqual, toContain, toThrow), lifecycle hooks (beforeAll, beforeEach, afterEach, afterAll), mocks (mock, spyOn), and snapshots (toMatchSnapshot) . The runner also rewrites imports from @jest/globals to bun:test equivalents, and it injects the global test and expect functions if the project relies on them .

The snapshot problem. Snapshot testing in Jest is a common pattern for UI components and serialized output. Bun implements toMatchSnapshot() with the same __snapshots__ directory convention and the same .snap file format. The --update-snapshots flag regenerates the files . The snapshot counter is reset correctly between runs, a bug that was fixed in Bun 1.3.13 .

The trade-off. Bun’s runner is not a 100% replacement for Jest. Some matchers are missing, the jsdom environment is not built in, and the configuration options are limited to what bunfig.toml exposes. For projects with complex Jest configurations, the migration may require adjustments. For projects with straightforward test suites, the switch is trivial.


a. Writing tests with bun:test

A test file imports test, expect, and optionally describe from bun:test. The test function takes a name and a callback. The callback can be synchronous or asynchronous. The expect function returns an object with matchers that assert on the value.

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

// Basic test
test("2 + 2 equals 4", () => {
  expect(2 + 2).toBe(4);
});

// Grouped tests
describe("math", () => {
  test("addition", () => {
    expect(1 + 1).toBe(2);
  });

  test("multiplication", () => {
    expect(2 * 2).toBe(4);
  });
});

The toBe matcher performs strict equality (Object.is). The toEqual matcher performs deep equality, which is the correct choice for objects and arrays. The toContain matcher checks for substring presence in strings or element presence in arrays. The toThrow matcher checks that a function throws an error, optionally with a specific message or error class .

Async tests are supported by returning a Promise from the callback or using an async function. The runner awaits the Promise and fails the test if it rejects.

test("async operation", async () => {
  const result = await Promise.resolve(42);
  expect(result).toBe(42);
});

The test.skip, test.only, test.todo, and test.failing modifiers control which tests run. test.skip excludes a test. test.only runs only that test and ignores the rest. test.todo marks a test as not yet implemented. test.failing asserts that the test fails, which is useful for documenting known bugs .


b. Running tests and filtering

The bun test command discovers test files and runs them. A positional argument filters the files by path substring. The -t or --test-name-pattern flag filters by test name.

# Run all tests
bun test

# Run tests in a specific file
bun test ./src/math.test.ts

# Run tests whose path contains "auth"
bun test auth

# Run tests whose name matches "addition"
bun test -t addition

The output lists each test with a checkmark or cross, the execution time, and a summary of passes, failures, and expect calls. The summary includes the total number of tests and files .

The --watch flag reruns the tests when a file changes. The runner watches the test files and their imported source files, and reruns only the affected tests. The --bail flag stops the run after a specified number of failures, which is useful in CI to avoid spending time on subsequent tests after an early failure .

The --coverage flag generates a coverage report. The --timeout flag sets the per-test timeout in milliseconds. The --concurrent flag runs async tests in parallel, with a default maximum concurrency of 20, controlled by --max-concurrency .


c. Lifecycle hooks, mocks, and configuration

The lifecycle hooks are beforeAll, beforeEach, afterEach, and afterAll. They run in the order implied by their names: beforeAll once before the suite, beforeEach before each test, afterEach after each test, and afterAll once after the suite.

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

let db;

beforeAll(() => {
  db = connect();
});

beforeEach(() => {
  db.clear();
});

afterAll(() => {
  db.close();
});

The mock function creates a mock function. It records calls, arguments, and return values. The spyOn function wraps an existing object method and records calls without replacing the implementation. The mock.module function replaces an entire module with a mock implementation, which is useful for isolating the code under test from external dependencies .

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

test("mock function", () => {
  const fn = mock((x: number) => x * 2);
  fn(5);
  expect(fn).toHaveBeenCalledWith(5);
  expect(fn.mock.results[0].value).toBe(10);
});

The bunfig.toml file configures the test runner. The [test] section accepts preload (files to load before tests), coverage (enable coverage), coverageThreshold (minimum percentage), and retry (default retry count) .

[test]
preload = ["./tests/setup.ts"]
coverage = true
coverageThreshold = 0.8

The preload file is the place to set up global mocks, configure the test environment, and load the bun-types/test-globals reference for TypeScript support of the global test and expect functions .


Complete Example Session

# ============================================
# PART 1: CREATE THE TEST FILE
# ============================================

cat > math.test.ts <<'EOF'
import { test, expect, describe } from "bun:test";

describe("math", () => {
  test("addition", () => {
    expect(1 + 1).toBe(2);
  });

  test("multiplication", () => {
    expect(2 * 2).toBe(4);
  });
});
EOF
# ============================================
# PART 2: RUN THE TESTS
# ============================================

bun test
# math.test.ts:
# ✓ addition [0.87ms]
# ✓ multiplication [0.02ms]
#  2 pass
#  0 fail
#  2 expect() calls
# Ran 2 tests across 1 file. [9.00ms]
# ============================================
# PART 3: RUN A SPECIFIC TEST
# ============================================

bun test -t addition
# ✓ addition [1.79ms]
#  1 pass
#  1 filtered out
#  0 fail
# ============================================
# PART 4: ADD LIFECYCLE HOOKS
# ============================================

cat > db.test.ts <<'EOF'
import { test, expect, beforeAll, afterAll, beforeEach } from "bun:test";

let db;

beforeAll(() => {
  db = { items: [] };
});

beforeEach(() => {
  db.items = [];
});

afterAll(() => {
  db = null;
});

test("insert", () => {
  db.items.push("a");
  expect(db.items).toHaveLength(1);
});
EOF
# ============================================
# PART 5: RUN WITH WATCH MODE
# ============================================

bun test --watch
# Tests rerun on file change.
# ============================================
# PART 6: ADD A MOCK
# ============================================

cat > api.test.ts <<'EOF'
import { test, expect, mock } from "bun:test";

test("mock fetch", async () => {
  const fetchMock = mock(() => Promise.resolve({ ok: true }));
  const res = await fetchMock();
  expect(res.ok).toBe(true);
  expect(fetchMock).toHaveBeenCalledTimes(1);
});
EOF
# ============================================
# PART 7: RUN WITH COVERAGE
# ============================================

bun test --coverage
# Generates a coverage report.
# ============================================
# PART 8: CONFIGURE IN BUNFIG.TOML
# ============================================

cat > bunfig.toml <<'EOF'
[test]
preload = ["./tests/setup.ts"]
coverage = true
timeout = 10000
EOF
# ============================================
# PART 9: SNAPSHOT TEST
# ============================================

cat > snap.test.ts <<'EOF'
import { test, expect } from "bun:test";

test("snapshot", () => {
  expect({ foo: "bar" }).toMatchSnapshot();
});
EOF

bun test snap.test.ts
# Creates __snapshots__/snap.test.ts.snap

The nine parts trace the test runner lifecycle: basic test, execution, name filtering, lifecycle hooks, watch mode, mocks, coverage, configuration, and snapshot testing.


Quick Reference

Test Functions

FunctionPurpose
test(name, fn)Define a test
describe(name, fn)Group tests
test.skipSkip a test
test.onlyRun only this test
test.todoMark as not implemented
test.failingExpect the test to fail
test.concurrentRun in parallel
test.serialForce sequential

Common Matchers

MatcherPurpose
toBeStrict equality
toEqualDeep equality
toContainArray/string contains
toThrowFunction throws
toHaveLengthLength check
toMatchRegex match
toMatchSnapshotSnapshot comparison
toHaveBeenCalledWithMock assertion

CLI Flags

FlagPurpose
-t, --test-name-patternFilter by test name
--watchRerun on file change
--bailStop after N failures
--coverageGenerate coverage
--timeoutPer-test timeout
--concurrentRun async tests in parallel
--retryRetry failed tests
--randomizeRandom test order
--update-snapshotsRegenerate snapshots

Lifecycle Hooks

HookTiming
beforeAllOnce before all tests
beforeEachBefore each test
afterEachAfter each test
afterAllOnce after all tests

Best Practices

✅ Do This:

import { test, expect } from "bun:test";
<!-- Import from bun:test explicitly -->              # ✅
test("returns null for missing user", () => { ... });
<!-- Name the behavior, not the function -->          # ✅
bun test -t "auth"
<!-- Filter tests during development -->              # ✅

❌ Don’t Do This:

# Rely on global test/expect without the reference
<!-- Add bun-types/test-globals to preload -->        # ❌
# Use --watch in CI
<!-- It does not exit; use default run mode -->       # ❌
# Assume 100% Jest compatibility
<!-- Check the compatibility table for gaps -->       # ❌

Common Pitfalls

PitfallWhy It HappensFix
test is not definedNo global injectionImport from bun:test
TypeScript errors on globalsMissing referenceAdd /// <reference types="bun-types/test-globals" />
Snapshot test fails after updateSnapshot not regeneratedbun test --update-snapshots
Tests hang in CI--watch flag usedRemove --watch
Coverage threshold not enforcedcoverageThreshold not setAdd to bunfig.toml

Real-World Examples

1. Basic Test

test("2 + 2", () => expect(2 + 2).toBe(4));

2. Describe Block

describe("math", () => {
  test("add", () => expect(1 + 1).toBe(2));
});

3. Async Test

test("fetch", async () => {
  const res = await fetch("/api");
  expect(res.ok).toBe(true);
});

4. Lifecycle Hook

beforeEach(() => { db.clear(); });

5. Mock

const fn = mock(() => 42);
fn();
expect(fn).toHaveBeenCalled();

6. Snapshot

expect(render()).toMatchSnapshot();

7. Filter by Name

bun test -t "addition"

8. Watch Mode

bun test --watch

9. Coverage

bun test --coverage

10. CI Configuration

- run: bun test --bail

Visual

Test Execution Flow

┌──────────────────────────────────────────────┐
│  bun test                                    │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Discover test files                         │
│  *.test.ts, *.spec.ts, etc.                  │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Load preload files (bunfig.toml)            │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Run each test file                          │
│  ├── beforeAll                               │
│  ├── test (beforeEach → test → afterEach)    │
│  └── afterAll                                │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Report results                              │
│  Pass/fail count, expect calls, time         │
└──────────────────────────────────────────────┘

Jest Compatibility

┌──────────────────────────────────────────────┐
│  Compatible                                  │
│  ├── describe, test, expect                  │
│  ├── toBe, toEqual, toContain, toThrow       │
│  ├── beforeAll, beforeEach, etc.             │
│  ├── mock, spyOn, mock.module                │
│  └── toMatchSnapshot                         │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Not fully compatible                        │
│  ├── jsdom environment (use happy-dom)       │
│  ├── Some advanced matchers                  │
│  └── Complex Jest configuration              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Test modulebun:test
Discovery patterns*.test.ts, *.spec.ts, etc.
Default timeout5000ms
Watch mode--watch
Coverage--coverage
Bail--bail
Concurrent--concurrent
Retry--retry
SnapshotstoMatchSnapshot()
Configurationbunfig.toml [test] section

Key takeaways:

  • bun:test is built into the runtime. No Jest, no Vitest, no configuration file is required for basic tests. The runner executes TypeScript and JSX natively .
  • The API is Jest-compatible. describe, test, expect, lifecycle hooks, mocks, and snapshots use the same names and semantics. Existing Jest tests often run without changes .
  • Discovery is convention-based. Files matching *.test.* and *.spec.* are found automatically. A positional argument filters by path, and -t filters by test name .
  • Lifecycle hooks manage state. beforeAll, beforeEach, afterEach, and afterAll run in the expected order. They are the place to set up and tear down shared resources .
  • Snapshots work like Jest. toMatchSnapshot() writes a .snap file in __snapshots__/. The --update-snapshots flag regenerates it .
  • Configuration lives in bunfig.toml. The [test] section controls preload files, coverage, timeouts, and retries .

Remember: Bun’s test runner is not a replacement for every Jest configuration. It is a replacement for the common case: a project with straightforward tests that wants to run them faster and without a build step. The bun:test import is the entry point. The bun test command is the runner. The bunfig.toml is the configuration. For projects that need jsdom or advanced Jest features, the migration may require adjustments—but for the majority of suites, the switch is a one-line change.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!