Пошук уроків, статей та іншого контенту
Перевірите введення даних, валідацію, відправлення та повідомлення про помилки у React-формах.
Тестування React-форми має перевіряти не внутрішній стан компонента, а поведінку, яку бачить користувач:
введення даних у поля;
повідомлення про помилки валідації;
заборону відправлення невалідних даних;
передавання правильних значень у функцію відправлення;
повідомлення про успішну відправку;
повідомлення про помилку сервера або іншої зовнішньої операції.
Для цього зручно використовувати:
@testing-library/react — рендеринг компонента та пошук елементів;
@testing-library/user-event — імітація дій користувача;
vitest або Jest — запуск тестів і перевірки;
@testing-library/jest-dom — додаткові перевірки DOM.
У тестах краще взаємодіяти з формою через доступні для користувача елементи: мітки полів, ролі кнопок і текст повідомлень.
Розглянемо форму реєстрації з полями електронної пошти та пароля.
Функція onSubmit передається через props. Це дає змогу в тесті замінити реальний HTTP-запит mock-функцією.
import { useState } from 'react';
function validate(values) {
const errors = {};
if (!values.email.trim()) {
errors.email = 'Введіть електронну пошту';
} else if (!values.email.includes('@')) {
errors.email = 'Введіть коректну електронну пошту';
}
if (!values.password) {
errors.password = 'Введіть пароль';
} else if (values.password.length < 8) {
errors.password = 'Пароль має містити щонайменше 8 символів';
}
return errors;
}
export default function SignupForm({ onSubmit }) {
const [values, setValues] = useState({
email: '',
password: '',
});
const [errors, setErrors] = useState({});
const [status, setStatus] = useState('');
function handleChange(event) {
const { name, value } = event.target;
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
}
async function handleSubmit(event) {
event.preventDefault();
const validationErrors = validate(values);
setErrors(validationErrors);
setStatus('');
if (Object.keys(validationErrors).length > 0) {
return;
}
try {
await onSubmit(values);
setStatus('Реєстрацію успішно завершено');
} catch {
setErrors({
form: 'Не вдалося завершити реєстрацію. Спробуйте ще раз.',
});
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Електронна пошта</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
aria-invalid={Boolean(errors.password)}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
{errors.form && <p role="alert">{errors.form}</p>}
<button type="submit">Зареєструватися</button>
{status && <p role="status">{status}</p>}
</form>
);
}Атрибути aria-invalid і aria-describedby не є обов’язковими для роботи компонента, але роблять форму зрозумілішою для допоміжних технологій. Вони також дають змогу перевірити в тестах, що поле справді позначене як невалідне.
Для прикладу використаємо Vitest і Testing Library.
Типові залежності для тестування:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domУ конфігурації Vitest середовищем для тестів має бути jsdom, оскільки React-компоненти працюють із DOM.
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
},
});Тестовий файл можна назвати SignupForm.test.jsx.
Перший сценарій: користувач натискає кнопку відправлення, не заповнивши поля.
У цьому випадку:
функція onSubmit не повинна викликатися;
на екрані мають з’явитися повідомлення про помилки;
поля повинні мати aria-invalid="true".
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import SignupForm from './SignupForm';
describe('SignupForm', () => {
it('показує помилки для порожніх полів', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(screen.getByText('Введіть електронну пошту')).toBeInTheDocument();
expect(screen.getByText('Введіть пароль')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByLabelText('Електронна пошта')).toHaveAttribute(
'aria-invalid',
'true',
);
expect(screen.getByLabelText('Пароль')).toHaveAttribute(
'aria-invalid',
'true',
);
});
});userEvent.setup() створює користувача, дії якого можуть бути асинхронними. Тому після click використовується await.
Не варто перевіряти лише наявність внутрішнього стану errors. Користувач не бачить стан React безпосередньо — він бачить текст помилки та змінений стан поля.
Окремо перевіримо, що компонент відхиляє некоректну електронну пошту та короткий пароль.
it('показує помилки для некоректних значень', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'invalid-email',
);
await user.type(screen.getByLabelText('Пароль'), '123');
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(
screen.getByText('Введіть коректну електронну пошту'),
).toBeInTheDocument();
expect(
screen.getByText('Пароль має містити щонайменше 8 символів'),
).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});Такий тест перевіряє саме поведінку форми:
користувач вводить дані;
натискає кнопку;
бачить помилки;
зовнішня функція відправлення не запускається.
Для валідних даних onSubmit має отримати об’єкт із правильними значеннями.
Оскільки обробник форми є асинхронним, перевірку виконуємо після завершення дії користувача.
it('передає валідні дані та показує повідомлення про успіх', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'anna@example.com',
);
await user.type(
screen.getByLabelText('Пароль'),
'secure-password',
);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(onSubmit).toHaveBeenCalledWith({
email: 'anna@example.com',
password: 'secure-password',
});
expect(
await screen.findByRole('status', {
name: 'Реєстрацію успішно завершено',
}),
).toBeInTheDocument();
});vi.fn().mockResolvedValue(undefined) створює mock-функцію, яка повертає успішно виконану Promise. Це імітує успішну відповідь сервера.
Для асинхронного результату використовується findByRole. На відміну від getByRole, цей запит очікує появи елемента в DOM.
Валідація може пройти успішно, але операція на сервері може завершитися помилкою. Для цього mock-функція має повернути відхилену Promise.
it('показує помилку, якщо відправлення завершилося невдало', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockRejectedValue(new Error('Server error'));
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'anna@example.com',
);
await user.type(
screen.getByLabelText('Пароль'),
'secure-password',
);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(
await screen.findByText(
'Не вдалося завершити реєстрацію. Спробуйте ще раз.',
),
).toBeInTheDocument();
expect(
screen.queryByRole('status', {
name: 'Реєстрацію успішно завершено',
}),
).not.toBeInTheDocument();
});Цей тест розділяє два типи помилок:
помилки валідації даних до відправлення;
помилки зовнішньої операції після відправлення.
Нижче наведено всі основні сценарії в одному файлі.
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import SignupForm from './SignupForm';
describe('SignupForm', () => {
it('показує помилки для порожніх полів', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(screen.getByText('Введіть електронну пошту')).toBeInTheDocument();
expect(screen.getByText('Введіть пароль')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('відхиляє некоректні значення', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'invalid-email',
);
await user.type(screen.getByLabelText('Пароль'), '123');
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(
screen.getByText('Введіть коректну електронну пошту'),
).toBeInTheDocument();
expect(
screen.getByText('Пароль має містити щонайменше 8 символів'),
).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('відправляє валідні дані', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'anna@example.com',
);
await user.type(
screen.getByLabelText('Пароль'),
'secure-password',
);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(onSubmit).toHaveBeenCalledWith({
email: 'anna@example.com',
password: 'secure-password',
});
expect(
await screen.findByRole('status', {
name: 'Реєстрацію успішно завершено',
}),
).toBeInTheDocument();
});
it('показує помилку сервера', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockRejectedValue(new Error('Server error'));
render(<SignupForm onSubmit={onSubmit} />);
await user.type(
screen.getByLabelText('Електронна пошта'),
'anna@example.com',
);
await user.type(
screen.getByLabelText('Пароль'),
'secure-password',
);
await user.click(
screen.getByRole('button', { name: 'Зареєструватися' }),
);
expect(
await screen.findByText(
'Не вдалося завершити реєстрацію. Спробуйте ще раз.',
),
).toBeInTheDocument();
});
});Запустити такі тести можна командою:
npx vitest runДля форм найчастіше використовуються такі запити:
getByRole('button', { name: '...' }) — пошук кнопки за роллю та доступною назвою;
getByLabelText('...') — пошук поля за пов’язаним label;
getByText('...') — пошук видимого повідомлення;
findByRole(...) — пошук елемента, який з’явиться асинхронно;
queryByRole(...) — перевірка, що елемент відсутній, без помилки, якщо його не знайдено.
Перевагу варто надавати запитам у такому порядку:
роль і доступна назва;
мітка поля;
видимий текст;
атрибути або CSS-селектори — лише коли це справді потрібно.
Наприклад, цей варіант відображає реальну дію користувача:
await user.type(
screen.getByLabelText('Електронна пошта'),
'anna@example.com',
);А пошук через клас компонента був би крихкішим:
container.querySelector('.email-input');Якщо зміниться CSS-клас, другий тест зламається, хоча поведінка форми залишиться правильною.
fireEvent для всіх дійfireEvent може бути корисним для окремих низькорівневих сценаріїв, але для введення тексту краще використовувати userEvent. Він точніше імітує реальну послідовність дій користувача.
const user = userEvent.setup();
await user.type(input, 'text');
await user.click(button);onSubmitТест, який перевіряє тільки onSubmit, не гарантує, що користувач побачить правильний результат. Потрібно також перевіряти повідомлення про успіх або помилку.
Перевірка помилки має супроводжуватися перевіркою:
expect(onSubmit).not.toHaveBeenCalled();Інакше компонент може показати помилку, але все одно виконати відправлення.
Після user.type, user.click та асинхронного onSubmit потрібно використовувати await. Для елементів, які з’являються після Promise, застосовуйте findBy....
Якщо поле не має пов’язаного label, тест буде складніше читати, а форма стане менш доступною. Для кожного поля використовуйте label і htmlFor:
<label htmlFor="email">Електронна пошта</label>
<input id="email" name="email" />Не потрібно тестувати виклики setState, назви внутрішніх функцій або структуру локального стану. Тестуйте те, що відбувається після дій користувача:
з’явилася помилка;
кнопка викликала відправлення;
передалися правильні дані;
показався результат операції.
Тести форм повинні перевіряти поведінку користувача.
Для введення даних використовуйте userEvent.
Поля зручно знаходити через getByLabelText, а кнопки — через getByRole.
Невалідні дані не повинні передаватися у функцію відправлення.
За допомогою mock-функцій можна перевірити успішну та невдалу відправку.
Для асинхронних результатів використовуйте findBy... і await.
Повідомлення про помилки та успіх є частиною публічної поведінки форми, тому їх потрібно перевіряти в тестах.