---
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