Коротко: Практическое руководство по тестированию кастомных 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-репозитория
- Строгая типизация: Сигнатуры хуков и их опции описаны явными интерфейсами без типа
any. - Сброс моков: В
setupFilesактивирована автоматическая очистка (vi.clearAllMocks()) между тестами. - Оптимальное окружение: Использование
jsdomпо умолчанию и Browser Mode только для тестов, критичных к реальному браузерному API. - Хелперы провайдеров: Если проект использует 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 и изоляция контекстов позволяют создавать стабильный тестовый набор, защищающий архитектуру приложения при рефакторинге и снижающий технический долг.




.svg.webp)





