Skip to main content
Bun’s test runner has specific runtime behavior that affects how tests are executed, how state is managed, and how resources are handled.

Test isolation

File-level isolation

Each test file runs in its own module scope:
// test1.test.ts
let counter = 0;

test("increment", () => {
  counter++;
  expect(counter).toBe(1);
});

// test2.test.ts
let counter = 0; // Different counter, separate module

test("increment", () => {
  counter++;
  expect(counter).toBe(1); // Also 1
});
Tests in different files don’t share global state.

Test-level isolation

Tests within the same file share the module scope:
import { test, expect } from "bun:test";

let counter = 0;

test("first test", () => {
  counter++;
  expect(counter).toBe(1);
});

test("second test", () => {
  counter++; // counter is now 2
  expect(counter).toBe(2);
});
Use beforeEach to reset state:
import { test, beforeEach, expect } from "bun:test";

let counter: number;

beforeEach(() => {
  counter = 0;
});

test("first test", () => {
  counter++;
  expect(counter).toBe(1);
});

test("second test", () => {
  counter++;
  expect(counter).toBe(1); // Reset to 0 by beforeEach
});

Execution model

Sequential execution

By default, tests in a file run sequentially:
test("test 1", () => console.log("1"));
test("test 2", () => console.log("2"));
test("test 3", () => console.log("3"));
// Output: 1, 2, 3

Concurrent execution

Mark tests as concurrent:
import { test } from "bun:test";

test.concurrent("test 1", async () => {
  await delay(100);
  console.log("1");
});

test.concurrent("test 2", async () => {
  await delay(50);
  console.log("2");
});

test.concurrent("test 3", async () => {
  await delay(10);
  console.log("3");
});
// Output: 3, 2, 1 (fastest first)

File-level concurrency

Run multiple test files in parallel:
$ bun test --concurrent
Or configure in bunfig.toml:
[test]
concurrent = true

Global state

Process environment

Environment variables are shared across all tests:
import { test, expect } from "bun:test";

process.env.TEST_VAR = "initial";

test("test 1", () => {
  process.env.TEST_VAR = "modified";
});

test("test 2", () => {
  expect(process.env.TEST_VAR).toBe("modified"); // Sees change from test 1
});
Reset in beforeEach:
import { test, beforeEach } from "bun:test";

const originalEnv = { ...process.env };

beforeEach(() => {
  process.env = { ...originalEnv };
});

Global objects

Changes to global objects persist:
import { test, expect } from "bun:test";

(globalThis as any).myGlobal = "initial";

test("test 1", () => {
  (globalThis as any).myGlobal = "modified";
});

test("test 2", () => {
  expect((globalThis as any).myGlobal).toBe("modified");
});

Module caching

Modules are cached per test file:
// utils.ts
export const timestamp = Date.now();

// test1.test.ts
import { timestamp } from "./utils";
test("test", () => {
  console.log(timestamp); // Always the same value
});
To clear cache, use dynamic import:
import { test } from "bun:test";

test("test", async () => {
  const { timestamp } = await import("./utils.ts");
  console.log(timestamp); // Fresh import
});

Resource management

Automatic cleanup with using

Bun supports JavaScript’s using keyword:
import { test } from "bun:test";

test("resource cleanup", () => {
  using server = createTestServer();
  // server.stop() called automatically
});

Manual cleanup

Use afterEach or afterAll for cleanup:
import { test, afterEach } from "bun:test";

const servers: Server[] = [];

afterEach(() => {
  servers.forEach(s => s.stop());
  servers.length = 0;
});

test("test 1", () => {
  const server = createServer();
  servers.push(server);
});

Error handling

Unhandled promise rejections

Bun catches unhandled rejections and fails the test:
import { test, expect } from "bun:test";

test("unhandled rejection", () => {
  Promise.reject(new Error("Oops")); // Test fails
});

Uncaught exceptions

Uncaught exceptions fail the test:
import { test } from "bun:test";

test("uncaught exception", () => {
  setTimeout(() => {
    throw new Error("Oops"); // Test fails
  }, 0);
});

Async test completion

Async tests must complete:
import { test, expect } from "bun:test";

test("async test", async () => {
  const result = await asyncOperation();
  expect(result).toBe(42);
  // Test completes when promise resolves
});

Memory management

Garbage collection

Bun runs garbage collection between test files:
import { test } from "bun:test";

test("creates large array", () => {
  const large = new Array(1_000_000).fill(0);
  // Memory freed after test file completes
});

Memory leaks

Avoid keeping references in global scope:
// ❌ Bad: Accumulates memory
const results: any[] = [];

test("test 1", () => {
  results.push(generateLargeData());
});

test("test 2", () => {
  results.push(generateLargeData());
});

// ✅ Good: Cleans up
let results: any[] = [];

afterEach(() => {
  results = [];
});

Performance characteristics

Test startup time

First test in a file includes:
  • Module loading
  • Dependency resolution
  • Global setup
import { test } from "bun:test";

test("first test", () => {
  // Includes startup overhead
});

test("second test", () => {
  // No startup overhead
});

Optimization tips

  1. Minimize global imports:
    // ✅ Good: Import only what you need
    import { test, expect } from "bun:test";
    
    // ❌ Bad: Imports entire library
    import * as everything from "bun:test";
    
  2. Use lazy imports for heavy dependencies:
    test("heavy import", async () => {
      const { heavy } = await import("./heavy-lib");
    });
    
  3. Share expensive setup with beforeAll:
    let db;
    beforeAll(async () => {
      db = await connectToDatabase();
    });
    

Timeout behavior

Default timeout

Tests timeout after 5 seconds by default:
import { test } from "bun:test";

test("slow test", async () => {
  await delay(10000); // Fails: timeout
});

Custom timeout

Set per-test timeout:
test("slow test", async () => {
  await delay(10000);
}, 15000); // 15 second timeout

Infinite operations

Ensure tests complete:
// ❌ Bad: Never completes
test("infinite loop", () => {
  while (true) {}
});

// ✅ Good: Completes
test("finite loop", () => {
  let count = 0;
  while (count < 100) {
    count++;
  }
});

Exit behavior

Normal exit

Bun exits with code 0 if all tests pass:
$ bun test
# Exit code: 0

Failure exit

Bun exits with code 1 if any test fails:
$ bun test
# Exit code: 1

Hanging processes

Bun waits for async operations to complete:
import { test } from "bun:test";

test("hanging test", () => {
  setInterval(() => {}, 1000); // Prevents exit
});
Clean up resources:
import { test, afterAll } from "bun:test";

const intervals: Timer[] = [];

afterAll(() => {
  intervals.forEach(clearInterval);
});

test("clean test", () => {
  const interval = setInterval(() => {}, 1000);
  intervals.push(interval);
});

Build docs developers (and LLMs) love