566

Testing React hooks with skills

Test hook contracts, rerenders, browser boundaries, and cleanup with a repeatable skill-driven workflow.

September 14, 2026

Useful hook tests follow the hook's public lifecycle: the initial result, updates, changed arguments, browser events, and cleanup. Tests get noisy when they start from effect mechanics instead. You end up with listener spies, timer counts, and setup that proves how the hook is built rather than what it promises.

Before writing scenarios, inspect the same hook's tests and the closest hooks with a similar contract. Their render helpers, naming, mocks, SSR setup, and cleanup checks are part of the repository's testing language. The testing-best-practices skill makes an AI coding agent follow that language before it writes code.

What matters when testing hooks

A hook is more than a function call. Its contract can span the first render, server rendering, actions, rerenders, asynchronous or browser events, and unmount. Work through the parts that the hook actually owns:

  • Initial contract and SSR - verify the public values, state, methods, and server fallback the hook exposes.
  • Inputs and target forms - cover meaningful overloads and supported forms when they change how the hook resolves or subscribes to a target.
  • State transitions - trigger updates through React and read result.current again after every render.
  • Changing arguments - rerender when callbacks, options, or targets are expected to update an already mounted hook.
  • Browser boundaries - mock only enough of the browser API to produce the event that drives public behavior.
  • Cleanup - trigger the event again after disabling or unmounting and verify that no new public effect occurs.

Test each independent dimension with one representative setup instead of multiplying every target, callback, and option into a Cartesian suite. The first contract test can still be small:

import { renderHook } from '@testing-library/react';
import { expect, it } from 'vitest';
 
import { useDisclosure } from './useDisclosure';
 
it('Should use disclosure', () => {
  const { result } = renderHook(useDisclosure);
 
  expect(result.current.isOpen).toBe(false);
  expect(result.current.open).toBeTypeOf('function');
  expect(result.current.close).toBeTypeOf('function');
  expect(result.current.toggle).toBeTypeOf('function');
});

That first test is small, but it anchors the suite. Every later test should explain one public behavior: opening, closing, toggling, receiving a new argument, calling a callback, or cleaning itself up.

Drive updates through React

When a hook changes state, trigger it inside the renderer's update boundary and read result.current again after the update:

import { act, renderHook } from '@testing-library/react';
import { expect, it } from 'vitest';
 
import { useDisclosure } from './useDisclosure';
 
it('Should open disclosure', () => {
  const { result } = renderHook(useDisclosure);
 
  act(() => result.current.open());
 
  expect(result.current.isOpen).toBe(true);
});

Do not cache a returned object and assert against it after a rerender. Hooks are render contracts. The current value is always result.current.

Treat browser APIs as boundaries

Browser hooks need a controlled environment. For useIntersectionObserver, the browser boundary is IntersectionObserver, so the test can replace that API with a tiny mock and then assert the hook's visible behavior:

import { act, renderHook } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
 
import { useIntersectionObserver } from './useIntersectionObserver';
 
const observe = vi.fn();
let intersectionCallback: IntersectionObserverCallback;
 
class MockIntersectionObserver {
  constructor(callback: IntersectionObserverCallback) {
    intersectionCallback = callback;
  }
 
  observe = observe;
  disconnect = vi.fn();
}
 
globalThis.IntersectionObserver =
  MockIntersectionObserver as unknown as typeof IntersectionObserver;
 
it('Should call callback on intersection', () => {
  const onChange = vi.fn();
  const element = document.createElement('div');
 
  const { result } = renderHook(() =>
    useIntersectionObserver<HTMLDivElement>({
      onChange
    })
  );
 
  act(() => result.current.ref(element));
 
  const entry = { isIntersecting: true, target: element } as IntersectionObserverEntry;
  act(() => intersectionCallback([entry], result.current.observer!));
 
  expect(onChange).toHaveBeenCalledWith([entry], result.current.observer);
  expect(observe).toHaveBeenCalledWith(element);
});

The mock exists only to drive the public event. The assertion still talks about the hook contract: callback payload and observed target.

Cleanup is absence of behavior

A cleanup test should prove that nothing public happens after unmount. For an event hook, dispatch the event again and expect no callback. For an observer hook, trigger the stored observer callback after unmount only if that is how the local tests model the browser boundary.

import { act, renderHook } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
 
import { useWindowEvent } from './useWindowEvent';
 
it('Should cleanup on unmount', () => {
  const listener = vi.fn();
  const { unmount } = renderHook(() => useWindowEvent('resize', listener));
 
  unmount();
 
  act(() => window.dispatchEvent(new Event('resize')));
 
  expect(listener).not.toHaveBeenCalled();
});

The important part is the phrasing: after cleanup, the consumer should not see another effect. That is stronger than only checking that an internal method was called.

Where the skill helps

The skill is useful because it gives an agent a repeatable order of operations:

  • inspect tests for the same module first;
  • keep names in the Should <observable behavior> style;
  • put shape and SSR tests before behavior tests when the hook supports server rendering;
  • test supported target forms without duplicating unrelated option cases;
  • mock browser APIs only to create the event the real browser would create;
  • restore global mocks, timers, and browser state after a test mutates them.

Install it next to your agent setup:

npx skills add https://github.com/siberiacancode/agent-skills --skill testing-best-practices

Then grill the testing subject before asking the agent to write the tests:

/unit-test-grill useIntersectionObserver

The grill inspects the implementation, public types, and nearby tests, then returns a focused list of Should <observable behavior> scenarios. You can review the contract, target forms, callback behavior, enabled, target changes, and cleanup before any test code is written. Once that list is right, ask the agent to implement it.