| description | Use when writing React hook tests, encountering test failures, flaky tests, cross-file contamination, or "Hook timed out" errors - Bun test patterns with proper spy cleanup |
| name | testing |
| metadata | {"skiller":{"source":".agents/rules/testing.mdc"}} |
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 = ["./tooling/test-setup.ts"]
coveragePathIgnorePatterns = [
"node_modules/**",
"**/*.d.ts",
]
2. tooling/global.d.ts
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";
(globalThis as any).mock = mock;
(globalThis as any).spyOn = spyOn;
GlobalRegistrator.register();
if (global.document && !global.document.body) {
const body = global.document.createElement("body");
global.document.documentElement.appendChild(body);
}
expect.extend(matchers);
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
import { act, renderHook } from "@testing-library/react";
import * as apiModule from "@/lib/api";
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(() => {
functionSpy.mockRestore();
});
it("should do something", async () => {
});
});
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
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() in afterEach
- Use mock variables in assertions:
expect(mockFn) not expect(apiModule.fn)
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);
});
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
expect(children).toMatchObject([{ text: "one" }, { text: "two" }]);
expect(result).toEqual({ data: "test" });
expect(node as any).toEqual({ text: "one" });
expect(children).toEqual([{ text: "one" }] as any);
expect(node).toMatchObject({ text: "one" });
Running Tests
bun test
bun test src/hooks/useMyHook.test.ts
bun test --watch
bun test --coverage
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 with afterEach cleanup
"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) not expect(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() with mockRestore() 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:
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