Lifecycle hooks

Learn how to use beforeAll, beforeEach, afterEach, afterAll, aroundEach, and aroundAll lifecycle hooks in Bun tests

The test runner supports the following lifecycle hooks. Use them to load test fixtures, mock data, and configure the test environment.

HookDescription
beforeAllRuns once before all tests.
beforeEachRuns before each test.
afterEachRuns after each test.
afterAllRuns once after all tests.
aroundEachWraps each test in a function.
aroundAllWraps all tests in a function.
onTestFinishedRuns after a single test finishes (after all afterEach).

Per-Test Setup and Teardown#

Perform per-test setup and teardown logic with beforeEach and afterEach.

test.ts
import { beforeEach, afterEach, test } from "bun:test";

beforeEach(() => {
  console.log("running test.");
});

afterEach(() => {
  console.log("done with test.");
});

// tests...
test("example test", () => {
  // This test will have beforeEach run before it
  // and afterEach run after it
});

Per-Scope Setup and Teardown#

Perform per-scope setup and teardown logic with beforeAll and afterAll. Where you define the hook determines its scope.

Scoped to a Describe Block#

To scope the hooks to a particular describe block:

test.ts
import { describe, beforeAll, afterAll, test } from "bun:test";

describe("test group", () => {
  beforeAll(() => {
    // setup for this describe block
    console.log("Setting up test group");
  });

  afterAll(() => {
    // teardown for this describe block
    console.log("Tearing down test group");
  });

  test("test 1", () => {
    // test implementation
  });

  test("test 2", () => {
    // test implementation
  });
});

Scoped to a Test File#

To scope the hooks to an entire test file:

test.ts
import { describe, beforeAll, afterAll, test } from "bun:test";

beforeAll(() => {
  // setup for entire file
  console.log("Setting up test file");
});

afterAll(() => {
  // teardown for entire file
  console.log("Tearing down test file");
});

describe("test group", () => {
  test("test 1", () => {
    // test implementation
  });
});

onTestFinished#

Use onTestFinished to run a callback after a single test completes. The callback runs after all afterEach hooks.

test.ts
import { test, onTestFinished } from "bun:test";

test("cleanup after test", () => {
  onTestFinished(() => {
    // runs after all afterEach hooks
    console.log("test finished");
  });
});

Not supported in concurrent tests; use test.serial instead.

Wrapping Tests#

Some setup only works as a function call around the test: a database transaction, a tracing span, an AsyncLocalStorage store. Use aroundEach for that. The hook receives a runTest function. It does its setup, awaits runTest(), then does its teardown.

test.ts
import { aroundEach, test } from "bun:test";
import { db } from "./db";

aroundEach(async runTest => {
  await db.transaction(runTest);
});

test("inserts a user", async () => {
  await db.insert({ name: "Alice" });
});

runTest() runs the beforeEach hooks, the test, the afterEach hooks and the onTestFinished callbacks. The promise it returns resolves when they're done, even if the test fails.

aroundAll does the same for a whole file or describe block. Its runSuite() runs the block's beforeAll hooks, its tests and nested blocks, and its afterAll hooks.

test.ts
import { aroundAll } from "bun:test";
import { tracer } from "./tracer";

aroundAll(async runSuite => {
  await tracer.trace("my suite", runSuite);
});
  • Order. Hooks nest. The first hook you register is the outermost one. The hooks of an outer describe block go around the hooks of an inner one.
  • Arguments. aroundEach calls the hook with (runTest, context, suite). context is the test context. aroundAll calls it with (runSuite, {}, suite).
  • Retries. aroundEach runs again for each retry and each repeat. It doesn't run for a skipped or todo test. aroundAll doesn't run if every test in its block is skipped.
  • Failures. The test fails if the hook throws, or if it finishes without calling runTest(). If the hook fails before it calls runTest(), Bun skips everything inside, and the hooks around it still finish. A second runTest() call returns a rejected promise.
  • Timeout. The second argument is a timeout in milliseconds: aroundEach(fn, 1000). It applies to the time before runTest() is called, and again to the time after its promise resolves. Bun doesn't count the time in between.

Async context#

Everything that runTest() runs sees the async context runTest() is called in. The same goes for runSuite(). So a test can read an AsyncLocalStorage store that a hook sets:

test.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { aroundEach, expect, test } from "bun:test";

const requestId = new AsyncLocalStorage<string>();

aroundEach(runTest => requestId.run("request-1", runTest));

test("sees the store", () => {
  expect(requestId.getStore()).toBe("request-1");
});

Global Setup and Teardown#

To scope the hooks to an entire multi-file test run, define the hooks in a separate file.

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

beforeAll(() => {
  // global setup
  console.log("Global test setup");
  // Initialize database connections, start servers, etc.
});

afterAll(() => {
  // global teardown
  console.log("Global test teardown");
  // Close database connections, stop servers, etc.
});

Then use --preload to run the setup script before any test files.

terminal
bun test --preload ./setup.ts

To avoid typing --preload every time you run tests, add it to your bunfig.toml:

bunfig.toml
[test]
preload = ["./setup.ts"]

In a preload script, aroundEach wraps every test of every file, and aroundAll wraps the whole run.

Practical Examples#

Database Setup#

database-setup.ts
import { beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
import { createConnection, closeConnection, clearDatabase } from "./db";

let connection;

beforeAll(async () => {
  // Connect to test database
  connection = await createConnection({
    host: "localhost",
    database: "test_db",
  });
});

afterAll(async () => {
  // Close database connection
  await closeConnection(connection);
});

beforeEach(async () => {
  // Start with clean database for each test
  await clearDatabase(connection);
});

API Server Setup#

server-setup.ts
import { beforeAll, afterAll } from "bun:test";
import { startServer, stopServer } from "./server";

let server;

beforeAll(async () => {
  // Start test server
  server = await startServer({
    port: 3001,
    env: "test",
  });
});

afterAll(async () => {
  // Stop test server
  await stopServer(server);
});

Mock Setup#

mock-setup.ts
import { beforeEach, afterEach } from "bun:test";
import { mock } from "bun:test";

beforeEach(() => {
  // Set up common mocks
  mock.module("./api-client", () => ({
    fetchUser: mock(() => Promise.resolve({ id: 1, name: "Test User" })),
    createUser: mock(() => Promise.resolve({ id: 2 })),
  }));
});

afterEach(() => {
  // Clear all mocks after each test
  mock.clearAllMocks();
});

Async Lifecycle Hooks#

All lifecycle hooks support async functions. Bun waits for the promise a hook or a test returns. It also waits for any other object with a then() method, such as the one React's act() returns.

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

beforeAll(async () => {
  // Async setup
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log("Async setup complete");
});

afterAll(async () => {
  // Async teardown
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log("Async teardown complete");
});

test("async test", async () => {
  // Test will wait for beforeAll to complete
  await expect(Promise.resolve("test")).resolves.toBe("test");
});

Nested Hooks#

You can nest hooks. They run in the following order:

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

beforeAll(() => console.log("File beforeAll"));
afterAll(() => console.log("File afterAll"));

describe("outer describe", () => {
  beforeAll(() => console.log("Outer beforeAll"));
  beforeEach(() => console.log("Outer beforeEach"));
  afterEach(() => console.log("Outer afterEach"));
  afterAll(() => console.log("Outer afterAll"));

  describe("inner describe", () => {
    beforeAll(() => console.log("Inner beforeAll"));
    beforeEach(() => console.log("Inner beforeEach"));
    afterEach(() => console.log("Inner afterEach"));
    afterAll(() => console.log("Inner afterAll"));

    test("nested test", () => {
      console.log("Test running");
    });
  });
});
// Output order:
// File beforeAll
// Outer beforeAll
// Inner beforeAll
// Outer beforeEach
// Inner beforeEach
// Test running
// Inner afterEach
// Outer afterEach
// Inner afterAll
// Outer afterAll
// File afterAll

Error Handling#

If a beforeAll hook throws, the test runner skips every test in the hook's scope:

test.ts
import { beforeAll, test } from "bun:test";

beforeAll(() => {
  // If this throws, all tests in this scope will be skipped
  throw new Error("Setup failed");
});

test("this test will be skipped", () => {
  // This won't run because beforeAll failed
});

To log a setup failure and still fail the suite:

test.ts
import { beforeAll, test, expect } from "bun:test";

beforeAll(async () => {
  try {
    await setupDatabase();
  } catch (error) {
    console.error("Database setup failed:", error);
    throw error; // Re-throw to fail the test suite
  }
});

Best Practices#

Keep Hooks Simple#

test.ts
// Good: Simple, focused setup
beforeEach(() => {
  clearLocalStorage();
  resetMocks();
});

// Avoid: Complex logic in hooks
beforeEach(async () => {
  // Too much complex logic makes tests hard to debug
  const data = await fetchComplexData();
  await processData(data);
  await setupMultipleServices(data);
});

Use Appropriate Scope#

test.ts
// Good: File-level setup for shared resources
beforeAll(async () => {
  await startTestServer();
});

// Good: Test-level setup for test-specific state
beforeEach(() => {
  user = createTestUser();
});

Clean Up Resources#

test.ts
import { afterAll, afterEach } from "bun:test";

afterEach(() => {
  // Clean up after each test
  document.body.innerHTML = "";
  localStorage.clear();
});

afterAll(async () => {
  // Clean up expensive resources
  await closeDatabase();
  await stopServer();
});