agentleFS
udecodeudecode/better-convex/.agents/skills/testing/SKILL.md

testing

Skill452 starsChanged 23 days agoApache-2.0View on GitHub

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

We split this skill into the rules it gives an agent

17 rules. 0 are common, 5 are distinctive, and 12 are very specific to this project.

  • 0

    Common

    Most of the 171 edited versions we read keep these.

  • 5

    Distinctive

    This author's own choices. Worth a look.

  • 12

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Solution: Use spyOn() instead of mock.module().

    line 134
  2. Very specific:

    Import module as namespace: import * as apiModule from './api'

    line 180
  3. Very specific:

    Create spies in beforeEach: spyOn(apiModule, 'function')

    line 181
  4. Very specific:

    Always mockRestore() in afterEach

    line 182
  5. Very specific:

    Use mock variables in assertions: expect(mockFn) not expect(apiModule.fn)

    line 183
  6. Very specific:

    Use mock.module() only for infrastructure every test needs, configured in a preload, or for a module that will never be imported as real …

    line 187
  7. Distinctive:

    Do not solve one contaminated test by lowering global concurrency, changing CI, or adding runtime dependency-indirection files solely for …

    line 193
  8. Very specific:

    Use mockClear() when only call history should reset.

    line 210
  9. Distinctive:

    If a subject reads an environment flag at module scope, changing the mock after the static import cannot affect it.

    line 214
  10. Distinctive:

    If a test passes alone and fails in the suite, run likely contaminators before the victim in one command:

    line 222
  11. Very specific:

    Search for mock.module() calls

    line 344
  12. Very specific:

    Verify spy setup: spyOn(module, 'correctFunctionName')

    line 353
  13. Very specific:

    Check assertions use mock variable: expect(mockFn) not expect(module.fn)

    line 354
  14. Very specific:

    Ensure all async operations are awaited

    line 362
  15. Very specific:

    Check mock returns resolved promises: mockResolvedValue()

    line 363
  16. Distinctive:

    Red: Write failing test for hook behavior

    line 393
  17. Distinctive:

    Repeat: Add next test case

    line 396

“Common” is measured: kept by at least 6 in 10 of the 171 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.