Тестирование хуков

Тестирование React-хуков выполняется через renderHook из @testing-library/react, который создаёт минимальный компонент-обёртку для вызова хука и обеспечивает доступ к его состоянию и методам.

Зачем нужно

Кастомные хуки содержат логику, которую сложно тестировать через компоненты (множество сценариев, граничные случаи). renderHook позволяет изолированно тестировать хук без UI, проверять обновления состояния и side effects напрямую.

Где используется

  • Кастомные хуки с бизнес-логикой (useCart, useAuth, useFetch)
  • Хуки с асинхронными операциями
  • Хуки, использующие Context
  • Хуки с debounce, throttle, таймерами

Основной контент

Базовый тест хука

// useCounter.ts
import { useState } from 'react';

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

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('начинает с начального значения', () => {
  const { result } = renderHook( => useCounter(5));
  expect(result.current.count).toBe(5);
});

test('increment увеличивает count', () => {
  const { result } = renderHook( => useCounter);

  act(() => {
    result.current.increment;
  });

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

test('reset возвращает к начальному значению', () => {
  const { result } = renderHook( => useCounter(10));

  act(() => {
    result.current.increment;
    result.current.increment;
    result.current.reset();
  });

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

Тестирование асинхронного хука

// useFetch.ts
export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(url)
      .then((r) => r.json())
      .then(setData)
      .catch((e) => setError(e.message))
      .finally( => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

// useFetch.test.ts
global.fetch = jest.fn;

test('загружает данные', async () => {
  (fetch as jest.Mock).mockResolvedValue({
    json: async  => ({ id: 1, name: 'Alice' }),
  });

  const { result } = renderHook( => useFetch('/api/users/1'));

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

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

  expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
  expect(result.current.error).toBeNull();
});

test('обрабатывает ошибку', async () => {
  (fetch as jest.Mock).mockRejectedValue(new Error('Network Error'));

  const { result } = renderHook( => useFetch('/api/users/1'));

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

  expect(result.current.error).toBe('Network Error');
  expect(result.current.data).toBeNull();
});

Хук с Context — wrapper

// useTheme.ts использует ThemeContext

test('useTheme возвращает текущую тему', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider value="dark">{children}</ThemeProvider>
  );

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

  expect(result.current.theme).toBe('dark');
});

Хук с параметрами (rerender)

test('обновляет данные при смене url', async () => {
  (fetch as jest.Mock).mockResolvedValue({ json: async  => ({ id: 1 }) });

  const { result, rerender } = renderHook(
    ({ url }) => useFetch(url),
    { initialProps: { url: '/api/users/1' } }
  );

  await waitFor( => expect(result.current.loading).toBe(false));
  expect(fetch).toHaveBeenCalledWith('/api/users/1');

  (fetch as jest.Mock).mockResolvedValue({ json: async  => ({ id: 2 }) });
  rerender({ url: '/api/users/2' });

  await waitFor( => expect(result.current.loading).toBe(false));
  expect(fetch).toHaveBeenCalledWith('/api/users/2');
});

Частые ошибки

  • Нет act при обновлении состояния — Jest предупредит о "act warning"; оборачивай любые вызовы, меняющие state
  • Нет waitFor для асинхронных обновленийresult.current.loading проверяется до завершения Promise; используй waitFor
  • Тест реализации вместо поведения — не проверяй useState напрямую; проверяй что возвращает хук

Связанные темы

Ресурсы