CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-testing-library--react

Simple and complete React DOM testing utilities that encourage good testing practices

Pending
Overview
Eval results
Files

hooks.mddocs/

Hook Testing

Test custom hooks in isolation without creating wrapper components.

API

function renderHook<Result, Props>(
  callback: (props: Props) => Result,
  options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;

interface RenderHookOptions<Props> {
  /**
   * Initial props passed to the hook callback
   */
  initialProps?: Props;

  /**
   * Custom container element (same as render options)
   */
  container?: HTMLElement;

  /**
   * Base element for queries (same as render options)
   */
  baseElement?: HTMLElement;

  /**
   * Use hydration instead of normal render
   */
  hydrate?: boolean;

  /**
   * Force synchronous rendering.
   * Only supported in React 18. Not supported in React 19+.
   * Throws an error if used with React 19 or later.
   */
  legacyRoot?: boolean;

  /**
   * Wrapper component for providers
   */
  wrapper?: React.JSXElementConstructor<{ children: React.ReactNode }>;

  /**
   * Enable React.StrictMode wrapper
   */
  reactStrictMode?: boolean;

  /**
   * React 19+ only: Callback when React catches an error in an Error Boundary.
   * Only available in React 19 and later.
   */
  onCaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void;

  /**
   * Callback when React automatically recovers from errors.
   * Available in React 18 and later.
   */
  onRecoverableError?: (error: Error, errorInfo: { componentStack?: string }) => void;
}

interface RenderHookResult<Result, Props> {
  /**
   * Stable reference to the latest hook return value.
   * Access the current value via result.current
   */
  result: {
    current: Result;
  };

  /**
   * Re-renders the hook with new props
   * @param props - New props to pass to the hook callback
   */
  rerender: (props?: Props) => void;

  /**
   * Unmounts the test component, triggering cleanup effects
   */
  unmount: () => void;
}

Common Patterns

Basic Hook Test

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}

test('useCounter increments', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});

Hook with Props

function useGreeting(name: string) {
  return `Hello, ${name}!`;
}

test('useGreeting with different names', () => {
  const { result, rerender } = renderHook(
    ({ name }) => useGreeting(name),
    { initialProps: { name: 'Alice' } }
  );

  expect(result.current).toBe('Hello, Alice!');

  rerender({ name: 'Bob' });
  expect(result.current).toBe('Hello, Bob!');
});

Hook with Context

function useUser() {
  return useContext(UserContext);
}

test('useUser returns context value', () => {
  const wrapper = ({ children }) => (
    <UserContext.Provider value={{ name: 'John' }}>
      {children}
    </UserContext.Provider>
  );

  const { result } = renderHook(() => useUser(), { wrapper });

  expect(result.current).toEqual({ name: 'John' });
});

Async Hook

function useData(url: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

test('useData fetches', async () => {
  const { result } = renderHook(() => useData('/api/user'));

  expect(result.current.loading).toBe(true);

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.data).toBeDefined();
});

Testing Patterns

State Management Hook

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(v => !v);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);
  return { value, toggle, setTrue, setFalse };
}

test('useToggle manages boolean state', () => {
  const { result } = renderHook(() => useToggle());

  expect(result.current.value).toBe(false);

  act(() => result.current.toggle());
  expect(result.current.value).toBe(true);

  act(() => result.current.setFalse());
  expect(result.current.value).toBe(false);
});

Effect Cleanup

function useEventListener(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler);
    return () => window.removeEventListener(event, handler);
  }, [event, handler]);
}

test('useEventListener cleans up', () => {
  const handler = jest.fn();
  const { unmount } = renderHook(() => useEventListener('click', handler));

  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);

  unmount();

  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);  // Not called after unmount
});

Hook with Dependencies

function useDebounce(value: string, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

test('useDebounce delays updates', async () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'initial', delay: 500 } }
  );

  expect(result.current).toBe('initial');

  rerender({ value: 'updated', delay: 500 });
  expect(result.current).toBe('initial');  // Not yet updated

  await waitFor(() => {
    expect(result.current).toBe('updated');
  }, { timeout: 1000 });
});

Complex Hook with Actions

function useList<T>(initial: T[] = []) {
  const [items, setItems] = useState(initial);

  return {
    items,
    add: useCallback((item: T) => setItems(prev => [...prev, item]), []),
    remove: useCallback((index: number) =>
      setItems(prev => prev.filter((_, i) => i !== index)), []
    ),
    clear: useCallback(() => setItems([]), []),
  };
}

test('useList manages array', () => {
  const { result } = renderHook(() => useList(['a']));

  expect(result.current.items).toEqual(['a']);

  act(() => result.current.add('b'));
  expect(result.current.items).toEqual(['a', 'b']);

  act(() => result.current.remove(0));
  expect(result.current.items).toEqual(['b']);

  act(() => result.current.clear());
  expect(result.current.items).toEqual([]);
});

Hook with External Store

function useStore() {
  const [state, setState] = useState(store.getState());

  useEffect(() => {
    const unsubscribe = store.subscribe(setState);
    return unsubscribe;
  }, []);

  return state;
}

test('useStore syncs with store', () => {
  const { result } = renderHook(() => useStore());

  expect(result.current.count).toBe(0);

  act(() => store.dispatch({ type: 'INCREMENT' }));

  expect(result.current.count).toBe(1);
});

Important Notes

act() Wrapping

Wrap state updates in act() to ensure React processes them:

// ✅ Correct
act(() => result.current.action());

// ❌ Wrong - may have timing issues
result.current.action();

result.current Stability

The result object is stable, but result.current updates with each render:

const { result } = renderHook(() => useState(0));

// ❌ Wrong - stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count);  // Still 0

// ✅ Correct - always use result.current
act(() => result.current[1](1));
console.log(result.current[0]);  // 1

Async Operations

Use waitFor for async hook updates:

const { result } = renderHook(() => useAsync());

await waitFor(() => {
  expect(result.current.isLoaded).toBe(true);
});

Production Patterns

Custom Wrapper Utility

// test-utils.tsx
import { renderHook, RenderHookOptions } from '@testing-library/react';

export function renderHookWithProviders<Result, Props>(
  callback: (props: Props) => Result,
  options?: RenderHookOptions<Props>
) {
  return renderHook(callback, {
    wrapper: ({ children }) => (
      <QueryClientProvider client={queryClient}>
        <ThemeProvider>{children}</ThemeProvider>
      </QueryClientProvider>
    ),
    ...options,
  });
}

Testing React Query Hooks

import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

test('useUserQuery fetches user', async () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  const { result } = renderHook(() => useUserQuery('123'), { wrapper });

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true);
  });

  expect(result.current.data).toEqual({ id: '123', name: 'John' });
});

Testing Custom Form Hook

function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  const handleChange = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    setErrors(prev => ({ ...prev, [name]: undefined }));
  };

  const validate = () => {
    const newErrors = {};
    if (!values.email) newErrors.email = 'Required';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  return { values, errors, handleChange, validate };
}

test('useForm validates', () => {
  const { result } = renderHook(() => useForm({ email: '' }));

  expect(result.current.errors).toEqual({});

  act(() => {
    const isValid = result.current.validate();
    expect(isValid).toBe(false);
  });

  expect(result.current.errors).toEqual({ email: 'Required' });

  act(() => result.current.handleChange('email', 'test@example.com'));

  expect(result.current.errors).toEqual({});

  act(() => {
    const isValid = result.current.validate();
    expect(isValid).toBe(true);
  });
});

Important Notes

act() Wrapping

State updates in hooks must be wrapped in act() to ensure React processes updates properly:

import { renderHook, act } from '@testing-library/react';

const { result } = renderHook(() => useCounter());

// Wrap state updates in act()
act(() => {
  result.current.increment();
});

result.current Stability

The result object itself is stable across renders, but result.current is updated with each render. Always access the latest value through result.current:

const { result } = renderHook(() => useState(0));

// WRONG: Destructuring creates a stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count); // Still 0 (stale)

// CORRECT: Access via result.current
act(() => result.current[1](1));
console.log(result.current[0]); // 1 (current)

Async Operations

Use waitFor for async operations:

import { renderHook, waitFor } from '@testing-library/react';

const { result } = renderHook(() => useAsyncHook());

await waitFor(() => {
  expect(result.current.isLoaded).toBe(true);
});

Deprecated Types

The following type aliases are deprecated and provided for backward compatibility:

/** @deprecated Use RenderHookOptions instead */
type BaseRenderHookOptions<Props, Q, Container, BaseElement> = RenderHookOptions<Props>;

/** @deprecated Use RenderHookOptions with hydrate: false instead */
interface ClientRenderHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
  hydrate?: false | undefined;
}

/** @deprecated Use RenderHookOptions with hydrate: true instead */
interface HydrateHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
  hydrate: true;
}

Best Practices

  1. Wrap updates in act() - All state changes must be in act()
  2. Access via result.current - Never destructure result.current
  3. Use waitFor for async - Don't assume timing
  4. Test in isolation - Focus on hook logic, not component behavior
  5. Provide context - Use wrapper for hooks needing context/providers
  6. Test cleanup - Verify useEffect cleanup with unmount()

Install with Tessl CLI

npx tessl i tessl/npm-testing-library--react@16.3.2

docs

async.md

configuration.md

events.md

hooks.md

index.md

queries.md

rendering.md

tile.json