Тестирование интерфейсов в React 19 принципиально отличается от тестирования обычных функций JavaScript. React-компонент — это не просто функция, возвращающая разметку, а декларативный узел дерева волокон (Fiber Tree), связанный с асинхронным планировщиком (Scheduler), очередью обновлений состояния (State Queue) и жизненным циклом эффектов.
Исторически библиотеки тестирования (такие как Enzyme) фокусировались на интроспекции внутренних деталей: инженеры проверяли приватный стейт компонента (wrapper.state()), пропсы дочерних элементов или вызывали внутренние методы инстанса. Это делало тесты крайне хрупкими: любой внутренний рефакторинг ломал тесты, даже если пользовательский интерфейс работал безупречно.
Библиотека React Testing Library (RTL) утверждает фундаментальный принцип:
«Чем ближе ваши тесты напоминают то, как программное обеспечение используется реальными людьми, тем больше уверенности они могут вам дать.»
Для тестирования изолированных хуков используется утилита renderHook. Она предоставляет доступ к актуальному значению хука через объект result.current.
При мутации состояния хука напрямую в тесте ключевую роль играет функция act(). Без act() проверки утверждений происходят до того, как React успел перерендерить компонент в памяти.
При вызове renderHook библиотека разворачивает виртуальный компонент-хост в среде Happy-DOM:
renderHook(() => useCounter(0))
│
▼
┌─────────────────────────────────────────────────────┐
│ Синтетический хост-компонент TestComponent │
│ │
│ const value = useCounter(0); │
│ result.current = value; // Актуальная ссылка │
│ return null; │
└─────────────────────────┬───────────────────────────┘
│
▼
Вызов act(() => { result.current.increment(); })
│
▼
┌─────────────────────────────────────────────────────┐
│ React 19 Scheduler & Microtask Flush │
│ 1. Планирование обновления setState │
│ 2. Синхронное применение очереди рендера Fiber │
│ 3. Запуск useEffect / useLayoutEffect │
│ 4. Обновление объекта result.current │
└─────────────────────────┬───────────────────────────┘
│
▼
expect(result.current.count).toBe(1)Каждый вызов хука изолирован. Свойство result.current всегда указывает на самое свежее значение, возвращенное хуком после завершения последнего цикла рендеринга React.
Вызов render(<Component />) или renderHook(() => useHook()) в изолированном окружении happy-dom.
Поиск интерактивных узлов по их ARIA-ролям через screen.getByRole('button', { name: /.../i }).
Имитация реальных кликов и нажатий клавиш через userEvent.click() с автоматическим ожиданием microtask queue.
Сравнение наблюдаемого состояния интерфейса через матчеры toBeInTheDocument(), toHaveTextContent() или toBeDisabled().
Для чего необходима функция act() при вызове методов кастомного хука в тестах?
useLocalStorage, сохраняющего преференсы пользователя, с проверкой сериализации JSON и обработки сбоев квоты хранилища.Enter, Escape, Tab).useCountdown в связке с Fake Timers Vitest (vi.useFakeTimers()) без реального ожидания секунд в тестах.Установим пакеты React Testing Library и эмулятор пользовательских событий в секцию devDependencies:
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/domСоздадим хук счетчика с поддержкой шага, минимального и максимального порогов:
import { useState, useCallback } from 'react';
export interface UseCounterOptions {
readonly initialValue?: number;
readonly step?: number;
readonly min?: number;
readonly max?: number;
}
export interface UseCounterReturn {
readonly count: number;
readonly increment: () => void;
readonly decrement: () => void;
readonly reset: () => void;
readonly setCount: (value: number) => void;
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { initialValue = 0, step = 1, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY } = options;
const [count, internalSetCount] = useState<number>(() => {
return Math.min(Math.max(initialValue, min), max);
});
const increment = useCallback(() => {
internalSetCount((prev) => Math.min(prev + step, max));
}, [step, max]);
const decrement = useCallback(() => {
internalSetCount((prev) => Math.max(prev - step, min));
}, [step, min]);
const reset = useCallback(() => {
internalSetCount(Math.min(Math.max(initialValue, min), max));
}, [initialValue, min, max]);
const setCount = useCallback((value: number) => {
internalSetCount(Math.min(Math.max(value, min), max));
}, [min, max]);
return {
count,
increment,
decrement,
reset,
setCount,
};
}Для запуска этого теста требуется окружение happy-dom. Укажем его с помощью директивы в самом верху файла:
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter (Кастомный React-хук)', () => {
it('инициализирует счетчик значением по умолчанию', () => {
// 1. Arrange & 2. Act
const { result } = renderHook(() => useCounter());
// 3. Assert
expect(result.current.count).toBe(0);
});
it('инициализирует счетчик переданным initialValue', () => {
const { result } = renderHook(() => useCounter({ initialValue: 10 }));
expect(result.current.count).toBe(10);
});
it('увеличивает значение на заданный шаг при вызове increment()', () => {
const { result } = renderHook(() => useCounter({ initialValue: 5, step: 2 }));
// Вызовы изменения состояния хука ОБЯЗАНЫ оборачиваться в act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(7);
});
it('не превышает установленный максимум max', () => {
const { result } = renderHook(() => useCounter({ initialValue: 9, step: 5, max: 10 }));
act(() => {
result.current.increment();
});
// 9 + 5 = 14, но максимум ограничен 10
expect(result.current.count).toBe(10);
});
it('сбрасывает значение до начального при вызове reset()', () => {
const { result } = renderHook(() => useCounter({ initialValue: 100 }));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(102);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(100);
});
});Теперь создадим интерактивный компонент кнопки-счетчика, использующий этот хук:
import React from 'react';
import { useCounter } from '@/hooks/useCounter';
export interface CounterWidgetProps {
readonly initialValue?: number;
readonly max?: number;
}
export function CounterWidget({ initialValue = 0, max = 10 }: CounterWidgetProps) {
const { count, increment, decrement, reset } = useCounter({ initialValue, max });
const isMaxReached = count >= max;
return (
<section aria-label="Виджет счетчика">
<div role="status" aria-live="polite">
Текущий счет: {count}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={decrement}
aria-label="Уменьшить значение"
>
-
</button>
<button
type="button"
onClick={increment}
disabled={isMaxReached}
aria-label="Увеличить значение"
>
+
</button>
<button
type="button"
onClick={reset}
aria-label="Сбросить счетчик"
>
Сброс
</button>
</div>
{isMaxReached && (
<p role="alert">Достигнут максимальный лимит!</p>
)}
</section>
);
}Напишем тест компонента, проверяющий поведение глазами пользователя:
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { CounterWidget } from './CounterWidget';
describe('CounterWidget (Интерактивный UI-компонент)', () => {
it('рендерит начальное состояние и доступные элементы управления', () => {
// 1. Arrange: рендерим компонент в виртуальный DOM
render(<CounterWidget initialValue={5} max={10} />);
// 2. Assert: проверяем отображение статуса через доступную роль
const status = screen.getByRole('status');
expect(status.textContent).toBe('Текущий счет: 5');
// Кнопки присутствуют и доступны
expect(screen.getByRole('button', { name: 'Увеличить значение' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Уменьшить значение' })).toBeDefined();
});
it('увеличивает счетчик при клике пользователя по кнопке увеличения', async () => {
// Инициализируем симулятор действий пользователя
const user = userEvent.setup();
render(<CounterWidget initialValue={0} max={5} />);
const incrementBtn = screen.getByRole('button', { name: 'Увеличить значение' });
const status = screen.getByRole('status');
// userEvent автоматически оборачивает действия в act() и ждет микротаски
await user.click(incrementBtn);
expect(status.textContent).toBe('Текущий счет: 1');
await user.click(incrementBtn);
expect(status.textContent).toBe('Текущий счет: 2');
});
it('блокирует кнопку увеличения и выводит предупреждение при достижении лимита', async () => {
const user = userEvent.setup();
render(<CounterWidget initialValue={4} max={5} />);
const incrementBtn = screen.getByRole('button', { name: 'Увеличить значение' }) as HTMLButtonElement;
// Кликаем, достигая максимума (5)
await user.click(incrementBtn);
// Кнопка должна стать disabled
expect(incrementBtn.disabled).toBe(true);
// Должно появиться оповещение role="alert"
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('Достигнут максимальный лимит!');
});
});Почему в React Testing Library поиск элементов по роли (screen.getByRole) считается предпочтительнее, чем поиск по селекторам классов (querySelector('.btn')) или data-testid?
Если вызвать метод хука, мутирующий стейт, без обертки в act(), тест выполнит проверку до того, как React завершит рендеринг:
Warning: An update to TestComponent inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});// ❌ Ошибка: вызов без act()
result.current.increment();
expect(result.current.count).toBe(1); // Может упасть или выдать Warning
// ✅ Правильное решение: обернуть в act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);Метод fireEvent.click(button) просто диспетчеризует синтетическое событие click в узел DOM. Он не фокусирует элемент, не генерирует сопутствующие события pointerdown, mousedown, focus, mouseup и не проверяет атрибут disabled.
// ❌ Устаревший подход: оторван от реальности
fireEvent.click(button);
// ✅ Современный стандарт: реалистичная эмуляция через userEvent
const user = userEvent.setup();
await user.click(button); // Генерирует полную цепочку событий и проверяет disabledПривязка тестов к классам стилей (например, Tailwind-классам bg-blue-500 или CSS-модулям styles.submitBtn):
// ❌ Хрупко: редизайн кнопки сломает тест при работающем функционале
const btn = container.querySelector('.btn-blue');
// ✅ Надежно: поиск по доступной семантической роли
const btn = screen.getByRole('button', { name: /отправить/i });Значение result.current мутирует при ререндере хука. Если деструктурировать его заранее в переменную, переменная сохранит старое примитивное значение:
const { result } = renderHook(() => useCounter());
// ❌ Ошибка: count скопирован как примитив 0 и никогда не изменится!
const { count, increment } = result.current;
act(() => {
increment();
});
expect(count).toBe(1); // Упадет: count остался 0!
// ✅ Правильное решение: всегда обращаться через result.current.count
expect(result.current.count).toBe(1);