Історично спроби впровадження Test-Driven Development у фронтенд-розробку нерідко призводили до розчарування та відчуття опору. Головна причина крилася в хибному виборі об'єкта тестування: інженери писали TDD-тести на візуальну розмітку, селектори CSS-класів (токени Tailwind або BEM-класи), внутрішній стан хуків (useState, props) або внутрішню структуру віртуального дерева React. За найменшого редизайну такі крихкі тести ламалися, вимагаючи постійного переписування без реального виявлення дефектів у функціональності.
User-Centric UI TDD вирішує цю проблему через зміщення фокуса тестування:
UI = f(state, events). Специфікація фіксує причинно-наслідковий зв'язок: «Коли користувач взаємодіє з елементом X, дерево доступності переходить у стан Y».combobox, listbox, button, status), їхні текстові мітки та стани (aria-expanded, aria-selected) виступають справжнім публічним API компонента.renderHook().TDD у React дозволяє спроєктувати чистий, доступний (a11y) компонент ще до того, як дизайнери узгодять фінальні візуальні стилі.
Внутрішній цикл 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 |
| (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' })) |
| |
+-----------------------------------------------------------------------+ArrowUp/ArrowDown/Enter) та озвученням скрінрідерами.useActionState та useOptimistic із блокуванням полів, індикаторами завантаження та доступними сповіщеннями про помилки.localStorage, медіа-запитів та таймерів неактивності користувача.Escape та повернення фокуса на елемент, що відкрив модальне вікно.Реалізуємо практичну задачу: віджет вибору тегів TagFilterSearch із дебаунс-пошуком. Розіб'ємо процес на дві окремі TDD-ітерації:
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');
});
});Тест падає, оскільки хук ще не створено (FAIL).
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('повинен рендерити combobox та відображати відфільтровані варіанти при введенні', 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();
});
});Тест падає через відсутність модуля компонента (FAIL).
'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>
);
}Усі тести успішно виконуються (PASS). Завдяки TDD ми створили повноцінний WAI-ARIA (combobox 1.2) компонент із повною підтримкою клавіатури та без витоків пам'яті.
Виклик fireEvent.change(input, { target: { value: 'react' } }) надсилає одиночну синтетичну DOM-подію без повноцінного життєвого циклу браузера (зміна фокуса, події keydown, input, change, keyup, перерахунок позиції курсора). Утиліта userEvent.type() точно відтворює дії реального користувача.
Виклик vi.useFakeTimers() в одному тесті без повернення реального часу через vi.useRealTimers() у блоці afterEach. Це ламає асинхронні запити наступних тестів (такі як findBy* та мікротаски всередині act), спричиняючи зависання тест-раннера по таймауту.
Спроби асертити внутрішній стан (useState) або властивості Fiber-вузлів React. Якщо поведінка інтерфейсу точно відображається в Accessibility Tree, спосіб збереження стану всередині компонента не повинен бути пов'язаний із тестом.
Методи userEvent (v14+) повертають Promise. Пропущений await призводить до того, що подія планується в чергу мікротасок вже після виконання асертів, породжуючи відоме попередження: An update to Component inside a test was not wrapped in act(...).
Визначити ролі елементів (combobox, dialog, form) та доступні текстові мітки. Написати перший тест взаємодії через getByRole.
Створити мінімальну розмітку з необхідними атрибутами aria-* та обробниками подій для проходження тесту.
Написати тести на введення тексту, кліки, навігацію стрілками та динамічні стани aria-selected/aria-expanded.
За наявності складних таймерів або мережевої логіки винести її в кастомний хук і протестувати через renderHook.
Додати класи Tailwind CSS чи модулі. Оскільки тести зав'язані на контракти ARIA, візуальні стилі ніколи не порушать перевірки.