Дисциплина Test-Driven Development (TDD) для интерфейсов React 19: тестирование поведения вместо деталей реализации, запросы исключительно по ARIA-ролям (getByRole), достоверная эмуляция событий пользователя с @testing-library/user-event v14 и сверхбыстрый тест-раннер Vitest 4.
Разработка компонентов без TDD («сначала код, потом тесты») обычно приводит к трем системным дефектам:
useState. Любой визуальный рефакторинг ломает тесты, хотя логика работает безупречно.div без клавиатурного фокуса и семантики, недоступные для скринридеров и вспомогательных технологий.Методология TDD + React Testing Library заставляет разработчика встать на место пользователя: тест формулирует контракт взаимодействия с интерфейсом еще до того, как написана первая строчка JSX.
Классический цикл Кента Бека Red -> Green -> Refactor в экосистеме React:
+-------------------------------------------------------------------------------+
| TDD RED-GREEN-REFACTOR CYCLE |
| |
| 1. ФАЗА RED (Красный тест): |
| - Пишем тест от лица пользователя (screen.getByRole, user.click) |
| - Запускаем Vitest: ТЕСТ ОБЯЗАН УПАСТЬ по понятной причине |
| (например: "Unable to find accessible element with role: button") |
| | |
| v |
| 2. ФАЗА GREEN (Зеленый тест): |
| - Пишем минимальный, простейший код компонента для прохождения ассертов |
| - Запускаем Vitest: ТЕСТ СТАНОВИТСЯ ЗЕЛЕНЫМ (Pass) |
| | |
| v |
| 3. ФАЗА REFACTOR (Рефакторинг под защитой тестов): |
| - Выделяем чистые функции, оптимизируем мемоизацию и стили |
| - Улучшаем ARIA-атрибуты и контракты пропсов TypeScript |
| - ТЕСТЫ ОСТАЮТСЯ ЗЕЛЕНЫМИ на каждом шаге! |
+-------------------------------------------------------------------------------+Создание файла спецификации FilterSearchBar.test.tsx с описанием пользовательских действий: поиск поля ввода, набор текста, проверка появления индикатора очистки.
Запуск vitest --watch для мгновенной обратной связи (задержка выполнения менее 50 мс благодаря ESM-модулям Vite).
Написание минимального JSX-компонента с правильными семантическими тегами input, button и aria-label до успешного прохождения проверки.
Написание теста на debounce через fake-таймеры vi.useFakeTimers() с проверкой задержки вызова внешнего колбэка onChange.
Оптимизация ререндеров с React.useTransition(), вынос таймеров в кастомный хук под контролем зеленых тестов.
Какой селектор React Testing Library имеет высший приоритет при поиске элементов в тестах согласно философии Кента К. Доддса?
Разберем сквозной TDD-кейс: разработка доступного компонента фильтрации с дебаунсом, кнопкой очистки и индикатором загрузки.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterSearchBar } from './FilterSearchBar';
describe('FilterSearchBar (TDD Pipeline)', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('должен корректно рендерить поле поиска с семантической ролью searchbox', () => {
render(<FilterSearchBar onSearch={vi.fn()} />);
// Ищем инпут по семантической доступной роли
const searchInput = screen.getByRole('searchbox', { name: /поиск по каталогу/i });
expect(searchInput).toBeInTheDocument();
});
it('должен вызывать onSearch с дебаунсом 300 мс при наборе текста пользователем', async () => {
const onSearchMock = vi.fn();
// Инициализируем userEvent с поддержкой фейковых таймеров Vitest
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<FilterSearchBar onSearch={onSearchMock} debounceMs={300} />);
const searchInput = screen.getByRole('searchbox', { name: /поиск по каталогу/i });
// Пользователь вводит поисковый запрос
await user.type(searchInput, 'React 19');
// До истечения 300 мс колбэк НЕ должен быть вызван
expect(onSearchMock).not.toHaveBeenCalled();
// Проматываем таймер на 300 мс
vi.advanceTimersByTime(300);
// Проверяем вызов с итоговым значением
expect(onSearchMock).toHaveBeenCalledTimes(1);
expect(onSearchMock).toHaveBeenCalledWith('React 19');
});
it('должен очищать введенный текст и фокусировать инпут при клике на кнопку очистки', async () => {
const onSearchMock = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<FilterSearchBar onSearch={onSearchMock} />);
const searchInput = screen.getByRole('searchbox', { name: /поиск по каталогу/i });
await user.type(searchInput, 'Vitest');
vi.advanceTimersByTime(300);
// Кнопка очистки появляется только при наличии текста
const clearButton = screen.getByRole('button', { name: /очистить поле поиска/i });
expect(clearButton).toBeInTheDocument();
// Клик по кнопке очистки
await user.click(clearButton);
expect(searchInput).toHaveValue('');
expect(searchInput).toHaveFocus();
vi.advanceTimersByTime(300);
expect(onSearchMock).toHaveBeenLastCalledWith('');
});
});'use client';
import React, { useState, useEffect, useRef, useTransition } from 'react';
import { Search, X, Loader2 } from 'lucide-react';
export interface FilterSearchBarProps {
onSearch: (query: string) => void;
debounceMs?: number;
placeholder?: string;
}
export function FilterSearchBar({
onSearch,
debounceMs = 300,
placeholder = 'Введите ключевые слова...'
}: FilterSearchBarProps) {
const [value, setValue] = useState('');
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
// Дебаунс отправки поискового запроса
useEffect(() => {
const timer = setTimeout(() => {
startTransition(() => {
onSearch(value);
});
}, debounceMs);
return () => clearTimeout(timer);
}, [value, debounceMs, onSearch]);
const handleClear = () => {
setValue('');
inputRef.current?.focus();
};
return (
<div className="relative w-full max-w-lg">
{/* Иконка лупы */}
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none">
{isPending ? (
<Loader2 size={18} className="animate-spin text-accent-violet" />
) : (
<Search size={18} />
)}
</div>
{/* Семантический инпут с правильным aria-label */}
<input
ref={inputRef}
type="search"
role="searchbox"
aria-label="Поиск по каталогу"
value={value}
onChange={e => setValue(e.target.value)}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-bg-surface border border-border text-sm text-text-primary placeholder:text-text-secondary/60 focus:border-accent-violet outline-none transition shadow-sm"
/>
{/* Кнопка очистки с WAI-ARIA доступностью */}
{value.length > 0 && (
<button
type="button"
onClick={handleClear}
aria-label="Очистить поле поиска"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-secondary hover:text-text-primary rounded-lg transition"
>
<X size={16} />
</button>
)}
</div>
);
}Метод fireEvent.click() или fireEvent.change() отправляет одиночное синтетическое событие браузера, игнорируя реальную цепочку взаимодействий (pointerDown, focus, keyDown, input, change, pointerUp). В результате формы не валидируются, а выпадающие списки не раскрываются. Всегда используйте библиотеку @testing-library/user-event v14 с вызовом userEvent.setup().
Злоупотребление waitFor(() => expect(...)) для синхронных проверок замедляет выполнение тестов и скрывает реальные таймауты рантайма. Используйте методы семейства findBy* (await screen.findByRole(...)), которые имеют встроенный асинхронный механизм ожидания элемента в DOM.
Попытки проверить внутреннее состояние компонента вроде expect(component.state.isOpen).toBe(true) делают тесты хрупкими. Тестируйте только то, что видит пользователь: появление диалогового окна с ролью dialog в дереве документа.
Какая технология сетевого мокирования считается золотым стандартом для тестирования React-компонентов в 2026 году?