Bun 11 ⚡ Writing and Running Unit Tests with Bun’s Native Test Runner
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
| Function | Purpose |
|---|---|
test(name, fn) | Define a test |
describe(name, fn) | Group tests |
test.skip | Skip a test |
test.only | Run only this test |
test.todo | Mark as not implemented |
test.failing | Expect the test to fail |
test.concurrent | Run in parallel |
test.serial | Force sequential |
Common Matchers
| Matcher | Purpose |
|---|---|
toBe | Strict equality |
toEqual | Deep equality |
toContain | Array/string contains |
toThrow | Function throws |
toHaveLength | Length check |
toMatch | Regex match |
toMatchSnapshot | Snapshot comparison |
toHaveBeenCalledWith | Mock assertion |
CLI Flags
| Flag | Purpose |
|---|---|
-t, --test-name-pattern | Filter by test name |
--watch | Rerun on file change |
--bail | Stop after N failures |
--coverage | Generate coverage |
--timeout | Per-test timeout |
--concurrent | Run async tests in parallel |
--retry | Retry failed tests |
--randomize | Random test order |
--update-snapshots | Regenerate snapshots |
Lifecycle Hooks
| Hook | Timing |
|---|---|
beforeAll | Once before all tests |
beforeEach | Before each test |
afterEach | After each test |
afterAll | Once 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
test is not defined | No global injection | Import from bun:test |
| TypeScript errors on globals | Missing reference | Add /// <reference types="bun-types/test-globals" /> |
| Snapshot test fails after update | Snapshot not regenerated | bun test --update-snapshots |
| Tests hang in CI | --watch flag used | Remove --watch |
| Coverage threshold not enforced | coverageThreshold not set | Add 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
| Item | Value |
|---|---|
| Test module | bun:test |
| Discovery patterns | *.test.ts, *.spec.ts, etc. |
| Default timeout | 5000ms |
| Watch mode | --watch |
| Coverage | --coverage |
| Bail | --bail |
| Concurrent | --concurrent |
| Retry | --retry |
| Snapshots | toMatchSnapshot() |
| Configuration | bunfig.toml [test] section |
Key takeaways:
bun:testis 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-tfilters by test name . - Lifecycle hooks manage state.
beforeAll,beforeEach,afterEach, andafterAllrun in the expected order. They are the place to set up and tear down shared resources . - Snapshots work like Jest.
toMatchSnapshot()writes a.snapfile in__snapshots__/. The--update-snapshotsflag 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.
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!