Коротка відповідь. Тести – це 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 для кожного шматка коду:
- Що тут може зламатись і скільки це коштує? Невідправлена форма оплати ≠ з'їхала іконка.
- На якому рівні баг проявляється найраніше? Помилка в розрахунку знижки видна вже в чистій функції – не треба E2E, щоб її впіймати.
- Який тест найдешевший в утриманні? Тест, що падає при кожному рефакторингу, має від'ємну цінність: його або гасять, або перестають читати.
Coverage у цій схемі – побічний ефект, а не ціль. 100% покриття легко отримати тестами, які виконують код, нічого не стверджуючи.
Рівень 1: чиста логіка → unit
Усе, що можна винести в чисту функцію – парсинг, фільтрація, розрахунки, редюсери – тестується unit-тестами. Вони мілісекундні, їх дешево мати сотні, і вони точно вказують місце поломки.
Наш наскрізний feature: пошук товарів із фільтрами. Логіку фільтрації виносимо з компонента:
// 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;
});
}// 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:
// 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 (як із цим боротись – окрема стаття). Тому їх мало, і кожен охороняє шлях, поломка якого коштує реальних грошей або користувачів:
// 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 питають саме щоб побачити мислення ризиками, а не знання синтаксису expect.
Типові помилки
- Інвертована піраміда: 40 E2E, нуль unit-ів – кожен реліз чекає годину на CI і все одно боїться.
- Моки всього: компонентний тест, де замокано стільки, що тестується власне мок.
- Тест-після-бага без тесту-до: виправив – додай тест, що відтворював баг, інакше він повернеться.
- Ігнор доступності в селекторах: якщо
getByRoleне знаходить кнопку – це не проблема тесту, це проблема доступності.
Практичне завдання
Візьми один живий feature свого проєкту і розклади за decision tree: (1) винеси логіку в чисту функцію + 5 unit-тестів на edge cases; (2) один компонентний тест на головний сценарій і один на empty/error state; (3) якщо feature лежить на критичному шляху – один Playwright-тест через ролі. Потім видали один тест на implementation details, якщо знайдеш. Майже напевно знайдеш.