Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Unit-тестирование React-хуков в Vitest: типобезопасный renderHook на практике

Тимофей Ищенко

Коротко: Практическое руководство по тестированию кастомных React-хуков в Vitest с TypeScript. Разбираем renderHook, типизацию generics, act, rerender и mock-провайдеры.

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

В экосистеме Vitest эта задача решается с помощью утилиты renderHook из @testing-library/react (или пакета vitest-browser-react для браузерного режима). Благодаря поддержке дженериков в TypeScript разработчик получает полную типобезопасность: автодополнение методов, строгую проверку пропсов и валидацию возвращаемых структур на этапе компиляции тестов.


Подготовка окружения: конфигурация Vitest под React и TypeScript

Для запуска тестов React-хуков в Vitest требуется среда эмуляции DOM, так как runtime React обращается к глобальным объектам window и document.

Необходимые зависимости

Установите тестовый раннер Vitest, эмулятор DOM (jsdom или happy-dom) и React Testing Library:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom

Настройка конфигурационного файла

Создайте или обновите vitest.config.ts. Если в проекте уже используется vite.config.ts, секцию test можно разместить прямо в нем (используя функцию defineConfig из vitest/config):

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});

В файле преднастройки подключите кастомные матчеры для удобной работы с утверждениями:

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

Анатомия renderHook: как работает вывод типов в TypeScript

Функция renderHook оборачивает вызов хука во внутренний синтетический функциональный компонент, монтирует его и возвращает объект-контейнер для доступа к актуальному результату.

Дженерики RenderHookResult

Сигнатура функции типизирована двумя основными generic-параметрами:

function renderHook<Result, Props>(
  render: (initialProps: Props) => Result,
  options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>
  • Result: Тип структуры, которую возвращает хук. TypeScript автоматически выводит его из возвращаемого значения переданного колбэка.
  • Props: Тип входных параметров, которые передаются в хук и могут обновляться через метод rerender.

Корректная работа со ссылкой result.current

Объект result, возвращаемый функцией renderHook, действует как неизменяемый контейнер-ссылка (ref). Актуальное состояние хука всегда находится в поле result.current.

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

// result.current строго типизирован как возвращаемый тип useCounter
expect(result.current.count).toBe(0);

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

1. Синхронное состояние и работа с act()

При прямом вызове методов хука, обновляющих стейт (useState или useReducer), изменения необходимо оборачивать в утилиту act(). Это гарантирует, что React применит все запланированные эффекты и ререндеры до выполнения следующих ассертов.

Напишем базовый хук счетчика:

// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

export interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export function useCounter(initialValue = 0): UseCounterReturn {
  const [count, setCount] = useState<number>(initialValue);

  const increment = useCallback(() => setCount((prev) => prev + 1), []);
  const decrement = useCallback(() => setCount((prev) => prev - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

Модульный тест для useCounter:

// src/hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('инициализирует состояние значением по умолчанию', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('инициализирует состояние переданным значением', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

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

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

    act(() => {
      result.current.decrement();
    });
    expect(result.current.count).toBe(0);
  });
});

2. Тестирование обновления входных параметров через rerender

Когда поведение хука зависит от внешних параметров (пропсов), необходимо проверить, как он реагирует на их изменение без полного перемонтирования.

// src/hooks/useMultiplier.ts
import { useMemo } from 'react';

interface MultiplierProps {
  value: number;
  multiplier: number;
}

export function useMultiplier({ value, multiplier }: MultiplierProps): number {
  return useMemo(() => value * multiplier, [value, multiplier]);
}

Тест с использованием строго типизированных initialProps и метода rerender:

// src/hooks/useMultiplier.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMultiplier } from './useMultiplier';

describe('useMultiplier', () => {
  it('пересчитывает значение при обновлении входных пропсов', () => {
    const { result, rerender } = renderHook(
      (props: { value: number; multiplier: number }) => useMultiplier(props),
      {
        initialProps: { value: 5, multiplier: 2 },
      }
    );

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

    // TypeScript проверяет соответствие типов обновленного объекта
    rerender({ value: 5, multiplier: 4 });

    expect(result.current).toBe(20);
  });
});

3. Асинхронные операции и сайд-эффекты

Для проверки хуков с сетевыми запросами или асинхронными процессами применяется утилита waitFor.

// src/hooks/useAsyncData.ts
import { useState, useEffect } from 'react';

interface AsyncState<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
}

export function useAsyncData<T>(fetcher: () => Promise<T>): AsyncState<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let isMounted = true;
    setIsLoading(true);

    fetcher()
      .then((res) => {
        if (isMounted) {
          setData(res);
          setIsLoading(false);
        }
      })
      .catch((err: Error) => {
        if (isMounted) {
          setError(err);
          setIsLoading(false);
        }
      });

    return () => {
      isMounted = false;
    };
  }, [fetcher]);

  return { data, isLoading, error };
}

Тестирование асинхронного поведения:

// src/hooks/useAsyncData.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAsyncData } from './useAsyncData';

describe('useAsyncData', () => {
  it('успешно загружает данные и обновляет статус загрузки', async () => {
    interface User {
      id: number;
      name: string;
    }

    const mockData: User = { id: 1, name: 'Тимофей' };
    const mockFetcher = vi.fn().mockResolvedValue(mockData);

    const { result } = renderHook(() => useAsyncData<User>(mockFetcher));

    // Начальное состояние до резолва промиса
    expect(result.current.isLoading).toBe(true);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();

    // Ожидание завершения асинхронного эффекта
    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });
});

4. Тестирование хуков с React Context

Если хук использует useContext, прямой вызов renderHook без провайдера приведет к ошибке или вернет значение по умолчанию. Для передачи контекста используется свойство wrapper в RenderHookOptions.

// src/context/AuthContext.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface AuthContextType {
  user: string | null;
  login: (name: string) => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [user, setUser] = useState<string | null>(null);

  const login = (name: string) => setUser(name);
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Тест с передачей провайдера:

// src/hooks/useAuth.test.ts
import React from 'react';
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../context/AuthContext';

describe('useAuth', () => {
  it('выбрасывает ошибку при вызове вне AuthProvider', () => {
    expect(() => renderHook(() => useAuth())).toThrow(
      'useAuth must be used within an AuthProvider'
    );
  });

  it('предоставляет методы контекста и обновляет пользователя', () => {
    const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
      <AuthProvider>{children}</AuthProvider>
    );

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

    expect(result.current.user).toBeNull();

    act(() => {
      result.current.login('Тимофей Ищенко');
    });

    expect(result.current.user).toBe('Тимофей Ищенко');

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

    expect(result.current.user).toBeNull();
  });
});

Vitest Browser Mode: альтернатива через vitest-browser-react

В экосистеме Vitest развивается Browser Mode, позволяющий выполнять тесты в реальных браузерах (Chromium, Firefox, WebKit) через Playwright или WebDriver.

Пакет vitest-browser-react предоставляет собственную реализацию renderHook:

import { test, expect } from 'vitest';
import { renderHook } from 'vitest-browser-react';
import { act } from 'react';
import { useCounter } from './useCounter';

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

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

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

Сравнение подходов:

  • jsdom выполняется внутри Node.js, быстро стартует и покрывает подавляющее большинство модульных тестов логики хуков.
  • vitest-browser-react запускает тесты в реальном браузерном движке. Это необходимо, если хук плотно взаимодействует с вычислением геометрических размеров элементов, Canvas, WebGL или специфичными событиями drag-and-drop.

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

1. Деструктуризация свойств из result.current

При деструктуризации примитивные типы данных (числа, строки, булевы флаги) копируются по значению:

// ❌ ОШИБКА: count сохранит начальное значение 0 навсегда
const { result } = renderHook(() => useCounter());
const { count, increment } = result.current;

act(() => {
  increment();
});

expect(count).toBe(1); // Тест упадет: count равен 0
// ✅ ПРАВИЛЬНО: чтение свойства напрямую через ref-объект result.current
const { result } = renderHook(() => useCounter());

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

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

2. Мутации состояния вне блока act()

Если функция, изменяющая состояние хука, вызывается напрямую без act(), React выведет предупреждение в консоль, а утверждения expect могут выполниться до завершения рендера. Любые прямые триггеры стейта должны быть обернуты в act(() => { ... }).

3. Отсутствие проверки размонтирования (unmount)

Если кастомный хук регистрирует слушатели событий, WebSocket-соединения или запускает таймеры, необходимо проверять корректность очистки ресурсов:

it('очищает интервал при размонтировании', () => {
  const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
  const { unmount } = renderHook(() => useAutoPolling());

  unmount();

  expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
});

Чек-лист для production-репозитория

  1. Строгая типизация: Сигнатуры хуков и их опции описаны явными интерфейсами без типа any.
  2. Сброс моков: В setupFiles активирована автоматическая очистка (vi.clearAllMocks()) между тестами.
  3. Оптимальное окружение: Использование jsdom по умолчанию и Browser Mode только для тестов, критичных к реальному браузерному API.
  4. Хелперы провайдеров: Если проект использует Redux Toolkit, React Query или Router, настройте универсальную функцию renderHookWithProviders для устранения дублирования кода оберток.

FAQ

Зачем использовать renderHook вместо тестового dummy-компонента?

renderHook избавляет от необходимости создавать вспомогательную разметку и промежуточные элементы DOM. Он обеспечивает прямой доступ к возвращаемым методам хука через result.current, а также предоставляет встроенные методы rerender и unmount.

Почему деструктуризация result.current приводит к неверным ассертам?

При деструктуризации примитивные значения копируются. При изменении состояния React обновляет ссылку result.current, но деструктурированная переменная сохраняет старое значение из момента инициализации.

Что предпочесть: jsdom или happy-dom?

happy-dom демонстрирует более высокую скорость выполнения тестов в Node.js, однако jsdom точнее повторяет стандарты DOM и обладает более полной поддержкой специфичных методов браузерного окружения.

Когда вызывать act() вручную?

Вызовы методов fireEvent и waitFor уже содержат act() внутри себя. Оборачивать вызов в act() вручную требуется тогда, когда метод изменения состояния вызывается напрямую из result.current.

Как тестировать хуки с React Query или Redux?

Для таких хуков создается компонент-обертка (wrapper), в который передаются QueryClientProvider (с отключенными повторными попытками) или Provider хранилища Redux. Для каждого теста создается изолированный экземпляр клиента/стора.


Заключение

Инструмент renderHook в сочетании с Vitest и TypeScript обеспечивает эффективный способ изолированного тестирования кастомных React-хуков. Строгий контроль типов, своевременная обработка циклов рендера через act и изоляция контекстов позволяют создавать стабильный тестовый набор, защищающий архитектуру приложения при рефакторинге и снижающий технический долг.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также