Introduction to Testing in Bun
Bun is a fast all-in-one JavaScript runtime that ships with a built-in test runner, bun test. Unlike Node.js, where you typically need to install Jest, Vitest, or Mocha, Bun provides a Jest-compatible testing API out of the box. This means you can write unit, integration, and end-to-end (E2E) tests without adding heavy dependencies to your project.
In this tutorial, we will explore how to test Bun components thoroughly — from isolated unit tests to full end-to-end scenarios. We will cover the built-in test runner, mocking strategies, snapshot testing, HTTP server testing, and browser-based E2E tests using Playwright.
Why Testing Matters in Bun Projects
Testing is the safety net that lets you move fast without breaking things. In a Bun project, where performance and developer experience are central, having a reliable test suite ensures that:
- Regressions are caught early before they reach production.
- Refactoring is safe because tests verify expected behavior.
- APIs and contracts are documented through executable examples.
- Confidence is high when shipping new features.
Because Bun's test runner is extremely fast, there is almost no excuse to skip writing tests. A suite of hundreds of tests can run in milliseconds, giving you near-instant feedback during development.
Setting Up a Bun Project for Testing
First, make sure Bun is installed. You can verify this by running:
bun --version
If you do not have a project yet, create one:
mkdir bun-testing-demo
cd bun-testing-demo
bun init -y
Bun automatically recognizes files with the .test.ts or .spec.ts suffix as test files. You do not need any configuration file to get started. Simply create a test file and run bun test.
Writing Your First Unit Test
Let us start with a simple utility module. Create a file called src/math.ts:
// src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
export function isEven(n: number): boolean {
return n % 2 === 0;
}
Now create a test file called src/math.test.ts:
// src/math.test.ts
import { test, expect, describe } from "bun:test";
import { add, divide, isEven } from "./math";
describe("math utilities", () => {
test("add returns the sum of two numbers", () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
test("divide throws when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
test("divide returns correct quotient", () => {
expect(divide(10, 2)).toBe(5);
});
test("isEven correctly identifies even numbers", () => {
expect(isEven(4)).toBe(true);
expect(isEven(7)).toBe(false);
});
});
Run the tests with:
bun test
Bun will discover all test files, execute them, and print a summary. You should see all four tests passing.
Testing Async Components
Most real-world components involve asynchronous operations. Bun's test runner handles async tests naturally. Create src/fetcher.ts:
// src/fetcher.ts
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user ${id}`);
}
return response.json();
}
Now write a test that mocks the global fetch function:
// src/fetcher.test.ts
import { test, expect, mock, beforeEach, afterEach } from "bun:test";
import { fetchUser } from "./fetcher";
beforeEach(() => {
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = input.toString();
if (url.includes("/users/1")) {
return new Response(JSON.stringify({ id: 1, name: "Alice" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not Found", { status: 404 });
}) as typeof fetch;
});
afterEach(() => {
mock.restore();
});
test("fetchUser returns user data for valid id", async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: "Alice" });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test("fetchUser throws on invalid id", async () => {
await expect(fetchUser(999)).rejects.toThrow("Failed to fetch user 999");
});
The mock() function from bun:test creates a mock function that tracks calls. You can assert how many times it was called and inspect call arguments.
Mocking Modules with mock.module
For more complex scenarios, you may want to mock an entire module. Bun provides mock.module() for this purpose. Suppose you have a database module:
// src/db.ts
export async function getUser(id: number) {
// In real life, this queries a database
return { id, name: "Real User", email: "real@example.com" };
}
And a service that depends on it:
// src/userService.ts
import { getUser } from "./db";
export async function getUserName(id: number): Promise<string> {
const user = await getUser(id);
return user.name;
}
You can mock the db module in your test:
// src/userService.test.ts
import { test, expect, mock, beforeEach } from "bun:test";
import { getUserName } from "./userService";
beforeEach(() => {
mock.module("./db", () => {
return {
getUser: mock(async (id: number) => ({
id,
name: "Mocked User",
email: "mocked@example.com",
})),
};
});
});
test("getUserName returns mocked name", async () => {
const name = await getUserName(42);
expect(name).toBe("Mocked User");
});
This approach is powerful for isolating the component under test from its dependencies.
Snapshot Testing
Snapshot testing is useful when you want to verify that the output of a function does not change unexpectedly. Bun supports snapshot testing with toMatchSnapshot().
// src/formatter.test.ts
import { test, expect } from "bun:test";
function formatReport(data: Record<string, unknown>) {
return {
generatedAt: "2024-01-01T00:00:00Z",
summary: Object.keys(data).length,
data,
};
}
test("formatReport matches snapshot", () => {
const result = formatReport({ users: 100, revenue: 5000 });
expect(result).toMatchSnapshot();
});
The first time you run this test, Bun creates a __snapshots__ directory with the saved snapshot. On subsequent runs, it compares the output against the saved snapshot. If the output changes, the test fails. You can update snapshots with:
bun test --update-snapshots
Testing Bun HTTP Servers
Bun makes it easy to build HTTP servers with Bun.serve(). Testing these servers is straightforward because you can start the server on a random port and make real HTTP requests against it.
Create src/server.ts:
// src/server.ts
export function createServer(port: number = 0) {
return Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" },
});
}
if (url.pathname === "/echo" && req.method === "POST") {
const body = await req.text();
return new Response(JSON.stringify({ echoed: body }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not Found", { status: 404 });
},
});
}
Now write an integration test:
// src/server.test.ts
import { test, expect, afterAll } from "bun:test";
import { createServer } from "./server";
const server = createServer(0);
const baseUrl = `http://localhost:${server.port}`;
afterAll(() => {
server.stop();
});
test("GET /health returns ok status", async () => {
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ status: "ok" });
});
test("POST /echo returns the posted body", async () => {
const res = await fetch(`${baseUrl}/echo`, {
method: "POST",
body: "hello world",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ echoed: "hello world" });
});
test("unknown route returns 404", async () => {
const res = await fetch(`${baseUrl}/unknown`);
expect(res.status).toBe(404);
});
This is an integration test because it exercises the real server, real HTTP transport, and real request handling — all without leaving the Bun process.
Testing with Setup and Teardown
Bun provides beforeAll, afterAll, beforeEach, and afterEach hooks for managing test setup and teardown. These are essential when tests share state or resources.
// src/cache.test.ts
import { test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
class SimpleCache {
private store = new Map<string, string>();
set(key: string, value: string) {
this.store.set(key, value);
}
get(key: string): string | undefined {
return this.store.get(key);
}
clear() {
this.store.clear();
}
}
let cache: SimpleCache;
beforeAll(() => {
console.log("Setting up test suite");
});
afterAll(() => {
console.log("Tearing down test suite");
});
beforeEach(() => {
cache = new SimpleCache();
});
test("cache stores and retrieves values", () => {
cache.set("foo", "bar");
expect(cache.get("foo")).toBe("bar");
});
test("cache returns undefined for missing keys", () => {
expect(cache.get("missing")).toBeUndefined();
});
End-to-End Testing with Playwright
While unit and integration tests cover individual components, end-to-end (E2E) tests verify the entire application from the user's perspective. For web applications, Playwright is the most popular choice, and it works seamlessly with Bun.
Install Playwright:
bun add -d @playwright/test
bunx playwright install
Create a simple frontend application served by Bun. Update src/server.ts to serve an HTML page:
// src/app.ts
export function createApp(port: number = 0) {
return Bun.serve({
port,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/") {
return new Response(
`<!DOCTYPE html>
<html>
<body>
<h1>Counter App</h1>
<p id="count">0</p>
<button id="increment">Increment</button>
<script>
let count = 0;
document.getElementById('increment').addEventListener('click', () => {
count++;
document.getElementById('count').textContent = count;
});
</script>
</body>
</html>`,
{ headers: { "Content-Type": "text/html" } }
);
}
return new Response("Not Found", { status: 404 });
},
});
}
Create a Playwright configuration file at playwright.config.ts:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30000,
use: {
baseURL: "http://localhost:3001",
headless: true,
},
webServer: {
command: "bun run src/app.ts",
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 10000,
},
});
Now create the E2E test at e2e/counter.spec.ts:
// e2e/counter.spec.ts
import { test, expect } from "@playwright/test";
test("counter increments when button is clicked", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toHaveText("Counter App");
await expect(page.locator("#count")).toHaveText("0");
await page.click("#increment");
await expect(page.locator("#count")).toHaveText("1");
await page.click("#increment");
await page.click("#increment");
await expect(page.locator("#count")).toHaveText("3");
});
Run the E2E tests with:
bunx playwright test
Playwright will automatically start your Bun server, launch a headless browser, navigate to the page, interact with the counter, and verify the expected behavior. This is a true end-to-end test because it exercises the full stack from browser to server.
Code Coverage
Bun can generate code coverage reports natively. Run your tests with the --coverage flag:
bun test --coverage
This prints a table showing file coverage, line counts, and percentages. You can also output coverage in specific formats:
bun test --coverage --coverage-reporter=text --coverage-reporter=lcov
Coverage helps you identify untested code paths, but remember that 100% coverage is not always the goal. Focus on testing meaningful behavior rather than chasing a number.
Best Practices for Testing Bun Components
1. Organize Tests Alongside Source Files
Keep test files next to the modules they test. If your source file is src/userService.ts, the test should be src/userService.test.ts. This makes it easy to find and maintain tests.
2. Write Small, Focused Tests
Each test should verify one behavior. Avoid testing multiple unrelated things in a single test. This makes failures easier to diagnose.
3. Use Descriptive Test Names
Test names should describe the expected behavior, not the implementation. Compare:
// Bad
test("test1", () => { ... });
// Good
test("returns empty array when no users exist", () => { ... });
4. Mock External Dependencies
Unit tests should not make real network calls or hit real databases. Use mock() and mock.module() to isolate the component under test.
5. Test Edge Cases
Always test boundary conditions: empty inputs, null values, very large numbers, concurrent operations, and error states. These are where bugs typically hide.
6. Keep E2E Tests Minimal
E2E tests are slow and brittle compared to unit tests. Use them to verify critical user flows, not every minor feature. A good ratio is many unit tests, some integration tests, and a few E2E tests.
7. Run Tests in CI
Integrate bun test into your CI pipeline. Bun's speed makes it practical to run the full suite on every pull request. Add a script to package.json:
{
"scripts": {
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"test:e2e": "bunx playwright test"
}
}
8. Use Watch Mode During Development
Bun supports watch mode, which re-runs tests automatically when files change:
bun test --watch
This gives you instant feedback as you write code, creating a tight development loop.
Conclusion
Testing Bun components is a seamless experience thanks to the built-in test runner. You can start with simple unit tests using test() and expect(), mock dependencies with mock() and mock.module(), test HTTP servers with real requests, and scale up to full end-to-end tests with Playwright. By combining fast unit tests, meaningful integration tests, and targeted E2E tests, you build a robust safety net that lets you ship features with confidence. The key is to start small, test the behavior that matters, and let Bun's speed keep your feedback loop tight. Happy testing!