Unit Testing
Test runner
Use Bun's built-in test runner — no install needed.
bun test # run all tests
bun test --watch # re-run on file changes
bun test src/lib/foo.test.ts # run a specific file
File naming and location
Place test files next to the source file they test:
src/
├── lib/
│ ├── format-date.ts
│ └── format-date.test.ts
└── components/
├── search-input.tsx
└── search-input.test.tsx
Use *.test.ts for logic and *.test.tsx for React components.
Test structure
Import everything from bun:test:
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
describe("formatDate", () => {
test("formats a date with the default locale", () => {
expect(formatDate(new Date("2024-01-15"))).toBe("Jan 15, 2024");
});
test("returns an empty string for null", () => {
expect(formatDate(null)).toBe("");
});
});
- Use
describeto group related cases; nest only when there's a real hierarchy - Use
testoverit - One logical assertion per
testkeeps failures easy to diagnose
Mocking
Mock at system boundaries — anything that reaches outside the process or produces non-deterministic output: outbound HTTP, file I/O, time, external services.
Do not mock internal modules. If internal modules need isolation, extract a pure function instead.
Example: mocking a module dependency
Given a function that reads a file:
// get-post.ts
import { readFileSync } from "node:fs";
export function getPost(id: string): string {
return readFileSync(`posts/${id}.md`, "utf-8");
}
Mock node:fs with mock.module() before importing the module under test. Define the mock function separately so you have a reference for assertions.
// get-post.test.ts
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { getPost } from "./get-post";
const mockReadFileSync = mock(() => "# Hello World");
mock.module("node:fs", () => ({
readFileSync: mockReadFileSync,
}));
describe("getPost", () => {
beforeEach(() => {
mockReadFileSync.mockClear();
});
test("reads the correct file path and returns content", () => {
expect(getPost("my-post")).toBe("# Hello World");
expect(mockReadFileSync).toHaveBeenCalledWith("posts/my-post.md", "utf-8");
});
});
Key points:
mock.module()overrides persist for the entire file and cannot be undone withmock.restore()- Call
mockClear()inbeforeEachto reset call counts between tests - For per-test return value variation, re-call
mockReadFileSync.mockImplementation(...)inbeforeEach
Spying on an existing method
Use spyOn when you want to observe calls on an object you already have, without replacing the whole module:
import { test, expect, spyOn, afterEach, mock } from "bun:test";
const spy = spyOn(console, "error");
afterEach(() => {
mock.restore(); // restores spied-on functions; does NOT reset mock.module() overrides
});
test("logs an error on invalid input", () => {
processInput(null);
expect(spy).toHaveBeenCalledTimes(1);
});
React component testing
For testing React components with React Testing Library and HappyDOM —
setup, userEvent interactions, and query priority — see
references/react-testing.md. Use the bootstrap
skill to install and configure both.
Before finishing
After writing or editing any test file, verify:
-
bun testpasses with no errors - No
.onlycalls left in the file - Mocks are restored in
afterEachwhere relevant - No
getByTestIdused when a role or label query would work