Bun Testing Patterns

SkillFiles & storage

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

Available today. Use it from your connected AI after setup.

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

PatternUse CaseExample
No imports neededTest globalsdescribe, it, expect, mock, spyOn are global
toMatchObject(array)Array partial matchChecks properties exist, allows extras
toEqual()Exact matchValidates complete structure
expect(val as any)Type mismatchCast actual value, not expected
mock() not jest.fn()Create mock functionBun test API
spyOn() + afterEachMock with cleanupAlways spy.mockRestore()
renderHook() + act()Test hooksWrap state changes in act()
void act()Prevent warningsUse with sync click/change events
ReturnType<typeof mock>Type mock variableslet mockFn: ReturnType<typeof mock>
ReturnType<typeof spyOn>Type spy variableslet 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:

  1. Import module as namespace: import * as apiModule from './api'
  2. Create spies in beforeEach: spyOn(apiModule, 'function')
  3. Always mockRestore() in afterEach
  4. Use mock variables in assertions: expect(mockFn) not expect(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

MistakeProblemFix
Importing from bun:testUnnecessary, globals availableRemove imports
Using mock.module()Cross-file contaminationUse spyOn() + afterEach cleanup
Forgetting afterEach cleanupSpies persist across testsAlways spy.mockRestore()
Direct import for spyOnCan't spy on named exportsimport * as module
Forgetting act()React warnings, flaky testsWrap state changes in act()
jest.fn() / jest.MockWrong frameworkUse mock() and ReturnType<typeof mock>
No type for mocksType errors, autocomplete failsReturnType<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:

  1. Search for mock.module() calls
  2. 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:

  1. Verify spy setup: spyOn(module, 'correctFunctionName')
  2. Check assertions use mock variable: expect(mockFn) not expect(module.fn)

"Hook timed out after 5000ms"

Cause: Missing await, unresolved promise.

Fix:

  1. Ensure all async operations are awaited
  2. Check mock returns resolved promises: mockResolvedValue()
  3. 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:

  • NO imports from bun:test - globals are available
  • Import renderHook, act from @testing-library/react when 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 beforeEach with spyOn(module, 'fn')
  • Set default mock return values in beforeEach
  • CRITICAL: Restore spies in afterEach with spy.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

  1. Red: Write failing test for hook behavior
  2. Green: Implement minimal code to pass test
  3. Refactor: Clean up implementation
  4. 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