Пошук уроків, статей та іншого контенту
Визначите межі тестування, уникнете перевірки деталей реалізації та оберете правильний рівень тесту для сценарію.
Тест має відповідати на запитання:
Чи працює функціональність так, як її очікує користувач або інший код системи?
У React-компонента є кілька рівнів поведінки:
користувач бачить певний інтерфейс;
користувач взаємодіє з елементами;
компонент викликає передані callback-функції з правильними даними;
компонент правильно реагує на loading, помилку та успішний результат;
бізнес-правила повертають правильний результат;
кілька компонентів працюють разом із роутером, контекстом або API.
Тестувати варто саме ці домовленості — observable behavior, тобто поведінку, яку можна спостерігати ззовні.
Не варто тестувати спосіб, яким компонент досягає результату:
назви useState;
кількість викликів useEffect;
внутрішній стан;
структуру дерева React-компонентів;
конкретні CSS-класи, якщо вони не є частиною вимоги;
виклики внутрішніх функцій;
деталі роботи React або React Testing Library.
Наприклад, вимога «після введення пошукового запиту та натискання кнопки викликається пошук» є хорошою вимогою для тесту.
А вимога «після натискання кнопки викликається функція handleSubmit, яка змінює isSubmitted» описує реалізацію, а не поведінку.
Найвища цінність у тестів, які перевіряють сценарії, важливі для продукту:
відправлення форми;
валідація даних;
авторизація;
додавання або видалення сутності;
перемикання станів завантаження;
повідомлення про помилки;
доступність основних дій;
обмеження доступу для різних ролей.
Тест не повинен перевіряти кожну можливу комбінацію DOM-вузлів. Він має захищати важливі правила системи.
Для компонента контрактом можуть бути:
доступні ролі та назви елементів;
текст, який бачить користувач;
callback-функції та їхні аргументи;
реакція на props;
поведінка під час взаємодії;
відображення станів loading, error і success.
Наприклад, якщо компонент отримує onSubmit, важливо перевірити, що він викликається з нормалізованим значенням. Неважливо, чи значення зберігається в useState, useReducer або взагалі не зберігається.
// SearchForm.jsx
import { useState } from 'react';
export function SearchForm({ onSearch, isLoading = false }) {
const [query, setQuery] = useState('');
function handleSubmit(event) {
event.preventDefault();
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return;
}
onSearch(normalizedQuery);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="search">Пошук</label>
<input
id="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !query.trim()}>
{isLoading ? 'Пошук…' : 'Знайти'}
</button>
</form>
);
}Тест перевіряє поведінку форми через доступні елементи та дії користувача:
// SearchForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { SearchForm } from './SearchForm';
describe('SearchForm', () => {
it('передає очищений запит після відправлення форми', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} />);
const input = screen.getByRole('textbox', { name: 'Пошук' });
const button = screen.getByRole('button', { name: 'Знайти' });
await user.type(input, ' react testing ');
await user.click(button);
expect(onSearch).toHaveBeenCalledOnce();
expect(onSearch).toHaveBeenCalledWith('react testing');
});
it('не відправляє порожній запит', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} />);
const input = screen.getByRole('textbox', { name: 'Пошук' });
await user.type(input, ' ');
expect(screen.getByRole('button', { name: 'Знайти' })).toBeDisabled();
await user.keyboard('{Enter}');
expect(onSearch).not.toHaveBeenCalled();
});
it('блокує введення та кнопку під час пошуку', () => {
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} isLoading />);
expect(screen.getByRole('textbox', { name: 'Пошук' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Пошук…' })).toBeDisabled();
});
});У цьому прикладі немає перевірок:
чи існує стан із назвою query;
чи використовується саме useState;
чи викликається handleSubmit;
чи форма має конкретний клас;
чи input є першим дочірнім елементом form.
Компонент можна повністю переписати, зберігши поведінку, і тести залишаться актуальними.
Unit-тест доречний для ізольованої логіки без React та браузера:
форматування;
обчислення;
валідації;
перетворення даних;
чистих функцій бізнес-логіки.
// formatPrice.js
export function formatPrice(amount, currency = 'UAH') {
return new Intl.NumberFormat('uk-UA', {
style: 'currency',
currency,
}).format(amount);
}Для такої функції не потрібно монтувати компонент:
// formatPrice.test.js
import { describe, expect, it } from 'vitest';
import { formatPrice } from './formatPrice';
describe('formatPrice', () => {
it('форматує суму в гривнях', () => {
expect(formatPrice(1250, 'UAH')).toBe('1 250,00 грн');
});
});Якщо логіку можна перевірити як звичайну функцію, це зазвичай дешевше, швидше та стабільніше, ніж перевіряти її через React-компонент.
Компонентний тест підходить, коли важливе поєднання:
props;
локального стану;
подій;
DOM;
дочірніх компонентів;
доступності.
Такі тести мають перевіряти завершений сценарій, а не кожен внутрішній крок.
Замість цього:
expect(setIsOpen).toHaveBeenCalledWith(true);
expect(menuState).toBe(true);краще перевірити:
await user.click(screen.getByRole('button', { name: 'Відкрити меню' }));
expect(screen.getByRole('menu')).toBeVisible();Інтеграційний тест потрібен, коли помилка може виникнути на межі кількох частин:
форма та схема валідації;
компонент і контекст;
список, фільтр і пагінація;
сторінка та роутинг;
компонент і шар роботи з даними.
Інтеграційний тест часто дає більше впевненості, ніж багато ізольованих тестів, бо перевіряє реальний потік даних.
Наприклад, для форми замовлення корисніше перевірити сценарій:
користувач вводить адресу;
валідація приймає дані;
кнопка стає доступною;
відправляється правильний запит;
користувач бачить повідомлення про успіх.
Не обов’язково окремо тестувати кожен внутрішній компонент, якщо поведінка вже надійно покрита інтеграційним тестом.
End-to-end-тест проходить сценарій через реальний браузер і кілька шарів застосунку.
Він виправданий для небагатьох найважливіших потоків:
вхід у систему;
оформлення замовлення;
створення критичної сутності;
оплата;
основний сценарій для ролі користувача.
E2E-тести повільніші та складніші в обслуговуванні. Не потрібно переносити в них кожну перевірку, яку дешевше виконати на рівні unit або компонента.
Для кожного сценарію поставте кілька запитань:
Чи є тут окрема чиста функція з бізнес-правилом?
Чи важлива взаємодія користувача з одним компонентом?
Чи взаємодіють кілька частин системи?
Чи повинен сценарій працювати саме в реальному браузері?
Який тест найменшої вартості дасть достатню впевненість?
Практичне правило:
чиста логіка — unit-тест;
поведінка одного компонента — компонентний тест;
взаємодія кількох частин — інтеграційний тест;
критичний потік усієї системи — E2E-тест.
Це не сувора класифікація. Межі можуть змінюватися залежно від архітектури застосунку. Важливішим за назву тесту є рівень реальної поведінки, яку він перевіряє.
Не перевіряйте стан безпосередньо:
expect(component.state.isOpen).toBe(true);У функціональних компонентах такий підхід ще й прив’язує тест до деталей реалізації.
Перевіряйте наслідок зміни стану:
expect(screen.getByRole('dialog')).toBeVisible();Назви та кількість внутрішніх функцій можуть змінитися без зміни поведінки. Тестування handleClick, handleChange або toggleMenu напряму робить рефакторинг дорогим.
Тестуйте подію, яка доступна користувачу, і результат цієї події.
Не потрібно перевіряти, що Dashboard рендерить саме Sidebar, Header і Content, якщо користувачу важлива не назва компонентів, а доступний інтерфейс.
Така перевірка зазвичай дублює код компонента:
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('header')).toBeInTheDocument();Якщо ці елементи не мають самостійного контракту, тест не дає значної впевненості.
Snapshot може бути корисним для невеликих стабільних структур, але великий snapshot:
погано пояснює, яка поведінка зламалася;
часто оновлюється без аналізу;
фіксує деталі розмітки;
створює шум під час рев’ю.
Зазвичай краще явно перевірити важливий текст, роль, стан або дію.
Не потрібно тестувати, що:
React виконує ререндер;
userEvent генерує події;
браузер підтримує стандартну поведінку кнопки;
компонент бібліотеки правильно реалізує власний API.
Тестуйте лише свою конфігурацію та власні правила на межі з бібліотекою.
Моки потрібні, щоб ізолювати зовнішні або дорогі залежності:
HTTP-запити;
таймери;
системний час;
браузерні API;
випадковість;
сторонні сервіси.
Однак надмірне мокання власних компонентів або модулів приховує помилки інтеграції.
Якщо замокати весь дочірній компонент, тест батьківського компонента вже не перевіряє, чи вони справді сумісні. Такий mock може бути виправданий, якщо дочірній компонент:
дуже дорогий;
належить зовнішній системі;
має власний набір тестів;
не є частиною сценарію, який зараз перевіряється.
Мок має відповідати межі тесту, а не просто спрощувати написання тесту.
Для взаємодії переважно використовуйте селектори, близькі до способу сприйняття інтерфейсу користувачем:
роль і доступна назва;
мітка поля;
видимий текст;
placeholder, якщо це справді частина контракту;
спеціальний атрибут на кшталт data-testid — коли інших стабільних варіантів немає.
Не використовуйте CSS-класи або довгі CSS-селектори лише тому, що вони доступні.
data-testid доречний, коли:
елемент не має доступної ролі;
потрібно знайти технічний вузол;
однаковий текст або роль зустрічається в кількох місцях;
компонент візуалізації не має корисної семантики.
Але додавання data-testid до кожного елемента зазвичай сигналізує, що тест прив’язаний до DOM, а не до поведінки.
Високе покриття рядків не гарантує якість тестів. Можна виконати кожен рядок і не перевірити важливу умову.
Важливіші запитання:
які правила захищає тест;
які помилки він може виявити;
чи перевіряє він негативний сценарій;
чи зрозуміло з назви, яка поведінка очікується;
чи переживе тест внутрішній рефакторинг.
Для компонента зі складною логікою краще мати кілька тестів за станами:
початковий стан;
коректні дані;
некоректні дані;
завантаження;
помилка;
успішне завершення.
Не потрібно створювати окремий тест для кожної властивості, якщо кілька властивостей є частиною одного сценарію.
Якщо тест знає назви станів, hooks і внутрішніх функцій, він надто тісно пов’язаний із кодом.
Як виправити: виконувати дії через публічний інтерфейс і перевіряти видимий результат або зовнішній callback.
Великий snapshot може пройти після механічного оновлення, навіть якщо змінилася важлива поведінка.
Як виправити: перевіряти конкретні умови, які мають значення для користувача.
Помилки часто виникають у валідації, завантаженні та обробці відмови API.
Як виправити: додавати негативні сценарії та переходи між станами.
Тести можуть стати зеленими, хоча реальні компоненти не працюють разом.
Як виправити: мокати зовнішні межі, але залишати разом ті частини власного застосунку, чию інтеграцію потрібно перевірити.
Повільний E2E-тест не є заміною швидким тестам логіки та компонентів.
Як виправити: переносити перевірку на найнижчий рівень, який усе ще перевіряє потрібний контракт.
Тестуйте поведінку та контракти, а не деталі реалізації.
Перевіряйте сценарії з точки зору користувача або споживача компонента.
Чисту логіку тестуйте unit-тестами без React.
Взаємодію компонента з DOM перевіряйте компонентними тестами.
Для взаємодії кількох частин застосунку використовуйте інтеграційні тести.
E2E залишайте для небагатьох критичних потоків.
Не прив’язуйте тести до внутрішнього стану, назв hooks, дерева компонентів і CSS-класів.
Мокайте зовнішні залежності, але не приховуйте інтеграцію власних модулів без потреби.
Обирайте найдешевший рівень тесту, який дає достатню впевненість у потрібній поведінці.