Форма выглядит исправной, пока сервер не возвращает ошибку. Проверим в настоящем браузере путь от ввода адреса до ответа 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 даже без открытой страницы.

Границы проверки внешнего вида поможет разобрать материал о тестировании вёрстки. Развивать практику проверки приложения целиком можно в программе «Фронтенд-инженер».