Исторически попытки внедрения Test-Driven Development во фронтенд-разработку часто приводили к разочарованию. Причина заключалась в ошибочном объекте тестирования: разработчики пытались писать TDD-тесты на визуальную разметку, CSS-селекторы (классы Tailwind или BEM), внутреннее состояние хуков (state, props) или структуру виртуального дерева компонентов. При малейшем изменении дизайна такие тесты становились помехой, требуя постоянной переделки без реального обнаружения дефектов.
User-Centric UI TDD решает эту проблему за счет изменения фокуса:
UI = f(state, events). Тест специфицирует причинно-следственную связь: «Когда пользователь взаимодействует с элементом X, дерево доступности переходит в состояние Y».combobox, listbox, button, status), их текстовые метки и состояния (aria-expanded, aria-selected) являются публичным API компонента.renderHook().TDD в React позволяет спроектировать чистый компонент с высокой степенью доступности (a11y) еще до того, как дизайнер согласует финальные CSS-стили.
Внутренний цикл User-Centric TDD для React моделирует полный стек прохождения событий через планировщик React 19:
+-----------------------------------------------------------------------+
| USER-CENTRIC REACT TDD CYCLE |
+-----------------------------------------------------------------------+
| |
| 1. Спецификация через пользовательский контракт (RED) |
| [userEvent.type(input, 'TypeScript')] |
| │ |
| ▼ |
| 2. Диспетчеризация событий браузера |
| (focusin -> keydown -> input -> change -> keyup) |
| │ |
| ▼ |
| 3. React 19 Concurrent Scheduler |
| (Fiber Work Loop -> State Transition -> Microtasks Flush) |
| │ |
| ▼ |
| 4. Мутация DOM и дерева доступности (GREEN) |
| [<div role="listbox"> -> <li role="option">TypeScript</li>] |
| │ |
| ▼ |
| 5. Верификация контракта ARIA |
| expect(screen.getByRole('option', { name: 'TypeScript' })) |
| |
+-----------------------------------------------------------------------+Описать тест взаимодействия пользователя через userEvent. Сформулировать ожидания доступности (getByRole, getByLabelText).
Реализовать семантическую разметку и локальный стейт, достаточный для прохождения тестов.
Вынести сложное состояние, таймеры или дебаунс в хук и протестировать через renderHook.
Добавить обработку клавиш (Escape, Enter, стрелки), захват фокуса и aria-атрибуты под защитой тестов.
Почему в React TDD категорически не рекомендуется тестировать внутреннее состояние компонентов (state) и приватные методы хуков?
Enter.useActionState и useOptimistic с блокировкой полей, лоадерами и доступными сообщениями об ошибках.localStorage, медиа-запросов и таймеров неактивности пользователя.Escape и возврата фокуса на элемент-триггер.Реализуем комплексную задачу: фильтр тегов TagFilterSearch с дебаунсированным поиском. Разработку разобьем на две части:
useDebouncedSearch через renderHook().TagFilterSearch через React Testing Library и @testing-library/user-event.useDebouncedSearch#Хук должен принимать поисковую строку и задержку в миллисекундах, возвращая дебаунсированное значение. Сразу настраиваем управление виртуальным временем через vi.useFakeTimers().
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebouncedSearch } from './use-debounced-search';
describe('useDebouncedSearch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('должен возвращать исходное значение сразу при первой инициализации', () => {
const { result } = renderHook(() => useDebouncedSearch('react', 300));
expect(result.current).toBe('react');
});
it('должен обновлять значение только по истечении указанной задержки', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedSearch(value, 300),
{ initialProps: { value: 'react' } }
);
// Меняем входящее значение
rerender({ value: 'react 19' });
// До истечения таймера значение остается старым
expect(result.current).toBe('react');
// Проматываем время вперед на 299 мс
act(() => {
vi.advanceTimersByTime(299);
});
expect(result.current).toBe('react');
// Проматываем оставшуюся 1 мс
act(() => {
vi.advanceTimersByTime(1);
});
expect(result.current).toBe('react 19');
});
it('должен сбрасывать предыдущий таймер при быстром последовательном вводе', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedSearch(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'abc' });
act(() => {
vi.advanceTimersByTime(200);
});
// Прошло 400 мс суммарно, но между вторым и третьим вводом прошло лишь 200 мс
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('abc');
});
});Тест падает, так как модуль ./use-debounced-search еще не существует. Переходим к реализации (GREEN).
import { useState, useEffect } from 'react';
export function useDebouncedSearch<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timerId);
};
}, [value, delayMs]);
return debouncedValue;
}Все 3 теста хука проходят успешно (PASS).
TagFilterSearch#Спроектируем компонент TagFilterSearch, удовлетворяющий требованиям:
role="combobox").role="listbox") с доступными опциями (role="option").import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TagFilterSearch } from './tag-filter-search';
describe('TagFilterSearch', () => {
const availableTags = ['Architecture', 'Vitest', 'React 19', 'Performance', 'TypeScript'];
it('должен отображать поле ввода и показывать отфильтрованные подсказки при вводе', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
render(
<TagFilterSearch
tags={availableTags}
selectedTags={[]}
onChange={handleTagsChange}
/>
);
const input = screen.getByRole('combobox', { name: /поиск тегов/i });
expect(input).toBeInTheDocument();
// Вводим поисковый запрос
await user.type(input, 'Type');
// Ожидаем появления списка доступных вариантов
const option = await screen.findByRole('option', { name: 'TypeScript' });
expect(option).toBeInTheDocument();
// Вариант, не подходящий под фильтр, не должен присутствовать
expect(screen.queryByRole('option', { name: 'Architecture' })).toBeNull();
});
});Тест падает с ошибкой отсутствия компонента.
'use client';
import React, { useState, useId, useMemo } from 'react';
export interface TagFilterSearchProps {
readonly tags: readonly string[];
readonly selectedTags: readonly string[];
readonly onChange: (nextTags: readonly string[]) => void;
}
export function TagFilterSearch({
tags,
selectedTags,
onChange,
}: TagFilterSearchProps): React.JSX.Element {
const [query, setQuery] = useState('');
const inputId = useId();
const listboxId = useId();
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]);
const filteredTags = useMemo(() => {
if (!query.trim()) return [];
return tags
.filter((tag) => !selectedSet.has(tag))
.filter((tag) => tag.toLowerCase().includes(query.toLowerCase()));
}, [tags, query, selectedSet]);
const hasSuggestions = filteredTags.length > 0;
return (
<div className="tag-filter-container">
<label htmlFor={inputId}>Поиск тегов</label>
<input
id={inputId}
role="combobox"
aria-expanded={hasSuggestions}
aria-controls={hasSuggestions ? listboxId : undefined}
aria-autocomplete="list"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{hasSuggestions && (
<ul id={listboxId} role="listbox" aria-label="Найденные теги">
{filteredTags.map((tag) => (
<li
key={tag}
role="option"
aria-selected={false}
onClick={() => {
onChange([...selectedTags, tag]);
setQuery('');
}}
>
{tag}
</li>
))}
</ul>
)}
</div>
);
}Первый тест проходит (PASS).
Добавляем требования:
onChange с добавленным тегом, инпут очищается, а список подсказок закрывается.onChange без этого тега.it('должен добавлять тег при клике на опцию и очищать поле ввода', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
const { rerender } = render(
<TagFilterSearch
tags={availableTags}
selectedTags={[]}
onChange={handleTagsChange}
/>
);
const input = screen.getByRole('combobox', { name: /поиск тегов/i });
await user.type(input, 'React');
const option = screen.getByRole('option', { name: 'React 19' });
await user.click(option);
// Проверяем вызов коллбэка
expect(handleTagsChange).toHaveBeenCalledTimes(1);
expect(handleTagsChange).toHaveBeenCalledWith(['React 19']);
// Симулируем обновление родительского состояния
rerender(
<TagFilterSearch
tags={availableTags}
selectedTags={['React 19']}
onChange={handleTagsChange}
/>
);
// Инпут очистился, меню скрылось
expect(input).toHaveValue('');
expect(screen.queryByRole('listbox')).toBeNull();
// Появился бейдж выбранного тега
const removeButton = screen.getByRole('button', { name: /удалить тег react 19/i });
expect(removeButton).toBeInTheDocument();
});
it('должен вызывать onChange с удалением тега при клике на бейдж', async () => {
const user = userEvent.setup();
const handleTagsChange = vi.fn();
render(
<TagFilterSearch
tags={availableTags}
selectedTags={['Architecture', 'Vitest']}
onChange={handleTagsChange}
/>
);
const removeArchButton = screen.getByRole('button', { name: /удалить тег architecture/i });
await user.click(removeArchButton);
expect(handleTagsChange).toHaveBeenCalledTimes(1);
expect(handleTagsChange).toHaveBeenCalledWith(['Vitest']);
});Тест падает, так как рендеринг бейджей и кнопок удаления еще не реализован.
'use client';
import React, { useState, useId, useMemo, useCallback } from 'react';
export interface TagFilterSearchProps {
readonly tags: readonly string[];
readonly selectedTags: readonly string[];
readonly onChange: (nextTags: readonly string[]) => void;
}
export function TagFilterSearch({
tags,
selectedTags,
onChange,
}: TagFilterSearchProps): React.JSX.Element {
const [query, setQuery] = useState('');
const inputId = useId();
const listboxId = useId();
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]);
const filteredTags = useMemo(() => {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return [];
return tags
.filter((tag) => !selectedSet.has(tag))
.filter((tag) => tag.toLowerCase().includes(trimmed));
}, [tags, query, selectedSet]);
const handleSelect = useCallback(
(tag: string) => {
onChange([...selectedTags, tag]);
setQuery('');
},
[selectedTags, onChange]
);
const handleRemove = useCallback(
(tagToRemove: string) => {
onChange(selectedTags.filter((t) => t !== tagToRemove));
},
[selectedTags, onChange]
);
const hasSuggestions = filteredTags.length > 0;
return (
<div className="tag-filter-search">
{/* Список уже выбранных тегов */}
{selectedTags.length > 0 && (
<ul className="selected-tags-list" aria-label="Выбранные теги">
{selectedTags.map((tag) => (
<li key={tag} className="selected-tag-item">
<span>{tag}</span>
<button
type="button"
aria-label={`Удалить тег ${tag}`}
onClick={() => handleRemove(tag)}
>
×
</button>
</li>
))}
</ul>
)}
{/* Поле поиска */}
<div className="input-wrapper">
<label htmlFor={inputId}>Поиск тегов</label>
<input
id={inputId}
role="combobox"
aria-expanded={hasSuggestions}
aria-controls={hasSuggestions ? listboxId : undefined}
aria-autocomplete="list"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* Выпадающий список совпадений */}
{hasSuggestions && (
<ul id={listboxId} role="listbox" aria-label="Найденные теги">
{filteredTags.map((tag) => (
<li
key={tag}
role="option"
aria-selected={false}
tabIndex={0}
onClick={() => handleSelect(tag)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSelect(tag);
}
}}
>
{tag}
</li>
))}
</ul>
)}
</div>
);
}Почему при TDD интерактивных компонентов формы с дебаунсом обязательно использовать @testing-library/user-event и advanceTimersByTimeAsync?
Использование метода fireEvent.change(input, { target: { value: 'react' } }) диспатчит синтетическое единичное событие DOM без полного жизненного цикла браузера (фокусировка, нажатие клавиш, поднятие событий keydown, keyup, пересчет каретки). userEvent.type() моделирует реальное поведение пользователя, гарантируя запуск всех зависимых обработчиков.
Вызов vi.useFakeTimers() в одном тесте без очистки через vi.useRealTimers() в блоке afterEach. Это ломает асинхронные операции последующих тестов сьюта (такие как findBy* и внутренние микротаски act), приводя к зависанию раннера по таймауту.
Попытки проверить внутреннее состояние компонента (например, через нестабильные внутренности react-test-renderer или доступ к свойствам Fiber). Если поведение интерфейса корректно отображается в Accessibility Tree, внутренний способ хранения состояния не должен волновать тест.
Методы библиотеки userEvent (v14+) возвращают Promise. Забытый await приводит к тому, что событие планируется в микротасках после завершения ассертов теста, порождая знаменитую ошибку: An update to Component inside a test was not wrapped in act(...).