Кнопка формы нажимается, но неверный адрес всё равно уходит в API. Напишем тест, который воспроизведёт сбой, затем добавим проверку поля и убедимся, что повторная отправка после ошибки сохраняет введённые данные.
Объект проверки — форма подписки на подборку статей. Vitest запустит тесты, Testing Library найдёт элементы по доступным именам, а user-event выполнит ввод и нажатия. Тесты работают в jsdom — среде с DOM внутри Node.js. Настоящий браузер здесь не запускается.
Нужны основы React, промисы JavaScript и Node.js 24 или новее. React Hook Form для упражнения не требуется: поведение проверяемой формы важно само по себе, независимо от способа управления полем.
Подготовьте проект с известным объектом проверки
В пустой папке установите зависимости. Команды ограничивают основные версии; точные номера сохранит package-lock.json. При передаче примера коллеге сохраните этот файл вместе с исходниками.
mkdir newsletter-tests
cd newsletter-tests
npm init -y
npm install react@19 react-dom@19
npm install --save-dev vite@7 vitest@3 jsdom@26 \
@testing-library/react@16 @testing-library/user-event@14 \
@testing-library/jest-dom@6
Создайте vitest.config.mjs. environment выбирает DOM-среду. setupFiles подключит дополнительные проверки вроде toHaveFocus перед выполнением файла тестов.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./test-setup.js']
}
});
В test-setup.js явно очищаем DOM после каждого теста. Следующая проверка начинает работу без элементов предыдущей формы.
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
Добавьте полный компонент в NewsletterForm.jsx. Функция saveEmail приходит через свойства. В приложении она может делать HTTP-запрос; в тесте мы передадим управляемую замену. Тест работает независимо от сервера и скорости сети.
import React, { useRef, useState } from 'react';
export function NewsletterForm({ saveEmail }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [serverError, setServerError] = useState('');
const [pending, setPending] = useState(false);
const [saved, setSaved] = useState(false);
const inputRef = useRef(null);
const busyRef = useRef(false);
async function submit(event) {
event.preventDefault();
if (busyRef.current) return;
setError('');
setServerError('');
setSaved(false);
const value = email.trim();
// Здесь позже появится клиентская проверка.
busyRef.current = true;
setPending(true);
try {
await saveEmail(value);
setSaved(true);
} catch {
setServerError('Не удалось подписать. Повторите попытку');
} finally {
busyRef.current = false;
setPending(false);
}
}
return (
<form noValidate onSubmit={submit} aria-label="Подписка на статьи">
<label htmlFor="newsletter-email">Почта</label>
<input id="newsletter-email" ref={inputRef}
type="email" autoComplete="email" required readOnly={pending}
value={email} onChange={event => setEmail(event.target.value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'email-help email-error' : 'email-help'} />
<p id="email-help">Обязательное поле. Например, reader@example.test</p>
<p id="email-error">{error}</p>
<div role="alert">{serverError}</div>
<p role="status">{saved ? 'Вы подписаны' : pending ? 'Сохраняем…' : ''}</p>
<button type="submit" disabled={pending}>
{pending ? 'Сохраняем…' : 'Подписаться'}
</button>
</form>
);
}
Форма уже умеет ждать saveEmail, показывать сбой и возвращать кнопку после завершения. При ошибке почта остаётся в состоянии email. Точка для проверки обозначена комментарием: это намеренно неполная версия, на которой сначала должен упасть тест.
Атрибут noValidate отключает встроенную блокировку отправки. Обязательность сохраняется через required и текст подсказки. Проверять ограничения будем вызовом checkValidity(). Сервер тоже обязан проверять адрес: компонентные тесты ничего не говорят о запросах в обход интерфейса.
Убедитесь, что тест замечает сломанную форму
Создайте NewsletterForm.test.jsx. Требование выглядит как действие пользователя: ввести неверную почту, отправить, получить объяснение у поля. Одновременно функция сохранения не должна вызываться.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NewsletterForm } from './NewsletterForm.jsx';
describe('форма подписки', () => {
it('останавливает неверную почту и возвращает фокус', async () => {
const user = userEvent.setup();
const saveEmail = vi.fn().mockResolvedValue(undefined);
render(<NewsletterForm saveEmail={saveEmail} />);
const email = screen.getByRole('textbox', { name: 'Почта' });
await user.type(email, 'wrong');
await user.click(screen.getByRole('button', { name: 'Подписаться' }));
expect(email).toHaveAttribute('aria-invalid', 'true');
expect(email).toHaveAccessibleDescription(
'Обязательное поле. Например, reader@example.test Введите корректную почту'
);
expect(email).toHaveFocus();
expect(saveEmail).not.toHaveBeenCalled();
});
});
Запустите:
npx vitest run
Ожидается один упавший тест. Первая проверка получит aria-invalid="false" вместо true: компонент отправил wrong в saveEmail. Это полезный провал. Тест отличает требуемое поведение от текущего.
Если проверка неожиданно прошла, убедитесь, что запускаете файл с исходной версией компонента. Зелёная строка может означать, что проверялось другое условие. Пока тест ни разу не упал на соответствующей ошибке, его способность замечать эту ошибку остаётся предположением.
Добавьте проверку и повторите запуск
В NewsletterForm.jsx замените комментарий перед busyRef.current = true этим блоком:
if (!inputRef.current.checkValidity()) {
setError('Введите корректную почту');
inputRef.current.focus();
return;
}
Метод checkValidity() использует type="email" и required: для пустого поля он вернёт false, для reader@example.test — true. Сам ящик мы не проверяем.
Повторите npx vitest run. Теперь проверка должна пройти. Ошибка устанавливается до вызова saveEmail, ранний return завершает обработчик. Фокус переходит к полю со связанным пояснением.
Запрос getByRole('textbox', { name: 'Почта' }) опирается на доступное имя от label. Временно удалите htmlFor у label и запустите тест: поле перестанет находиться по подписи. Затем восстановите htmlFor="newsletter-email" и повторите запуск. Перед следующим разделом тест должен снова проходить. Так тест замечает потерю связи подписи с полем даже при внешне правильной странице.
toHaveAccessibleDescription проверяет результат связи aria-describedby. Сообщение где-то на странице ещё не доказывает, что поле с ним связано. Поэтому в этом тесте проверяем описание самого поля. Реальное озвучивание и видимость рамки фокуса проверяют отдельно в браузере и с экранным диктором.
Проверьте успех, ожидание и повтор после сбоя
В тот же NewsletterForm.test.jsx, после первого describe, добавьте блок ниже. Импорты уже находятся в начале файла. Фрагмент не требует нового тестового файла.
describe('отправка подписки', () => {
it('передаёт почту и показывает подтверждение', async () => {
const user = userEvent.setup();
const saveEmail = vi.fn().mockResolvedValue(undefined);
render(<NewsletterForm saveEmail={saveEmail} />);
await user.type(screen.getByLabelText('Почта'), 'reader@example.test');
await user.click(screen.getByRole('button', { name: 'Подписаться' }));
expect(await screen.findByText('Вы подписаны')).toBeVisible();
expect(saveEmail).toHaveBeenCalledExactlyOnceWith('reader@example.test');
});
it('сохраняет почту и разрешает повторить после ошибки', async () => {
const user = userEvent.setup();
const saveEmail = vi.fn()
.mockRejectedValueOnce(new Error('Учебный сбой'))
.mockResolvedValueOnce(undefined);
render(<NewsletterForm saveEmail={saveEmail} />);
const email = screen.getByLabelText('Почта');
await user.type(email, 'reader@example.test');
await user.click(screen.getByRole('button', { name: 'Подписаться' }));
expect(await screen.findByText('Не удалось подписать. Повторите попытку'))
.toBeVisible();
expect(email).toHaveValue('reader@example.test');
const retry = screen.getByRole('button', { name: 'Подписаться' });
expect(retry).toBeEnabled();
await user.click(retry);
expect(await screen.findByText('Вы подписаны')).toBeVisible();
expect(screen.getByRole('alert')).toBeEmptyDOMElement();
expect(saveEmail).toHaveBeenCalledTimes(2);
});
it('держит кнопку выключенной до завершения операции', async () => {
const user = userEvent.setup();
let finish;
const saveEmail = vi.fn(() => new Promise(resolve => { finish = resolve; }));
render(<NewsletterForm saveEmail={saveEmail} />);
await user.type(screen.getByLabelText('Почта'), 'reader@example.test');
await user.click(screen.getByRole('button', { name: 'Подписаться' }));
expect(screen.getByRole('button', { name: 'Сохраняем…' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Сохраняем…');
const email = screen.getByLabelText('Почта');
expect(email).toHaveAttribute('readonly');
await user.type(email, 'other@example.test');
expect(email).toHaveValue('reader@example.test');
expect(saveEmail).toHaveBeenCalledExactlyOnceWith('reader@example.test');
finish();
expect(await screen.findByText('Вы подписаны')).toBeVisible();
expect(screen.getByRole('button', { name: 'Подписаться' })).toBeEnabled();
expect(email).not.toHaveAttribute('readonly');
});
});
await user.type и await user.click ждут завершения действий пользователя. Результат сохранения ищет findByText. Он ждёт появления элемента. getByText проверил бы DOM сразу и мог опередить обновление компонента.
В последнем сценарии промис остаётся незавершённым, пока тест не вызовет finish(). Такая задержка управляется тестом. Ей не требуется предположение, что за 100 миллисекунд запрос наверняка ещё не завершится.
После finish() ждём видимое подтверждение. Если проверять кнопку сразу после разрешения промиса, React может ещё не успеть обновить DOM. Сначала наблюдаем завершение операции, затем связанную с ним кнопку.
Поле получает readOnly={pending} на время сохранения: подтверждение относится к адресу, который всё ещё виден на странице. Тест пробует допечатать другой адрес до finish(), проверяет сохранность отправленного значения, затем убеждается, что поле снова редактируемо. В самостоятельной задаче менять адрес после отказа нужно уже после завершения промиса.
busyRef защищает обработчик от повторного входа до нового рендера React. Видимую кнопку отключает pending. На сервере потребуется собственная защита от повторного создания записи: компонент не контролирует запросы из другого окна и сетевые повторы.
Читайте сбой по месту расхождения
Теперь команда должна показать четыре успешных теста. Начинайте диагностику с первого несоответствия в упавшем сценарии. Менять ожидание под фактический результат следует только при изменении требования.
| Симптом | Возможная причина | Проверка |
|---|---|---|
document is not defined |
DOM-среда не выбрана | Посмотрите environment: 'jsdom' |
toHaveFocus is not a function |
Подготовка не подключена | Проверьте setupFiles |
| Поле не найдено по имени | Нарушена связь подписи | Сравните htmlFor и id |
| Тест ждёт результат | Промис остался незавершённым | Найдите вызов finish() |
В сохранение уходит wrong |
Проверка стоит после запроса | Перенесите её до saveEmail |
| Успех появился до ответа | Пропущен await |
Успех устанавливается после сохранения |
Один тест описывает один сценарий. В сценарии повтора закономерно несколько утверждений: сохранность значения, доступная кнопка, результат второй попытки. Так последовательность сохраняет смысл действия пользователя.
Функция сохранения заменена тестовой. Зелёный отчёт доказывает работу компонента с успехом и отказом именно этой функции. Он не сообщает о доступности API, CORS, вёрстке при увеличении и реальном озвучивании живой области. Для целого пути «страница → сеть → ответ» нужен браузерный сценарий.
Убедитесь, что тест ловит зависшую кнопку
Временно удалите finally с возвратом pending в false. Запустите набор. Сценарий повтора должен упасть: после ошибки кнопка останется выключенной. Верните блок и повторите запуск. Теперь у теста есть проверенная регрессия, от которой он защищает.
Самостоятельно добавьте сценарий пустого поля: нажмите «Подписаться», проверьте фокус и отсутствие вызова saveEmail. Во второй задаче дождитесь сбоя, замените адрес на other@example.test и повторите. Проверьте второй вызов в saveEmail.mock.calls: он должен содержать новый адрес, который человек ввёл уже после серверного отказа; затем дождитесь подтверждения успеха.
Основы обработки значений есть в материале про управляемые и неуправляемые поля React. Практику разработки компонентов можно продолжить на курсе по React.