Bun Testing Patterns
Overview
Bun's test runner provides Jest-compatible API with TypeScript support and fast execution. Critical: Test globals (describe, it, expect, mock, spyOn, beforeEach, afterEach) are available globally via tooling/global.d.ts - no imports needed. mock.module() is process-global - use spyOn() instead.
Setup (First-Time Installation)
1. bunfig.toml
[test]
# Preload scripts execute BEFORE any test file
preload = ["./tooling/test-setup.ts"]
# Coverage exclusions
coveragePathIgnorePatterns = [
"node_modules/**",
"**/*.d.ts",
]
2. tooling/global.d.ts
/// <reference types="bun-types/test-globals" />
declare var mock: typeof import("bun:test").mock;
declare var spyOn: typeof import("bun:test").spyOn;
3. tooling/test-setup.ts
import { afterEach, expect, mock, spyOn } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import * as matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
// Make mock and spyOn globally available
(globalThis as any).mock = mock;
(globalThis as any).spyOn = spyOn;
// Register DOM globals synchronously
GlobalRegistrator.register();
// Ensure document.body exists
if (global.document && !global.document.body) {
const body = global.document.createElement("body");
global.document.documentElement.appendChild(body);
}
// Extend Bun's expect with Testing Library matchers
expect.extend(matchers);
// Cleanup after each test
afterEach(() => {
cleanup();
});
4. Dependencies
bun add -d @happy-dom/global-registrator @testing-library/react @testing-library/jest-dom
When to Use
- Writing new tests for React hooks
- Debugging test failures, especially when tests pass individually but fail in full suite
- Fixing cross-file contamination ("test passes alone, fails with others")
- Encountering "Hook timed out", race conditions, or flaky tests
Quick Reference
| Pattern | Use Case | Example |
|---|---|---|
| No imports needed | Test globals | describe, it, expect, mock, spyOn are global |
toMatchObject(array) |
Array partial match | Checks properties exist, allows extras |
toEqual() |
Exact match | Validates complete structure |
expect(val as any) |
Type mismatch | Cast actual value, not expected |
mock() not jest.fn() |
Create mock function | Bun test API |
spyOn() + afterEach |
Mock with cleanup | Always spy.mockRestore() |
renderHook() + act() |
Test hooks | Wrap state changes in act() |
void act() |
Prevent warnings | Use with sync click/change events |
ReturnType<typeof mock> |
Type mock variables | let mockFn: ReturnType<typeof mock> |
ReturnType<typeof spyOn> |
Type spy variables | let spy: ReturnType<typeof spyOn> |
Core Patterns
Test File Structure
// NO bun:test imports needed - globals are available
import { act, renderHook } from "@testing-library/react";
import * as apiModule from "@/lib/api"; // Import as namespace for spyOn
describe("HookName", () => {
let mockFunction: ReturnType<typeof mock>;
let functionSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockFunction = mock();
functionSpy = spyOn(apiModule, "functionName").mockImplementation(
mockFunction
);
mockFunction.mockResolvedValue(defaultResponse);
});
afterEach(() => {
// CRITICAL: Always restore spies
functionSpy.mockRestore();
});
it("should do something", async () => {
// Test implementation
});
});
Avoiding Cross-File Contamination
Problem: mock.module() is process-global. If fileA.test.ts uses mock.module('@/lib/api'), it contaminates fileB.test.ts.
Solution: Use spyOn() instead of mock.module().
❌ WRONG - Causes Cross-Contamination
// This globally mocks the module for ALL test files
mock.module("@/lib/api", () => ({
fetchData: mock(),
}));
✅ CORRECT - File-Scoped Mocking
import * as apiModule from "@/lib/api";
describe("MyHook", () => {
let mockFetchData: ReturnType<typeof mock>;
let fetchDataSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockFetchData = mock();
fetchDataSpy = spyOn(apiModule, "fetchData").mockImplementation(
mockFetchData
);
mockFetchData.mockResolvedValue({ data: "test" });
});
afterEach(() => {
fetchDataSpy.mockRestore();
});
it("fetches data", async () => {
const { result } = renderHook(() => useMyHook());
await act(async () => {
await result.current.fetch();
});
expect(mockFetchData).toHaveBeenCalled();
});
});
Key differences:
- Import module as namespace:
import * as apiModule from './api' - Create spies in
beforeEach:spyOn(apiModule, 'function') - Always
mockRestore()inafterEach - Use mock variables in assertions:
expect(mockFn)notexpect(apiModule.fn)
When mock.module() Is Allowed
Use mock.module() only for infrastructure every test needs, configured in a
preload, or for a module that will never be imported as real code elsewhere in
the same Bun process. It mutates process-global module state and may preserve
unmocked real exports while overriding only returned keys. The result can look
real while a few exports are poisoned.
Do not solve one contaminated test by lowering global concurrency, changing CI, or adding runtime dependency-indirection files solely for tests. Prefer a lower-level contract, a file-scoped spy, or a different integration harness.
mockReset() Destroys Implementations
mockReset() clears calls and the implementation. Restore defaults immediately:
const fetchValue = mock(async () => "default");
beforeEach(() => {
fetchValue.mockReset();
fetchValue.mockResolvedValue("default");
});
Use mockClear() when only call history should reset.
Module-Scope Environment Values Need Fresh Imports
If a subject reads an environment flag at module scope, changing the mock after the static import cannot affect it. Set a mutable environment mock first, then dynamically import the subject. Add a deterministic cache-busting import query only when Bun otherwise reuses the module. Avoid random cache keys in committed tests when an incrementing case id works.
Reproducing Order-Dependent Failures
If a test passes alone and fails in the suite, run likely contaminators before the victim in one command:
bun test likely-contaminator.test.ts victim.test.ts
Record the minimal ordering that fails. Inspect module keys, critical function types, and resolved default values behind a temporary opt-in debug flag. Remove debug output after the owner is proven.
Testing React Hooks
import { act, renderHook } from "@testing-library/react";
it("updates state correctly", async () => {
const { result } = renderHook(() => useCustomHook());
await act(async () => {
await result.current.fetchData();
});
expect(result.current.data).toEqual(expectedData);
expect(result.current.loading).toBe(false);
});
// Use void to prevent unused promise warnings
void act(() => getByText("button").click());
Custom wrapper pattern for context providers:
const createWrapper = (props) => ({ children }: any) => (
<Provider {...props}>{children}</Provider>
);
const wrapper = createWrapper({ value: 'test' });
const { result } = renderHook(() => useCustomHook(), { wrapper });
Testing Async Errors
it("handles async errors", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useCustomHook());
await act(async () => {
try {
await result.current.fetchData();
} catch (error) {
expect(error).toEqual(new Error("Failed to load"));
}
});
expect(result.current.error).toBe("Failed to load");
});
Matcher Selection
// ✅ Array partial matching
expect(children).toMatchObject([{ text: "one" }, { text: "two" }]);
// ✅ Exact matching
expect(result).toEqual({ data: "test" });
// ✅ Cast actual value for type mismatches
expect(node as any).toEqual({ text: "one" });
// ❌ Don't cast expected value
expect(children).toEqual([{ text: "one" }] as any);
// ❌ Don't use toMatchObject() for single objects (won't catch extra props)
expect(node).toMatchObject({ text: "one" });
Running Tests
# Run all tests
bun test
# Run specific file
bun test src/hooks/useMyHook.test.ts
# Watch mode
bun test --watch
# Coverage
bun test --coverage
# Bail on first failure
bun test --bail
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Importing from bun:test |
Unnecessary, globals available | Remove imports |
Using mock.module() |
Cross-file contamination | Use spyOn() + afterEach cleanup |
Forgetting afterEach cleanup |
Spies persist across tests | Always spy.mockRestore() |
| Direct import for spyOn | Can't spy on named exports | import * as module |
Forgetting act() |
React warnings, flaky tests | Wrap state changes in act() |
jest.fn() / jest.Mock |
Wrong framework | Use mock() and ReturnType<typeof mock> |
| No type for mocks | Type errors, autocomplete fails | ReturnType<typeof mock> |
Debugging Test Failures
Test passes alone, fails in suite
Symptom: bun test file.test.ts passes, bun test fails.
Cause: Cross-file contamination from mock.module().
Fix:
- Search for
mock.module()calls - Refactor to
spyOn()pattern withafterEachcleanup
"Expected to be called but it was not called"
Cause: Wrong mock variable or wrong function name.
Fix:
- Verify spy setup:
spyOn(module, 'correctFunctionName') - Check assertions use mock variable:
expect(mockFn)notexpect(module.fn)
"Hook timed out after 5000ms"
Cause: Missing await, unresolved promise.
Fix:
- Ensure all async operations are
awaited - Check mock returns resolved promises:
mockResolvedValue() - Increase timeout if needed:
it('name', fn, 10000)
Red Flags - Cross-Contamination Risk
- Using
mock.module()outside of preload scripts - Importing modules directly instead of as namespace for spyOn
- Missing
afterEach()withmockRestore()calls - Tests passing individually but failing in full suite
All indicate cross-file contamination. Refactor to spyOn pattern.
Implementation Checklist
For each new hook test file:
- NO imports from
bun:test- globals are available - Import
renderHook,actfrom@testing-library/reactwhen testing hooks - Import modules as namespace for spyOn:
import * as module - Declare mock variables with
ReturnType<typeof mock> - Declare spy variables with
ReturnType<typeof spyOn> - Create spies in
beforeEachwithspyOn(module, 'fn') - Set default mock return values in
beforeEach - CRITICAL: Restore spies in
afterEachwithspy.mockRestore() - Wrap React state changes in
act() - Use mock variables in assertions, not module functions
- Verify tests pass both individually and in full suite
TDD Workflow
- Red: Write failing test for hook behavior
- Green: Implement minimal code to pass test
- Refactor: Clean up implementation
- Repeat: Add next test case