# Що тестувати у React: unit, component чи E2E – стратегія без «100% coverage»

> Не піраміда тестів, а decision tree: який ризик ловить кожен рівень, що тестувати unit-тестами, що через Testing Library, що в Playwright – і що не тестувати взагалі.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/react-testing-strategy

---

**Коротка відповідь.** Тести – це risk management, а не ритуал. Питання не «скільки тестів», а «який баг тут найдорожчий і який тест ловить його найдешевше». Робоче правило: чиста логіка → unit; поведінка компонента → Testing Library; критичні користувацькі шляхи → кілька E2E у Playwright; контракти з API → типи і валідація на межі. А implementation details – стан, внутрішні виклики, CSS-класи – не тестуємо взагалі: такі тести падають при рефакторингу і мовчать при реальних багах. Нижче – decision tree і один feature, покритий на всіх трьох рівнях.

## Тести як risk management

У Testing Library це сформульовано одним реченням, яке варто повісити над монітором: «The more your tests resemble the way your software is used, the more confidence they can give you».

Звідси простий decision tree для кожного шматка коду:

1. **Що тут може зламатись і скільки це коштує?** Невідправлена форма оплати ≠ з'їхала іконка.
2. **На якому рівні баг проявляється найраніше?** Помилка в розрахунку знижки видна вже в чистій функції – не треба E2E, щоб її впіймати.
3. **Який тест найдешевший в утриманні?** Тест, що падає при кожному рефакторингу, має від'ємну цінність: його або гасять, або перестають читати.

Coverage у цій схемі – побічний ефект, а не ціль. 100% покриття легко отримати тестами, які виконують код, нічого не стверджуючи.

## Рівень 1: чиста логіка → unit

Усе, що можна винести в чисту функцію – парсинг, фільтрація, розрахунки, редюсери – тестується unit-тестами. Вони мілісекундні, їх дешево мати сотні, і вони точно вказують місце поломки.

Наш наскрізний feature: пошук товарів із фільтрами. Логіку фільтрації виносимо з компонента:

```ts
// filterProducts.ts
export type Filters = { query: string; inStockOnly: boolean; maxPrice?: number };

export function filterProducts(products: Product[], filters: Filters): Product[] {
  const query = filters.query.trim().toLowerCase();
  return products.filter((product) => {
    if (query && !product.title.toLowerCase().includes(query)) return false;
    if (filters.inStockOnly && !product.inStock) return false;
    if (filters.maxPrice !== undefined && product.price > filters.maxPrice) return false;
    return true;
  });
}
```

```ts
// filterProducts.test.ts
it('порожній query не фільтрує нічого', () => {
  expect(filterProducts(products, { query: '  ', inStockOnly: false })).toHaveLength(products.length);
});

it('maxPrice межа включна', () => {
  const result = filterProducts(products, { query: '', inStockOnly: false, maxPrice: 100 });
  expect(result.every((p) => p.price <= 100)).toBe(true);
});
```

Зверни увагу: саме тут живуть edge cases (трим, регістр, межі). Ловити їх через клікання в E2E – у сотні разів дорожче.

## Рівень 2: поведінка компонента → Testing Library

Компонентний тест відповідає на питання «що бачить і може зробити користувач», а не «що лежить у стані». Той самий feature:

```tsx
// ProductSearch.test.tsx
it('показує лише товари, що відповідають запиту', async () => {
  const user = userEvent.setup();
  render(<ProductSearch products={products} />);

  await user.type(screen.getByRole('searchbox', { name: /пошук/i }), 'навушники');

  expect(screen.getByText('Bluetooth-навушники')).toBeInTheDocument();
  expect(screen.queryByText('Клавіатура')).not.toBeInTheDocument();
});

it('порожній результат показує empty state, а не білий екран', async () => {
  const user = userEvent.setup();
  render(<ProductSearch products={products} />);

  await user.type(screen.getByRole('searchbox', { name: /пошук/i }), 'щось неіснуюче');

  expect(screen.getByText(/нічого не знайдено/i)).toBeInTheDocument();
});
```

Правила гігієни цього рівня:

- Селектори – через ролі й доступні імена (`getByRole`), а не testid-и на кожному кроці: так тест заодно перевіряє доступність.
- API мокається на межі (MSW або мок fetch-а), а не всередині компонента.
- Один тест – один користувацький сценарій. «Тест на 200 рядків, що перевіряє все» неможливо дебажити.

## Рівень 3: критичні шляхи → Playwright E2E

E2E дорогі: повільні, вимагають інфраструктури, вміють бути flaky (як із цим боротись – [окрема стаття](/blog/playwright-no-flaky-tests)). Тому їх мало, і кожен охороняє шлях, поломка якого коштує реальних грошей або користувачів:

```ts
// search-and-order.spec.ts
test('користувач знаходить товар через пошук і доходить до checkout', async ({ page }) => {
  await page.goto('/catalog');

  await page.getByRole('searchbox', { name: /пошук/i }).fill('навушники');
  await page.getByRole('link', { name: /bluetooth-навушники/i }).click();
  await page.getByRole('button', { name: /додати в кошик/i }).click();
  await page.getByRole('link', { name: /оформити/i }).click();

  await expect(page.getByRole('heading', { name: /оформлення замовлення/i })).toBeVisible();
});
```

Цей тест не перевіряє логіку фільтрів (її вже покрили unit-и) – він перевіряє, що **шви між шарами** зшиті: роутинг, дані, стан кошика, реальний браузер.

Четвертий, часто забутий рівень – **контракти з API**: типи + схема-валідація (zod чи аналог) на межі. Тоді зміна бекенда ламає build або один contract-тест, а не двадцять компонентних.

## Що НЕ тестувати

- **Внутрішній стан** (`expect(component.state.isOpen)`) – користувач не бачить стану, він бачить модалку.
- **Факт виклику внутрішніх функцій** – це цементування реалізації; після рефакторингу тест падає, хоча поведінка ідентична.
- **CSS і верстку поелементно** – «кнопка синя» ламається від зміни токена; для візуалу або ніяк, або скріншот-тести на обрані екрани.
- **Чужі бібліотеки** – ти не маєш тестувати, що react-hook-form уміє валідувати.
- **Тривіальні прокидання пропсів** – тест, що перевіряє «пропс дійшов», не ловить жодного реального бага.

Симптом проблеми: тест упав → ти правиш *тест*, а не код. Якщо це трапляється системно – тестуються implementation details.

## Coverage vs confidence

Coverage корисний в одному режимі: як **детектор мертвих зон** («цей модуль ніхто не виконує взагалі»). Як KPI він шкідливий: мотивує писати тести до простих рядків, а не до страшних сценаріїв. Чесна метрика – питання «якщо зараз задеплоїти, що мене розбудить уночі?» і наявність тесту саме там. Для співбесід ця різниця, до речі, теж важлива – про тести на [live coding](/blog/testy-dlia-live-coding) питають саме щоб побачити мислення ризиками, а не знання синтаксису expect.

## Типові помилки

- Інвертована піраміда: 40 E2E, нуль unit-ів – кожен реліз чекає годину на CI і все одно боїться.
- Моки всього: компонентний тест, де замокано стільки, що тестується власне мок.
- Тест-після-бага без тесту-до: виправив – додай тест, що відтворював баг, інакше він повернеться.
- Ігнор доступності в селекторах: якщо `getByRole` не знаходить кнопку – це не проблема тесту, це [проблема доступності](/blog/react-accessibility-audit).

## Практичне завдання

Візьми один живий feature свого проєкту і розклади за decision tree: (1) винеси логіку в чисту функцію + 5 unit-тестів на edge cases; (2) один компонентний тест на головний сценарій і один на empty/error state; (3) якщо feature лежить на критичному шляху – один Playwright-тест через ролі. Потім видали один тест на implementation details, якщо знайдеш. Майже напевно знайдеш.
