Форма выглядит исправной, пока сервер не возвращает ошибку. Проверим в настоящем браузере путь от ввода адреса до ответа API, затем повторим отправку после управляемого сбоя. Все файлы и сервер будут локальными.
Понадобятся основы HTML и JavaScript, терминал и Node.js 24 или новее. Пример использует Playwright Test — средство запуска браузерных тестов. React не требуется: проверять будем законченную страницу подписки с собственным HTTP-сервером.
Поднимите страницу и API в одном процессе
Создайте пустую папку. Браузер установите отдельной командой: пакет тестов и исполняемый файл браузера — разные части окружения.
mkdir first-browser-test
cd first-browser-test
npm init -y
npm install --save-dev @playwright/test@1
npx playwright install chromium
Сохраните package-lock.json. Он фиксирует точную версию Playwright; браузер устанавливайте командой из этого проекта, чтобы его сборка соответствовала пакету.
Добавьте index.html. На странице есть обязательная почта, подсказка и два заранее созданных контейнера сообщений. Ошибка поля связана с полем, общий сбой сообщает живая область role="alert", успех — role="status".
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Подписка на статьи</title>
<style>
body { font: 1.1rem/1.5 system-ui; margin: 2rem; }
main { max-width: 36rem; }
label, input, button { display: block; }
input, button { font: inherit; padding: .5rem; }
:focus-visible { outline: 3px solid #164bc0; outline-offset: 3px; }
input[aria-invalid="true"] { border: 2px solid #a00020; }
</style>
</head>
<body>
<main>
<h1>Подписка на статьи</h1>
<form novalidate>
<label for="email">Почта</label>
<input id="email" name="email" type="email" required
autocomplete="email" aria-describedby="email-hint">
<p id="email-hint">Обязательное поле. Например, reader@example.test</p>
<p id="email-error"></p>
<div role="alert" id="problem"></div>
<p role="status" id="status"></p>
<button>Подписаться</button>
</form>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
В app.js добавьте обработчик. Адрес страницы с ?mode=retry включает учебный режим: первый запрос попросит сервер вернуть 503, повторный пойдёт обычным путём. Такая договорённость нужна только стенду; рабочее API не должно принимать от пользователя команды для имитации сбоев.
const form = document.querySelector('form');
const email = document.querySelector('#email');
const error = document.querySelector('#email-error');
const problem = document.querySelector('#problem');
const status = document.querySelector('#status');
const button = document.querySelector('button');
let failNext = new URL(location.href).searchParams.get('mode') === 'retry';
let pending = false;
form.addEventListener('submit', async event => {
event.preventDefault();
if (pending) return;
error.textContent = '';
problem.textContent = '';
status.textContent = '';
email.removeAttribute('aria-invalid');
email.setAttribute('aria-describedby', 'email-hint');
if (!email.checkValidity()) {
error.textContent = 'Введите корректную почту';
email.setAttribute('aria-invalid', 'true');
email.setAttribute('aria-describedby', 'email-hint email-error');
email.focus();
return;
}
pending = true;
button.disabled = true;
status.textContent = 'Сохраняем…';
try {
const simulateFailure = failNext;
failNext = false;
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim(), simulateFailure })
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
status.textContent = data.message;
} catch {
status.textContent = '';
problem.textContent = 'Не удалось подписать. Повторите попытку';
} finally {
pending = false;
button.disabled = false;
}
});
Значение почты после запроса остаётся в input. Если сервер недоступен, человек может повторить действие, сохранив адрес. В finally кнопка снова становится доступной и после успеха, и после ошибки.
Теперь создайте server.mjs. Он отдаёт только два явно разрешённых файла. Произвольный путь из URL не превращается в имя файла на диске. API проверяет вход независимо от клиентской формы.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8']
};
const json = (res, status, data) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
createServer(async (req, res) => {
const path = new URL(req.url, 'http://127.0.0.1:4173').pathname;
if (req.method === 'GET' && files[path]) {
const [file, type] = files[path];
res.writeHead(200, { 'Content-Type': type });
return res.end(await readFile(new URL(file, import.meta.url)));
}
if (req.method !== 'POST' || path !== '/api/subscribe') {
return json(res, 404, { message: 'Маршрут не найден' });
}
if (!req.headers['content-type']?.startsWith('application/json')) {
return json(res, 415, { message: 'Ожидается JSON' });
}
try {
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > 4096) return json(res, 413, { message: 'Запрос слишком большой' });
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString('utf8');
let data;
try { data = JSON.parse(raw); }
catch { return json(res, 400, { message: 'Повреждён JSON' }); }
if (typeof data?.email !== 'string' || data.email.length > 200 ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
return json(res, 422, { message: 'Проверьте почту' });
}
if (data.simulateFailure === true) {
return json(res, 503, { message: 'Учебный временный сбой' });
}
json(res, 201, { message: 'Вы подписаны' });
} catch {
if (!res.writableEnded) json(res, 500, { message: 'Сервер не обработал запрос' });
}
}).listen(4173, '127.0.0.1', () => {
console.log('Стенд: http://127.0.0.1:4173');
});
Сервер складывает исходные байтовые фрагменты в chunks и один раз декодирует их после Buffer.concat(chunks). Так кириллическая буква не повредится, если граница запроса пройдёт между её байтами. Лимит проверяется по исходному числу байтов.
Сервер учебный. Он не хранит адреса и не отправляет письма; учётных записей здесь тоже нет. Регулярное выражение ограничено форматом примера, поэтому в сценариях используем согласованный адрес reader@example.test. Встроенная проверка браузера type="email" и серверное правило могут по-разному оценивать другие адреса; сервер в любом случае проверяет вход самостоятельно.
Передайте запуск сервера Playwright
Создайте playwright.config.mjs. Команда webServer поднимет локальный сервер перед набором и завершит созданный ею процесс после тестов. Отдельный терминал для API при тестовом запуске не требуется.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'retain-on-failure'
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'node server.mjs',
url: 'http://127.0.0.1:4173',
reuseExistingServer: false,
timeout: 10000
}
});
Настройка reporter выводит результаты в терминал и создаёт HTML-отчёт. open: 'never' отключает его автоматическое открытие: ниже мы откроем отчёт отдельной командой.
reuseExistingServer: false помогает обнаружить занятый порт. Иначе можно незаметно проверить страницу от старого процесса. Если порт 4173 занят, остановите именно свой предыдущий учебный процесс или согласованно поменяйте адрес в сервере и конфигурации.
Каждый тест получает отдельный браузерный контекст со своим состоянием страницы. Серверные переменные, база данных и внешние сервисы от этого отдельными не становятся. Наш стенд не хранит общих заявок: режим сбоя принадлежит открытой странице, поэтому параллельные сценарии не меняют результат друг друга.
Напишите проверки наблюдаемого результата
Создайте папку tests и файл tests/subscribe.spec.mjs. Первый сценарий проверяет, что клиент остановит пустую заявку ещё до запроса. Успешный путь доходит до ответа сервера. Отдельная проверка пройдёт через 503 и повтор с сохранённым адресом.
import { test, expect } from '@playwright/test';
test('пустая почта: ошибка связана с полем', async ({ page }) => {
let calls = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/api/subscribe') calls++;
});
await page.goto('/');
await page.getByRole('button', { name: 'Подписаться' }).click();
const email = page.getByRole('textbox', { name: 'Почта' });
await expect(email).toBeFocused();
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toHaveAccessibleDescription(
'Обязательное поле. Например, reader@example.test Введите корректную почту'
);
expect(calls).toBe(0);
});
test('корректная почта: сервер подтвердил подписку', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Почта').fill('reader@example.test');
const responsePromise = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subscribe' &&
response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Подписаться' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
await expect(page.getByRole('status')).toHaveText('Вы подписаны');
});
test('после сбоя можно повторить без повторного ввода', async ({ page }) => {
await page.goto('/?mode=retry');
const email = page.getByLabel('Почта');
await email.fill('reader@example.test');
const failed = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/subscribe'
);
await page.getByRole('button', { name: 'Подписаться' }).click();
expect((await failed).status()).toBe(503);
await expect(page.getByRole('alert')).toHaveText(
'Не удалось подписать. Повторите попытку'
);
await expect(email).toHaveValue('reader@example.test');
await expect(page.getByRole('button', { name: 'Подписаться' })).toBeEnabled();
await page.getByRole('button', { name: 'Подписаться' }).click();
await expect(page.getByRole('status')).toHaveText('Вы подписаны');
await expect(page.getByRole('alert')).toBeEmpty();
});
Запустите тесты:
npx playwright test
Ожидаемый результат — три пройденных сценария проекта chromium. Проверки expect(locator) ждут нужного состояния в пределах своего тайм-аута. Фиксированная пауза waitForTimeout(1000) здесь не нужна: она замедлила бы быстрый ответ и всё равно не гарантировала бы завершение медленного.
Ожидание ответа создаётся до нажатия. Если подписаться на него после click, быстрый локальный сервер может ответить раньше, чем тест начнёт ждать. Нажатие и ответ относятся к разным событиям, их порядок нужно задать явно.
getByRole и getByLabel опираются на смысл интерфейса. Изменение CSS-класса не сломает эти обращения. При этом смена подписи «Почта» потребует осознанного обновления теста: доступное имя входит в проверяемый договор с пользователем.
Проверьте, что тест действительно замечает поломку
В app.js временно уберите button.disabled = false из finally. Снова выполните npx playwright test. Третий сценарий должен упасть на проверке доступности кнопки после ошибки. Пока не возвращайте строку и не запускайте набор повторно: сначала изучим это падение.
Такая отрицательная проверка полезнее добавления ещё одного утверждения о текущем тексте кнопки. Так воспроизводится заблокированная подписка.
Для просмотра падения откройте отчёт:
npx playwright show-report
Откройте неуспешный сценарий и его трассировку. Настройка retain-on-failure сохраняет трассу для неуспешного теста текущего запуска; следующий запуск очищает test-results. HTML-отчёт тоже будет обновлён. Поэтому изучите нужные артефакты сейчас, а если они понадобятся позже — сохраните их копию вне этих папок. Для трассировки используем вымышленные адреса.
После просмотра остановите сервер отчёта через Ctrl+C в терминале. Верните button.disabled = false в finally и снова выполните npx playwright test: теперь ожидаются три успешных сценария.
| Сбой запуска | Что проверить первым |
|---|---|
| Исполняемый файл браузера не найден | Выполнена ли npx playwright install chromium в этом проекте |
webServer не запустился |
Ошибки node server.mjs, занятость порта 4173 |
| Поле не найдено по подписи | Связь label for и input id |
| Не дождались ответа API | Ожидание создано до нажатия, путь совпадает |
| Тест успеха проходит при сломанном API | В сценарии проверяют код ответа и видимый результат |
Браузерный тест проверяет конкретный маршрут в выбранном Chromium. Он не доказывает поддержку всех браузеров, отсутствие визуальных ошибок и полную доступность. Состояние фокуса и связь ошибки мы проверили программно; озвучивание и удобство при увеличении требуют отдельного прохода.
Расширьте сценарий без случайных задержек
Сначала выполните управляемое изменение: замените подтверждение на «Подписка сохранена» и в сервере, и в ожидании теста. Проверьте, что изменение только в одном месте приводит к осмысленному падению.
Самостоятельно добавьте проверку клавиатурой: на новой странице нажмите Tab до поля, введите неверный адрес, перейдите к кнопке и нажмите Enter. Ожидайте возврат фокуса и отсутствие запроса. Во второй задаче проверьте прямой POST с неверной почтой через request из Playwright: сервер должен ответить 422 даже без открытой страницы.
Границы проверки внешнего вида поможет разобрать материал о тестировании вёрстки. Развивать практику проверки приложения целиком можно в программе «Фронтенд-инженер».