Writing tests

Write tests with Bun's Jest-compatible API, including async tests, timeouts, and test modifiers

Define tests with a Jest-like API imported from the built-in bun:test module. Long term, Bun aims for complete Jest compatibility.

Basic Usage#

To define a test:

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

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

Grouping Tests#

Group tests into suites with describe.

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

describe("arithmetic", () => {
  test("2 + 2", () => {
    expect(2 + 2).toBe(4);
  });

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

Async Tests#

Tests can be async.

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

test("2 * 2", async () => {
  const result = await Promise.resolve(2 * 2);
  expect(result).toEqual(4);
});

Alternatively, use the done callback to signal completion. If your test function takes a done parameter, you must call it or the test hangs.

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

test("2 * 2", done => {
  Promise.resolve(2 * 2).then(result => {
    expect(result).toEqual(4);
    done();
  });
});

Timeouts#

Optionally specify a per-test timeout in milliseconds by passing a number as the third argument to test.

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

test("wat", async () => {
  const data = await slowOperation();
  expect(data).toBe(42);
}, 500); // test must run in <500ms

In bun:test, a timeout throws an uncatchable exception to force the test to stop running and fail. Bun also kills any child processes spawned in the test, so they don't linger as zombie processes.

The default timeout for each test is 5000ms (5 seconds) unless you override it with this timeout option or jest.setTimeout().

Retries and Repeats#

test.retry#

Use the retry option to automatically retry a flaky test when it fails. The test passes if it succeeds within the specified number of attempts.

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

test(
  "flaky network request",
  async () => {
    const response = await fetch("https://example.com/api");
    expect(response.ok).toBe(true);
  },
  { retry: 3 }, // Retry up to 3 times if the test fails
);

test.repeats#

Use the repeats option to run a test multiple times regardless of pass/fail status; the test fails if any iteration fails. Use it to detect flaky tests or for stress testing. repeats: N runs the test N+1 times total (1 initial run + N repeats).

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

test(
  "ensure test is stable",
  () => {
    expect(Math.random()).toBeLessThan(1);
  },
  { repeats: 20 }, // Runs 21 times total (1 initial + 20 repeats)
);
You cannot use both retry and repeats on the same test.

🧟 Zombie Process Killer#

When a test times out, Bun kills any still-running processes that the test spawned with Bun.spawn, Bun.spawnSync, or node:child_process, and logs a message to the console. This prevents zombie processes from lingering after timed-out tests.

Test Modifiers#

test.skip#

Skip individual tests with test.skip. Bun does not run these tests.

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

test.skip("wat", () => {
  // TODO: fix this
  expect(0.1 + 0.2).toEqual(0.3);
});

test.todo#

Mark a test as a todo with test.todo. Bun does not run these tests.

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

test.todo("fix this", () => {
  myTestFunction();
});

To run todo tests and find any that pass, use bun test --todo. Once myTestFunction() works, Bun reports the test above as a failure.

terminal
bun test --todo
math.test.ts:
βœ— fix this
  ^ this test is marked as todo but passes. Remove `.todo` if tested behavior now works

 0 pass
 1 fail

With this flag, failing todo tests do not cause an error, but Bun marks todo tests that pass as failing so you can remove the todo mark or fix the test.

test.only#

To run a particular test or suite of tests, use test.only() or describe.only().

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

test("test #1", () => {
  // does not run
});

test.only("test #2", () => {
  // runs
});

describe.only("only", () => {
  test("test #3", () => {
    // runs
  });
});

The following command runs only tests #2 and #3.

terminal
bun test --only

test.if#

To run a test conditionally, use test.if(). The test runs if the condition is truthy. Use it for tests that should only run on a specific architecture or operating system.

example.test.ts
test.if(Math.random() > 0.5)("runs half the time", () => {
  // ...
});

const macOS = process.platform === "darwin";
test.if(macOS)("runs on macOS", () => {
  // runs if macOS
});

test.skipIf#

To instead skip a test based on some condition, use test.skipIf() or describe.skipIf().

example.test.ts
const macOS = process.platform === "darwin";

test.skipIf(macOS)("runs on non-macOS", () => {
  // runs if *not* macOS
});

test.todoIf#

To mark the test as TODO instead, use test.todoIf() or describe.todoIf(). The choice between skipIf and todoIf signals intent: "invalid for this target" versus "planned but not implemented yet."

example.test.ts
const macOS = process.platform === "darwin";

// TODO: we've only implemented this for Linux so far.
test.todoIf(macOS)("runs on posix", () => {
  // runs if *not* macOS
});

test.failing#

Use test.failing() when you know a test is failing but you want to track it and be notified when it starts passing. This inverts the test result:

  • A failing test marked with .failing() passes
  • A passing test marked with .failing() fails, with a message that it now passes and should be fixed
math.test.ts
// This will pass because the test is failing as expected
test.failing("math is broken", () => {
  expect(0.1 + 0.2).toBe(0.3); // fails due to floating point precision
});

// This will fail with a message that the test is now passing
test.failing("fixed bug", () => {
  expect(1 + 1).toBe(2); // passes, but we expected it to fail
});

Use it to track known bugs you plan to fix later, or for test-driven development.

Conditional Tests for Describe Blocks#

The conditional modifiers .if(), .skipIf(), and .todoIf() also work on describe blocks, affecting all tests in the suite:

example.test.ts
const isMacOS = process.platform === "darwin";

// Only runs the entire suite on macOS
describe.if(isMacOS)("macOS-specific features", () => {
  test("feature A", () => {
    // only runs on macOS
  });

  test("feature B", () => {
    // only runs on macOS
  });
});

// Skips the entire suite on Windows
describe.skipIf(process.platform === "win32")("Unix features", () => {
  test("feature C", () => {
    // skipped on Windows
  });
});

// Marks the entire suite as TODO on Linux
describe.todoIf(process.platform === "linux")("Upcoming Linux support", () => {
  test("feature D", () => {
    // marked as TODO on Linux
  });
});

Parametrized Tests#

test.each and describe.each#

To run the same test with multiple sets of data, use test.each. This creates a parametrized test that runs once for each test case provided.

math.test.ts
const cases = [
  [1, 2, 3],
  [3, 4, 7],
];

test.each(cases)("%p + %p should be %p", (a, b, expected) => {
  expect(a + b).toBe(expected);
});

describe.each creates a parametrized suite that runs once for each test case:

sum.test.ts
describe.each([
  [1, 2, 3],
  [3, 4, 7],
])("add(%i, %i)", (a, b, expected) => {
  test(`returns ${expected}`, () => {
    expect(a + b).toBe(expected);
  });

  test(`sum is greater than each value`, () => {
    expect(a + b).toBeGreaterThan(a);
    expect(a + b).toBeGreaterThan(b);
  });
});

Argument Passing#

How Bun passes arguments to your test function depends on the structure of your test cases:

  • If a table row is an array (like [1, 2, 3]), Bun passes each element as an individual argument
  • If a row is not an array (like an object), Bun passes it as a single argument
example.test.ts
// Array items passed as individual arguments
test.each([
  [1, 2, 3],
  [4, 5, 9],
])("add(%i, %i) = %i", (a, b, expected) => {
  expect(a + b).toBe(expected);
});

// Object items passed as a single argument
test.each([
  { a: 1, b: 2, expected: 3 },
  { a: 4, b: 5, expected: 9 },
])("add($a, $b) = $expected", data => {
  expect(data.a + data.b).toBe(data.expected);
});

Format Specifiers#

Use these specifiers to format the test title:

SpecifierDescription
%ppretty-format
%sString
%dNumber
%iInteger
%fFloating point
%jJSON
%oObject
%#Index of the test case
%%Single percent sign (%)

Examples#

example.test.ts
// Basic specifiers
test.each([
  ["hello", 123],
  ["world", 456],
])("string: %s, number: %i", (str, num) => {
  // "string: hello, number: 123"
  // "string: world, number: 456"
});

// %p for pretty-format output
test.each([
  [{ name: "Alice" }, { a: 1, b: 2 }],
  [{ name: "Bob" }, { x: 5, y: 10 }],
])("user %p with data %p", (user, data) => {
  // "user { name: 'Alice' } with data { a: 1, b: 2 }"
  // "user { name: 'Bob' } with data { x: 5, y: 10 }"
});

// %# for index
test.each(["apple", "banana"])("fruit #%# is %s", fruit => {
  // "fruit #0 is apple"
  // "fruit #1 is banana"
});

Assertion Counting#

Bun supports verifying that a specific number of assertions were called during a test:

expect.hasAssertions()#

Use expect.hasAssertions() to verify that at least one assertion is called during a test:

example.test.ts
test("async work calls assertions", async () => {
  expect.hasAssertions(); // Will fail if no assertions are called

  const data = await fetchData();
  expect(data).toBeDefined();
});

expect.hasAssertions() is especially useful in async tests, to make sure your assertions run.

expect.assertions(count)#

Use expect.assertions(count) to verify that a specific number of assertions are called during a test:

example.test.ts
test("exactly two assertions", () => {
  expect.assertions(2); // Will fail if not exactly 2 assertions are called

  expect(1 + 1).toBe(2);
  expect("hello").toContain("ell");
});

expect.assertions(count) helps ensure all your assertions run, especially in complex async code with multiple code paths.

Type Testing#

Bun includes expectTypeOf for testing TypeScript types, compatible with Vitest.

expectTypeOf#

These functions are no-ops at runtime. Run TypeScript separately to verify the type checks.

The expectTypeOf function provides type-level assertions that TypeScript's type checker verifies. To test your types:

  1. Write your type assertions using expectTypeOf
  2. Run bunx tsc --noEmit to check that your types are correct
example.test.ts
import { expectTypeOf } from "bun:test";

// Basic type assertions
expectTypeOf<string>().toEqualTypeOf<string>();
expectTypeOf(123).toBeNumber();
expectTypeOf("hello").toBeString();

// Object type matching
expectTypeOf({ a: 1, b: "hello" }).toMatchObjectType<{ a: number }>();

// Function types
function greet(name: string): string {
  return `Hello ${name}`;
}

expectTypeOf(greet).toBeFunction();
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>();
expectTypeOf(greet).returns.toEqualTypeOf<string>();

// Array types
expectTypeOf([1, 2, 3]).items.toBeNumber();

// Promise types
expectTypeOf(Promise.resolve(42)).resolves.toBeNumber();

For full documentation on expectTypeOf matchers, see the API Reference.

Matchers#

Bun implements the following matchers. Full Jest compatibility is planned; see the tracking issue.

Basic Matchers#

StatusMatcher
βœ….not
βœ….toBe()
βœ….toEqual()
βœ….toBeNull()
βœ….toBeUndefined()
βœ….toBeNaN()
βœ….toBeDefined()
βœ….toBeFalsy()
βœ….toBeTruthy()
βœ….toStrictEqual()

String and Array Matchers#

StatusMatcher
βœ….toContain()
βœ….toHaveLength()
βœ….toMatch()
βœ….toContainEqual()
βœ….stringContaining()
βœ….stringMatching()
βœ….arrayContaining()

Object Matchers#

StatusMatcher
βœ….toHaveProperty()
βœ….toMatchObject()
βœ….toContainAllKeys()
βœ….toContainValue()
βœ….toContainValues()
βœ….toContainAllValues()
βœ….toContainAnyValues()
βœ….objectContaining()

Number Matchers#

StatusMatcher
βœ….toBeCloseTo()
βœ….closeTo()
βœ….toBeGreaterThan()
βœ….toBeGreaterThanOrEqual()
βœ….toBeLessThan()
βœ….toBeLessThanOrEqual()

Function and Class Matchers#

StatusMatcher
βœ….toThrow()
βœ….toBeInstanceOf()

Promise Matchers#

StatusMatcher
βœ….resolves()
βœ….rejects()

Mock Function Matchers#

StatusMatcher
βœ….toHaveBeenCalled()
βœ….toHaveBeenCalledTimes()
βœ….toHaveBeenCalledWith()
βœ….toHaveBeenLastCalledWith()
βœ….toHaveBeenNthCalledWith()
βœ….toHaveReturned()
βœ….toHaveReturnedTimes()
βœ….toHaveReturnedWith()
βœ….toHaveLastReturnedWith()
βœ….toHaveNthReturnedWith()

Snapshot Matchers#

StatusMatcher
βœ….toMatchSnapshot()
βœ….toMatchInlineSnapshot()
βœ….toThrowErrorMatchingSnapshot()
βœ….toThrowErrorMatchingInlineSnapshot()

Utility Matchers#

StatusMatcher
βœ….extend
βœ….anything()
βœ….any()
βœ….assertions()
βœ….hasAssertions()

Not Yet Implemented#

StatusMatcher
❌.addSnapshotSerializer()

Best Practices#

Use Descriptive Test Names#

example.test.ts
// Good
test("should calculate total price including tax for multiple items", () => {
  // test implementation
});

// Avoid
test("price calculation", () => {
  // test implementation
});
auth.test.ts
describe("User authentication", () => {
  describe("with valid credentials", () => {
    test("should return user data", () => {
      // test implementation
    });

    test("should set authentication token", () => {
      // test implementation
    });
  });

  describe("with invalid credentials", () => {
    test("should throw authentication error", () => {
      // test implementation
    });
  });
});

Use Appropriate Matchers#

auth.test.ts
// Good: Use specific matchers
expect(users).toHaveLength(3);
expect(user.email).toContain("@");
expect(response.status).toBeGreaterThanOrEqual(200);

// Avoid: Using toBe for everything
expect(users.length === 3).toBe(true);
expect(user.email.includes("@")).toBe(true);
expect(response.status >= 200).toBe(true);

Test Error Conditions#

example.test.ts
test("should throw error for invalid input", () => {
  expect(() => {
    validateEmail("not-an-email");
  }).toThrow("Invalid email format");
});

test("should handle async errors", async () => {
  await expect(async () => {
    await fetchUser("invalid-id");
  }).rejects.toThrow("User not found");
});

Use Setup and Teardown#

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

let testUser;

beforeEach(() => {
  testUser = createTestUser();
});

afterEach(() => {
  cleanupTestUser(testUser);
});

test("should update user profile", () => {
  // Use testUser in test
});