Типи фронтенд-тестування
Фронтенд-тестування перевіряє компоненти, логіку і взаємодію з користувачем на кількох рівнях, щоб знаходити баги до того, як вони потрапляють у продакшн.
Теорія
TL;DR
- Аналогія: юніт-тести перевіряють двигун окремо, інтеграційні перевіряють чи двигун і трансмісія працюють разом, E2E тести їдуть на машині по реальній дорозі
- Юніт-тести мокають залежності і виконуються менш ніж за 1ms; інтеграційні з'єднують реальні модулі; E2E запускають справжній браузер і займають секунди
- Стартовий розподіл: 70% юніт, 20% інтеграційні, 10% E2E
- Кожен тип ловить різне: юніт - помилки в логіці, інтеграційні - неправильну взаємодію між модулями, E2E - зламані сценарії користувача
Швидкий приклад
Юніт-тест з Jest і React Testing Library:
// Button.tsx
const Button = ({ onClick, label }: { onClick: () => void; label: string }) => (
<button onClick={onClick}>{label}</button>
);
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
test('викликає onClick при кліку', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} label="Натисни мене" />);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1); // проходить ✅
});Цей тест рендерить компонент, симулює клік і перевіряє що колбек спрацював один раз. Браузер не потрібен, мережевих викликів немає.
Ключова різниця
Юніт-тести ізолюють одну одиницю коду (функцію, хук, компонент), мокаючи все навколо. Інтеграційні тести з'єднують реальні модулі і дозволяють їм взаємодіяти між собою. E2E тести відкривають справжній браузер і симулюють поведінку користувача від логіну до оформлення замовлення. Тут є компроміс між швидкістю і реалізмом: юніт-тести завершуються менш ніж за 1ms, інтеграційні займають 10-100ms, E2E можуть виконуватись кілька секунд за сценарій.
Коли використовувати
- Чиста функція або утиліта: юніт-тест (найшвидший фідбек, найпростіше дебажити)
- Компонент що використовує дочірні компоненти або хук: інтеграційний тест (реальний потік даних, без моків для внутрішніх частин)
- Сценарій користувача - логін, оформлення замовлення, відправка форми: E2E тест (ловить layout shift, проблеми з редиректами, специфічні баги браузера)
- Стабільність UI між деплоями: snapshot тест (ловить випадкові зміни класів або розмітки)
- Точна піксельна відповідність: visual regression тест з Percy або Chromatic
Таблиця порівняння
| Тип | Область | Швидкість | Нестабільність | Інструменти | Що ловить |
|---|---|---|---|---|---|
| Unit | Функція/хук/компонент | <1ms | Низька | Jest, Vitest, RTL | Логічні помилки |
| Integration | 2+ компоненти або сервіси | 10-100ms | Середня | Jest + MSW, RTL | Форма не проходить API валідацію |
| E2E | Повний сценарій у браузері | 1-10s | Висока | Playwright, Cypress | Кнопка логіну зламана в mobile Safari |
| Snapshot | Серіалізований рендер | 1-5ms | Низька | Jest snapshots | CSS клас перейменовано, стилі зламались |
| Visual | Піксельні скріншоти | 100ms-1s | Середня | Percy, Chromatic | Зміна товщини шрифту зсуває верстку |
Правило вибору: юніт для логіки, інтеграційні для взаємодії між частинами, E2E для критичних сценаріїв, snapshot/visual для стабільності UI.
Як тести працюють під капотом
Jest виконується в Node.js і використовує jsdom для симуляції DOM без реального браузера. Саме тому юніт і інтеграційні тести швидкі. Playwright і Cypress запускають справжній Chromium, WebKit або Firefox через браузерні API і вбудовують скрипти для контролю вкладок і перехоплення мережевих подій. Моки використовують JavaScript Proxy для перехоплення викликів до того, як вони досягають реального HTTP.
Типові помилки
Тестування деталей реалізації замість поведінки. Найпоширеніша помилка в юніт-тестах - перевіряти внутрішній стан або мок-функцію на кшталт setState напряму.
// Погано: зламається якщо поміняти useState на useReducer
test('оновлює стан', () => {
const setState = jest.fn();
expect(setState).toHaveBeenCalled(); // ❌ тестує як, а не що
});
// Добре: тестує що користувач бачить насправді
test('показує оновлений текст', () => {
fireEvent.click(button);
expect(screen.getByText('Оновлено')).toBeVisible(); // ✅ стабільно при рефакторингу
});Реальні API виклики в юніт-тестах. Без моків тест робить справжні HTTP запити. Це додає 500ms+ на тест, ламається при відсутності мережі і може попасти в продакшн базу даних. Використовуй MSW або jest.mock для перехоплення запитів.
Надмірне використання E2E. Деякі команди пишуть E2E на все, бо так здається реалістичніше. Але E2E тести в 10 разів повільніші і падають через race condition і проблеми з таймінгом, не пов'язані з реальними багами. Тримай E2E для 5-10 критичних сценаріїв.
Snapshot на все дерево компонентів. Snapshot повної сторінки змінюється щоразу, коли змінюється будь-який дочірній компонент. Розробники оновлюють snapshot файли, не читаючи що саме змінилось. Через деякий час ніхто не довіряє цим тестам і вони перетворюються на шум. Краще робити snapshot серіалізованих даних або невеликих стабільних частин UI.
Де зустрічається у реальних проектах
- React застосунки: RTL для поведінки компонентів, MSW для API моків (Netflix, Shopify)
- Next.js: Playwright для E2E сторінок (документація самого Vercel)
- Vue/Nuxt: Vitest для юніт тестів, Cypress для E2E (підхід GitLab)
- SvelteKit: Web Test Runner для юніт, Playwright для E2E (офіційні стартери)
Питання на співбесіді
Q: Що таке піраміда тестування (testing pyramid) і чому рекомендується розподіл 70/20/10?
A: Піраміда (Mike Cohn) розміщує найбільше тестів внизу, де вони найдешевші і найшвидші. Юніт-тести дешево писати і вони виконуються за мілісекунди. E2E дорогі в підтримці і повільні. Розподіл відображає ROI - витрати проти покриття, а не догму.
Q: Як зменшити нестабільність E2E тестів?
A: Не використовуй фіксовані setTimeout. Використовуй waitFor або вбудований auto-waiting Playwright. Додай retry логіку в CI. Використовуй --trace в Playwright щоб записати що сталось при падінні - без цього дебажити E2E наосліп.
Q: Коли MSW, а коли jest.mock для мокування API?
A: MSW перехоплює на рівні мережі і працює в браузері і Node одночасно, тому підходить для інтеграційних тестів що використовують fetch або axios. jest.mock працює на рівні модулів і простіший для юніт-тестів що викликають сервісну функцію напряму.
Q: Як тестувати кастомний React хук?
A: Використовуй renderHook з React Testing Library: const { result } = renderHook(useCounter); act(() => result.current.increment()); expect(result.current.count).toBe(1);
Q (senior): Спроектуй стратегію тестування для e-commerce чекауту. Яким буде розподіл і чому?
A: 70% юніт для математики кошика, логіки знижок і розрахунків ціни. 20% інтеграційні для форми і моків платіжного шлюзу. 10% E2E для сценарію від додавання в кошик до оплати, бо тут живе дохід. Джуніорська відповідь - рівне покриття всіх типів. Сеніорська відповідь оптимізує витрати: юніти дешеві, E2E ловить те що юніти не можуть.
Приклади
Базовий: юніт-тест для утилітарної функції
// priceUtils.ts
export function applyDiscount(price: number, discount: number): number {
if (discount < 0 || discount > 100) throw new Error('Невірна знижка');
return price * (1 - discount / 100);
}
// priceUtils.test.ts
import { applyDiscount } from './priceUtils';
test('застосовує знижку 20% правильно', () => {
expect(applyDiscount(100, 20)).toBe(80); // ✅
});
test('кидає помилку при невірній знижці', () => {
expect(() => applyDiscount(100, 150)).toThrow('Невірна знижка'); // ✅
});Чиста функція без компонентів, без DOM, без моків. Виконується менш ніж за 1ms і дає миттєвий фідбек по логіці.
Середній: інтеграційний тест для форми з валідацією
// UserForm.tsx - форма з React Hook Form
import { useForm } from 'react-hook-form';
type FormData = { email: string; age: number };
const UserForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">Email</label>
<input id="email" {...register('email', { required: true })} />
<label htmlFor="age">Вік</label>
<input id="age" type="number" {...register('age', { min: 18 })} />
{errors.age && <span>Мінімальний вік - 18</span>}
<button type="submit">Відправити</button>
</form>
);
};
// UserForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import UserForm from './UserForm';
test('показує помилку валідації якщо вік менше 18', async () => {
render(<UserForm />);
fireEvent.change(screen.getByLabelText(/вік/i), { target: { value: '16' } });
fireEvent.click(screen.getByRole('button', { name: /відправити/i }));
expect(await screen.findByText(/мінімальний вік/i)).toBeInTheDocument(); // ❌ блокує відправку
});Це інтеграційний тест: перевіряє компонент форми разом з логікою валідації React Hook Form. Бібліотека не мокається, реальна валідація виконується. В цьому і є відмінність від юніт-тесту.
Просунутий: E2E тест Playwright для сценарію логіну
// login.spec.ts - E2E тест Playwright
import { test, expect } from '@playwright/test';
test('користувач може залогінитись і потрапити на дашборд', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Пароль').fill('password123');
await page.getByRole('button', { name: /увійти/i }).click();
// auto-waiting без фіксованих таймаутів
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Ласкаво просимо')).toBeVisible(); // ✅
});Playwright відкриває справжній Chromium, заповнює поля як реальний користувач і перевіряє результуючий URL і вміст сторінки. Це ловить те, що юніт-тести не можуть: логіку редиректів, обробку сесії на сервері і layout shift на різних розмірах екрану.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.