Кастомные hooks: тестирование и отладка

Кастомные хуки в React используются для инкапсуляции логики состояния, побочных эффектов и повторно используемого поведения. Они не рендерят UI напрямую, а значит, не могут тестироваться так же, как компоненты. Основная задача тестирования кастомных хуков — проверить корректность логики, изменение состояния, обработку эффектов и взаимодействие с внешними зависимостями без привязки к DOM-структуре.

React Testing Library ориентирована на тестирование поведения через пользовательские сценарии, однако для хуков применяется более низкоуровневый подход, при котором хук исполняется в изолированном окружении.


Инструменты для тестирования кастомных хуков

Для тестирования хуков используется пакет:

@testing-library/react

Начиная с версии 13, отдельный пакет @testing-library/react-hooks считается устаревшим, а его функциональность перенесена в основной пакет.

Ключевая функция:

renderHook

Она позволяет выполнить кастомный хук в тестовой среде и получить доступ к его текущему состоянию и методам.


Базовый пример кастомного хука

import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);

  return { count, increment, decrement };
}

Тестирование начального состояния

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

test('возвращает начальное значение', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});

Ключевые моменты:

  • result.current всегда содержит актуальное состояние хука
  • каждый вызов renderHook изолирован
  • параметры хука передаются через функцию-обёртку

Тестирование изменения состояния

Все операции, изменяющие состояние, должны быть обёрнуты в act.

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

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

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

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

act гарантирует, что все обновления состояния и эффекты будут обработаны до выполнения assertions.


Тестирование хуков с параметрами, изменяемыми со временем

function useMultiplier(value) {
  return value * 2;
}

test('реагирует на изменение входных параметров', () => {
  const { result, rerender } = renderHook(
    ({ value }) => useMultiplier(value),
    { initialProps: { value: 2 } }
  );

  expect(result.current).toBe(4);

  rerender({ value: 5 });
  expect(result.current).toBe(10);
});

rerender позволяет имитировать изменение props, аналогично повторному рендеру компонента.


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

import { useEffect, useState } from 'react';

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}
test('обновляет document.title', () => {
  renderHook(() => useDocumentTitle('Test title'));
  expect(document.title).toBe('Test title');
});

Важно учитывать побочные эффекты и очищать глобальное состояние после тестов при необходимости.


Асинхронные хуки и ожидание обновлений

function useAsyncData() {
  const [data, setData] = useState(null);

  useEffect(() => {
    Promise.resolve('result').then(setData);
  }, []);

  return data;
}
import { waitFor } from '@testing-library/react';

test('возвращает данные асинхронно', async () => {
  const { result } = renderHook(() => useAsyncData());

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

waitFor повторяет проверку до выполнения условия или истечения таймаута.


Мокирование зависимостей внутри хуков

При использовании API, таймеров или сторонних библиотек применяется мокирование.

jest.spyOn(global, 'fetch').mockResolvedValue({
  json: async () => ({ value: 42 }),
});
function useFetchValue() {
  const [value, setValue] = useState(null);

  useEffect(() => {
    fetch('/api').then(r => r.json()).then(data => setValue(data.value));
  }, []);

  return value;
}
test('загружает значение из API', async () => {
  const { result } = renderHook(() => useFetchValue());

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

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

Если хук использует useContext, необходимо предоставить провайдер.

const ThemeContext = React.createContext('light');

function useTheme() {
  return React.useContext(ThemeContext);
}
test('получает значение из контекста', () => {
  const wrapper = ({ children }) => (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  );

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

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

Отладка кастомных хуков

Для локальной отладки применяются:

  • временные console.log внутри хука
  • использование debug() при тестировании компонентов-обёрток
  • проверка result.all для анализа истории состояний
expect(result.all).toEqual([
  { count: 0 },
  { count: 1 },
]);

result.all полезен для анализа последовательных обновлений состояния.


Типичные ошибки при тестировании хуков

  • отсутствие act при изменении состояния
  • тестирование реализации вместо поведения
  • прямое использование таймеров без jest.useFakeTimers
  • утечки глобального состояния между тестами
  • чрезмерное мокирование, скрывающее реальные проблемы

Принципы качественного тестирования кастомных хуков

  • проверка публичного API хука, а не внутренней реализации
  • изоляция логики от UI
  • минимальное количество моков
  • фокус на изменении состояния и побочных эффектах
  • повторяемость и независимость тестов

Кастомные хуки, покрытые корректными тестами, значительно упрощают сопровождение бизнес-логики, повышают предсказуемость поведения приложения и уменьшают связность компонентов.