Testing Hooks and Custom Hooks in React

Loading

Testing hooks requires different approaches than component testing since hooks can’t be called outside of React components. Here’s a comprehensive guide to effectively testing both built-in and custom hooks.

Testing Built-in React Hooks

1. Testing useState Hooks

test('should update state with useState', () => {
  const TestComponent = () => {
    const [count, setCount] = React.useState(0);
    return (
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
    );
  };

  render(<TestComponent />);
  const button = screen.getByRole('button');
  expect(button).toHaveTextContent('Count: 0');

  fireEvent.click(button);
  expect(button).toHaveTextContent('Count: 1');
});

2. Testing useEffect Hooks

test('should fetch data on mount with useEffect', async () => {
  const mockFetch = jest.fn(() => Promise.resolve('data'));

  const TestComponent = () => {
    const [data, setData] = React.useState(null);

    React.useEffect(() => {
      mockFetch().then(setData);
    }, []);

    return <div>{data}</div>;
  };

  render(<TestComponent />);
  expect(mockFetch).toHaveBeenCalledTimes(1);

  await waitFor(() => {
    expect(screen.getByText('data')).toBeInTheDocument();
  });
});

Testing Custom Hooks

Using @testing-library/react-hooks

npm install @testing-library/react-hooks

Basic Custom Hook Test

import { renderHook } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());

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

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

Testing Hooks with Context

test('should consume context value', () => {
  const wrapper = ({ children }) => (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  );

  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current).toBe('dark');
});

Advanced Hook Testing Patterns

1. Testing Asynchronous Hooks

test('should handle async operations', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useFetchData());

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

  await waitForNextUpdate();

  expect(result.current.isLoading).toBe(false);
  expect(result.current.data).toEqual({ id: 1 });
});

2. Testing Hook Dependencies

test('should update when dependencies change', () => {
  const { result, rerender } = renderHook(
    ({ userId }) => useUserProfile(userId),
    { initialProps: { userId: 1 } }
  );

  expect(result.current.userId).toBe(1);

  rerender({ userId: 2 });
  expect(result.current.userId).toBe(2);
});

3. Testing Complex Hook Logic

describe('useFormValidation hook', () => {
  test('should validate email format', () => {
    const { result } = renderHook(() => useFormValidation());

    act(() => result.current.handleChange({
      target: { name: 'email', value: 'invalid' }
    }));

    expect(result.current.errors.email).toBe('Invalid email');
  });
});

Mocking in Hook Tests

1. Mocking External Dependencies

jest.mock('../api', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ name: 'John' }))
}));

test('should fetch user data', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useUser());

  await waitForNextUpdate();

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

2. Mocking Browser APIs

beforeAll(() => {
  window.matchMedia = jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
  }));
});

test('should detect mobile viewport', () => {
  const { result } = renderHook(() => useViewport());
  expect(result.current.isMobile).toBe(false);
});

Error Handling in Hook Tests

Testing Error States

test('should handle errors', async () => {
  jest.spyOn(console, 'error').mockImplementation(() => {});
  const mockFetch = jest.fn(() => Promise.reject('API Error'));

  const { result, waitForNextUpdate } = renderHook(() => 
    useApi(mockFetch)
  );

  await waitForNextUpdate();

  expect(result.current.error).toBe('API Error');
});

Performance Considerations

1. Avoiding Unnecessary Renders

test('should memoize values properly', () => {
  const calculateExpensiveValue = jest.fn(() => 42);

  const { result, rerender } = renderHook(
    ({ deps }) => useMemoizedValue(calculateExpensiveValue, deps),
    { initialProps: { deps: [1] } }
  );

  expect(calculateExpensiveValue).toHaveBeenCalledTimes(1);

  rerender({ deps: [1] }); // Same dependencies
  expect(calculateExpensiveValue).toHaveBeenCalledTimes(1);

  rerender({ deps: [2] }); // New dependencies
  expect(calculateExpensiveValue).toHaveBeenCalledTimes(2);
});

Integration with TypeScript

Typed Hook Tests

interface CounterActions {
  increment: () => void;
  decrement: () => void;
}

test('should type hook results correctly', () => {
  const { result } = renderHook(() => useCounter());

  // result.current is properly typed
  expectType<number>(result.current.count);
  expectType<CounterActions>(result.current.actions);

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

Best Practices

  1. Test Hook Contracts: Verify the hook’s API (return values and methods)
  2. Test Edge Cases: Empty states, error conditions, boundary values
  3. Keep Tests Focused: Test one behavior per test case
  4. Use Cleanup: Unsubscribe effects to prevent memory leaks
afterEach(() => {
  cleanup();
  jest.clearAllMocks();
});

Common Testing Patterns Cheat Sheet

Hook TypeTesting ApproachTools Needed
State ManagementVerify state changes after actionsrenderHook, act
Side EffectsMock dependencies, test cleanupjest.mock, waitFor
Context ConsumersWrap with Providercustom wrapper
Async OperationsWait for updates, test loading stateswaitForNextUpdate
Callback HandlersSimulate events, verify callsfireEvent, jest.fn

Leave a Reply

Your email address will not be published. Required fields are marked *