Testing Hooks

A hook cannot be called outside a component, so it cannot be called from a test either. renderHook closes the gap: it renders a throwaway component whose only job is to call your hook and publish the return value on result.current. Here useUndoable keeps a history array and an index, returning { value, set, undo, canUndo }.

src/useUndoable.test.jsxJSX
test('records values and steps back through them', () => {
  const { result } = renderHook(() => useUndoable('a'));
  expect(result.current.value).toBe('a');
  expect(result.current.canUndo).toBe(false);
  act(() => result.current.set('b'));
  act(() => result.current.set('c'));
  expect(result.current.value).toBe('c');
  act(() => result.current.undo());
  expect(result.current.value).toBe('b');
  expect(result.current.canUndo).toBe(true);
});

That test and a second one pass in 17 ms. Two rules explain every mistake made here. Anything that updates state goes inside act, because renderHook has no events to piggyback on. And result.current is a snapshot, not a live binding: destructuring const { set } = result.current captures the first render's closure, so later reads go stale.

renderHook takes the same options as render, plus initialProps, which pairs with rerender to test how a hook responds to changing arguments. The second test starts from initialProps: { start: 'x' }, calls rerender({ start: 'y' }), and asserts the value is still 'x' — real behavior, since useState(start) reads its argument only on the first render (Lazy State Initialization). Pass wrapper when the hook consumes context, or it silently reads the default value.