| name | bun-test-patterns |
| description | Bun test runner - Jest-compatible testing with mocks, snapshots, coverage, and DOM testing patterns
When user writes tests with Bun, uses bun:test, creates mocks, runs test coverage, or mentions describe/it/expect patterns
|
Bun Test Patterns Agent
What's New in Bun Test (2024-2025)
- Vitest compatibility:
vi alias for easier migration
- Module mocking:
mock.module() for ESM/CJS mocking
- Type testing:
expectTypeOf for TypeScript type assertions
- Custom matchers:
expect.extend() for custom assertions
- Improved coverage: Built-in code coverage reporting
- Watch mode: Automatic test re-runs on file changes
Running Tests
Basic Commands
bun test
bun test math.test.ts
bun test --test-name-pattern "add"
bun test -t "add"
bun test --watch
bun test --coverage
bun test --timeout 10000
bun test src/utils
File Discovery
Bun automatically finds test files matching:
*.test.{js|jsx|ts|tsx}
*_test.{js|jsx|ts|tsx}
*.spec.{js|jsx|ts|tsx}
*_spec.{js|jsx|ts|tsx}
Configuration (bunfig.toml)
[test]
coverage = true
coverageThreshold = { line = 80, function = 80 }
preload = ["./test/setup.ts"]
timeout = 5000
smol = true
Writing Tests
Basic Structure
import { describe, test, it, expect, beforeAll, afterEach } from "bun:test";
describe("Calculator", () => {
describe("add()", () => {
it("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("handles negative numbers", () => {
expect(add(-1, 1)).toBe(0);
});
});
});
Test Modifiers
test.skip("not ready yet", () => {
});
test.only("focus on this", () => {
});
test.todo("implement later");
test.if(process.env.CI)("only in CI", () => {
});
test.skipIf(!process.env.DB_URL)("needs database", () => {
});
Async Tests
test("fetches user", async () => {
const user = await fetchUser(1);
expect(user.name).toBe("Alice");
});
test("resolves correctly", () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe("Alice");
});
});
test("callback style", (done) => {
setTimeout(() => {
expect(true).toBe(true);
done();
}, 100);
});
Timeout
test("slow operation", async () => {
const result = await slowOperation();
expect(result).toBeDefined();
}, 10000);
Expect Matchers
Equality
expect(value).toBe(expected);
expect(value).toEqual(expected);
expect(value).toStrictEqual(expected);
expect(value).not.toBe(other);
Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toBeNaN();
Numbers
expect(num).toBeGreaterThan(5);
expect(num).toBeGreaterThanOrEqual(5);
expect(num).toBeLessThan(10);
expect(num).toBeLessThanOrEqual(10);
expect(num).toBeCloseTo(0.3, 5);
expect(num).toBePositive();
expect(num).toBeNegative();
expect(num).toBeInteger();
expect(num).toBeFinite();
Strings
expect(str).toMatch(/pattern/);
expect(str).toContain("substring");
expect(str).toStartWith("prefix");
expect(str).toEndWith("suffix");
expect(str).toHaveLength(10);
Arrays and Iterables
expect(arr).toContain(item);
expect(arr).toContainEqual({ id: 1 });
expect(arr).toHaveLength(3);
expect(arr).toBeArray();
expect(arr).toBeArrayOfSize(3);
expect(arr).toInclude(item);
expect(arr).toIncludeAllMembers([1, 2]);
expect(arr).toIncludeAnyMembers([1, 5]);
expect(arr).toSatisfyAll((x) => x > 0);
Objects
expect(obj).toHaveProperty("key");
expect(obj).toHaveProperty("nested.key", value);
expect(obj).toMatchObject({ subset: true });
expect(obj).toContainKey("key");
expect(obj).toContainKeys(["a", "b"]);
expect(obj).toContainAllKeys(["a", "b"]);
expect(obj).toContainValue(42);
Functions and Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("message");
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrowError(/pattern/);
await expect(asyncFn()).rejects.toThrow();
await expect(asyncFn()).resolves.toBe(value);
Assertions Count
test("multiple assertions", () => {
expect.assertions(3);
expect(a).toBe(1);
expect(b).toBe(2);
expect(c).toBe(3);
});
test("at least one", async () => {
expect.hasAssertions();
const data = await fetchData();
expect(data).toBeDefined();
});
Lifecycle Hooks
Basic Hooks
import { beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
beforeAll(() => {
console.log("Setting up");
});
afterAll(() => {
console.log("Tearing down");
});
beforeEach(() => {
console.log("Before each test");
});
afterEach(() => {
console.log("After each test");
});
Async Hooks
beforeAll(async () => {
await database.connect();
});
afterAll(async () => {
await database.disconnect();
});
Scoped Hooks
describe("outer", () => {
beforeAll(() => console.log("outer beforeAll"));
beforeEach(() => console.log("outer beforeEach"));
describe("inner", () => {
beforeAll(() => console.log("inner beforeAll"));
beforeEach(() => console.log("inner beforeEach"));
test("example", () => {
});
});
});
Preload Scripts
import { beforeEach, afterEach, mock } from "bun:test";
beforeEach(() => {
mock.restore();
});
afterEach(() => {
});
[test]
preload = ["./test/setup.ts"]
Mocking
Mock Functions
import { mock, expect, test } from "bun:test";
test("mock function", () => {
const mockFn = mock(() => 42);
const result = mockFn("arg1", "arg2");
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
expect(result).toBe(42);
expect(mockFn.mock.calls).toEqual([["arg1", "arg2"]]);
expect(mockFn.mock.results).toEqual([{ type: "return", value: 42 }]);
});
Mock Implementations
const mockFn = mock();
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(0);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(0);
mockFn.mockImplementation((x) => x * 2);
expect(mockFn(5)).toBe(10);
mockFn.mockResolvedValue({ data: "test" });
await expect(mockFn()).resolves.toEqual({ data: "test" });
mockFn.mockRejectedValue(new Error("fail"));
await expect(mockFn()).rejects.toThrow("fail");
Spies
import { spyOn, expect, test } from "bun:test";
const calculator = {
add(a: number, b: number) {
return a + b;
},
};
test("spy on method", () => {
const spy = spyOn(calculator, "add");
const result = calculator.add(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
expect(spy).toHaveBeenCalledTimes(1);
expect(result).toBe(5);
expect(spy.mock.calls).toEqual([[2, 3]]);
});
test("spy with mock implementation", () => {
const spy = spyOn(calculator, "add").mockImplementation(() => 100);
expect(calculator.add(1, 2)).toBe(100);
spy.mockRestore();
expect(calculator.add(1, 2)).toBe(3);
});
Module Mocking
import { mock, test, expect } from "bun:test";
mock.module("./database", () => ({
query: mock(() => [{ id: 1, name: "Test" }]),
connect: mock(() => Promise.resolve()),
}));
test("uses mocked module", async () => {
const db = await import("./database");
const result = db.query("SELECT * FROM users");
expect(result).toEqual([{ id: 1, name: "Test" }]);
expect(db.query).toHaveBeenCalled();
});
mock.module("./config", () => {
return {
get apiUrl() {
return process.env.API_URL || "http://localhost:3000";
},
};
});
Gotcha — mock.module leaks across test files. mock.module(specifier, factory) registers the mock process-wide and is not auto-restored between files (mock.restore() does not undo it). A partial mock returning only the symbols it overrides effectively deletes the module's other exports for every test file that runs afterward, surfacing as a flaky, CI-only SyntaxError: Export named 'X' not found in module '...' that can't be reproduced in isolation. Spread the real module so only the intended symbol changes:
import * as realMod from "./agent-task-command.ts";
void mock.module("#activities/agent-task-command.ts", () => ({
...realMod,
buildAgentTaskCommand: async () => {
},
}));
Specifier aliases that resolve to the same file (#activities/x.ts and ./x.ts) share one mock.
Restoring Mocks
import { mock, afterEach } from "bun:test";
afterEach(() => {
mock.restore();
});
const mockFn = mock(() => 42);
mockFn.mockClear();
mockFn.mockReset();
mockFn.mockRestore();
Vitest Compatibility
import { vi, test, expect } from "bun:test";
test("vitest-style mocking", () => {
const mockFn = vi.fn(() => 42);
mockFn();
expect(mockFn).toHaveBeenCalled();
});
Snapshots
Basic Snapshots
import { test, expect } from "bun:test";
test("snapshot object", () => {
const user = {
id: 1,
name: "Alice",
createdAt: new Date("2024-01-01"),
};
expect(user).toMatchSnapshot();
});
Snapshots are stored in __snapshots__/ directory.
Inline Snapshots
test("inline snapshot", () => {
const result = formatUser({ name: "Bob", age: 30 });
expect(result).toMatchInlineSnapshot(`
{
"displayName": "Bob",
"isAdult": true,
}
`);
});
Error Snapshots
test("error snapshot", () => {
expect(() => {
throw new Error("Something went wrong");
}).toThrowErrorMatchingSnapshot();
});
test("inline error snapshot", () => {
expect(() => {
throw new Error("Invalid input");
}).toThrowErrorMatchingInlineSnapshot(`"Invalid input"`);
});
Updating Snapshots
bun test --update-snapshots
bun test -u
Coverage
Enabling Coverage
bun test --coverage
Coverage Output
File | % Funcs | % Lines | Uncovered Line #s
All files | 66.67 | 77.78 |
math.ts | 50.00 | 66.67 | 15-20
utils.ts | 100.00 | 100.00 |
Configuration
[test]
coverage = true
coverageDir = "coverage"
coverageThreshold = { line = 80, function = 80, branch = 80 }
DOM Testing
Setup with Happy-DOM
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
[test]
preload = ["./test/setup.ts"]
Testing Components
import { test, expect } from "bun:test";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import Button from "./Button";
test("button click handler", async () => {
const handleClick = mock();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole("button");
await fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test("renders with label", () => {
render(<Button>Submit</Button>);
expect(screen.getByText("Submit")).toBeInTheDocument();
});
Testing Library Setup
import { afterEach } from "bun:test";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
afterEach(() => {
cleanup();
});
Custom Matchers
import { expect } from "bun:test";
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
});
declare module "bun:test" {
interface Matchers<T> {
toBeWithinRange(floor: number, ceiling: number): void;
}
}
test("custom matcher", () => {
expect(5).toBeWithinRange(1, 10);
expect(20).not.toBeWithinRange(1, 10);
});
Type Testing
import { test, expectTypeOf } from "bun:test";
test("type inference", () => {
const result = add(1, 2);
expectTypeOf(result).toBeNumber();
expectTypeOf(result).not.toBeString();
expectTypeOf(add).toBeFunction();
expectTypeOf(add).parameters.toEqualTypeOf<[number, number]>();
expectTypeOf(add).returns.toEqualTypeOf<number>();
});
Integration Testing Patterns
Database Testing
import {
beforeAll,
afterAll,
afterEach,
describe,
test,
expect,
} from "bun:test";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
afterEach(async () => {
await prisma.user.deleteMany();
});
describe("User repository", () => {
test("creates user", async () => {
const user = await prisma.user.create({
data: { email: "test@example.com", name: "Test" },
});
expect(user.id).toBeDefined();
expect(user.email).toBe("test@example.com");
});
});
API Testing
import { test, expect, beforeAll, afterAll } from "bun:test";
import app from "./app";
let server: ReturnType<typeof Bun.serve>;
beforeAll(() => {
server = Bun.serve({
fetch: app.fetch,
port: 0,
});
});
afterAll(() => {
server.stop();
});
test("GET /users returns list", async () => {
const response = await fetch(`http://localhost:${server.port}/users`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toBeArray();
});
test("POST /users creates user", async () => {
const response = await fetch(`http://localhost:${server.port}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", email: "test@example.com" }),
});
expect(response.status).toBe(201);
});
Best Practices Summary
- Use
describe blocks - organize related tests
- One assertion focus - each test verifies one behavior
- Clean up in afterEach - prevent test pollution
- Use
mock.restore() - reset mocks between tests
- Prefer spies over mocks - when original behavior is needed
- Use inline snapshots - for small, readable values
- Enable coverage - maintain test quality
- Preload common setup - DRY principle
- Use
.skip and .todo - track incomplete tests
- Test edge cases - null, empty, boundary values
When to Ask for Help
- Complex module mocking scenarios
- Performance optimization for large test suites
- Integration with specific testing libraries
- Parallel test execution configuration
- Custom reporter development
- CI/CD pipeline setup for Bun tests