--- 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 ```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 ```typescript /// declare var mock: typeof import("bun:test").mock; declare var spyOn: typeof import("bun:test").spyOn; ``` ### 3. tooling/test-setup.ts ```typescript 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 ```bash 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` | **Type mock variables** | `let mockFn: ReturnType` | | `ReturnType` | **Type spy variables** | `let spy: ReturnType` | ## Core Patterns ### Test File Structure ```typescript // 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; let functionSpy: ReturnType; 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 ```typescript // This globally mocks the module for ALL test files mock.module("@/lib/api", () => ({ fetchData: mock(), })); ``` #### ✅ CORRECT - File-Scoped Mocking ```typescript import * as apiModule from "@/lib/api"; describe("MyHook", () => { let mockFetchData: ReturnType; let fetchDataSpy: ReturnType; 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: ```typescript 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: ```bash 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 ```typescript 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: ```typescript const createWrapper = (props) => ({ children }: any) => ( {children} ); const wrapper = createWrapper({ value: 'test' }); const { result } = renderHook(() => useCustomHook(), { wrapper }); ``` ### Testing Async Errors ```typescript 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 ```typescript // ✅ 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 ```bash # 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` | | No type for mocks | Type errors, autocomplete fails | `ReturnType` | ## 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 `await`ed 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` - [ ] Declare spy variables with `ReturnType` - [ ] 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