Bun Testing Patterns
SkillFiles & storageUse 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
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Bun Testing Patterns skill
What this skill tells your AI
The instructions your AI receives, as published by udecode/kitcn in .agents/skills/testing/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 447
- Forks
- 34
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
testing-udecode- Source
- github.com/udecode/kitcn