1. Введение — боль без мока

Представь типичную ситуацию: фронтенд готов, дизайн согласован, задачи в Jira закрыты — но бэкенд ещё пишется. Или бэкенд есть, но тестовый стенд падает раз в день, а нужный сценарий — «сервер вернул 500» — в нём вообще не воспроизвести без танцев с бубном.

Playwright в таких условиях выглядит примерно так:

// Тест написан. Но где взять живой /auth/login?
await page.goto('/login');
await page.fill('[name=email]', 'user@example.com');
await page.fill('[name=password]', 'correct123');
await page.click('button[type=submit]');
await expect(page.locator('.dashboard')).toBeVisible();

Тест нестабилен не потому что плохо написан, а потому что зависит от внешнего состояния: поднят ли сервер, правильные ли данные в базе, не упал ли контейнер.

Обычно в таких случаях советуют page.route() — встроенный механизм перехвата запросов в

Обычно в таких случаях советуют page.route() — встроенный механизм перехвата запросов в Playwright:

await page.route('/api/auth/login', route => {
  route.fulfill({
    status: 200,
    body: JSON.stringify({ token: 'fake-jwt-token' }),
  });
});

Это работает. Но у такого подхода есть цена:

  • Моки живут внутри тестов. Хочешь проверить 5 сценариев — пишешь 5 раз route.fulfill() с разными телами. Меняется контракт API — идёшь и правишь каждый тест вручную.

  • Нет истории запросов. Тест упал — вы не знаете, какой именно запрос ушёл, с каким телом, в каком порядке. Только стектрейс Playwright.

  • Сложно шарить между командами. QA хочет вручную потыкать форму логина против тех же сценариев — с route.fulfill() это не выйдет, это только для Playwright.

Альтернатива — вынести моки на уровень выше: в отдельный мок-сервер, который живёт независимо от тестов, доступен по HTTP и управляется через UI. Таких сервисов немало: Mockoon, WireMock, msw, FakeAPI и другие. В этой статье я буду использовать FakeAPI как пример — но подход универсальный и без труда переносится на любой аналог.

Поведение описывается один раз — в интерфейсе сервиса. Тесты просто делают обычные HTTP-запросы. История запросов сохраняется и фильтруется. QA может открыть тот же мок в браузере и проверить вручную.

Этот подход особенно актуален для фронтендеров: если бэкенд ещё не готов, нестабилен или вы хотите тестировать UI независимо от состояния сервера — мок-сервер решает эту задачу чисто и без лишних зависимостей.

Дальше посмотрим как это выглядит на практике — на примере формы логина с пятью тестовыми сценариями.

2. Какой мок-сервер нам нужен

Мок-серверов существует много. Прежде чем выбирать конкретный инструмент, стоит понять что именно нужно для нашей задачи.

Матчинг по условиям.

Нам нужно одним эндпоинтом покрыть несколько сценариев: успех, ошибка, rate limit. Значит сервис должен уметь возвращать разные ответы на один и тот же запрос — в зависимости от заголовков, тела или параметров.

Симуляция задержки.

Тест на долгий ответ невозможен без настраиваемого latency на стороне мока. Это должна быть встроенная фича, а не хак через setTimeout в тесте.

Произвольные HTTP-статусы.

500, 429, 401 — сервис должен уметь вернуть любой статус, не только 200.

История запросов.

Когда тест падает, хочется видеть что именно ушло на сервер — заголовки, тело, порядок запросов. Логи из коробки сильно ускоряют отладку.

Доступность по HTTP без поднятия инфраструктуры.

Идеально — облачный сервис с готовым URL. Локальные решения вроде Mockoon тоже работают, но требуют дополнительной настройки в CI.

В статье я буду использовать FakeAPI — он закрывает все перечисленные пункты и бесплатен. Подход одинаково работает с любым аналогом.

3. Постановка задачи

Мы тестируем форму логина — React-приложение на Vite. Форма отправляет POST /api/auth/login с телом:

{
  "email": "user@example.com",
  "password": "correct123"
}

Нужно покрыть пять сценариев:

#

Сценарий

Условие матчинга

Ответ

1

Успешный вход

body содержит "correct123"

200 + { token }

2

Неверный пароль

body не содержит "correct123"

401 + { error }

3

Долгий ответ

X-Test-Scenario: slow

200 + задержка 3s

4

Сервер упал

X-Test-Scenario: server-error

500

5

Rate limit

X-Test-Scenario: too-many-requests

429

Стратегия матчинга:

  • Кейсы 1 и 2 — матчинг по body целиком. FakeAPI проверяет всё тело запроса: если оно содержит строку "correct123" — возвращает 200, иначе — 401.

  • Кейсы 3, 4, 5 — матчинг по заголовку. Playwright перед отправкой запроса добавляет заголовок X-Test-Scenario с нужным значением. FakeAPI видит заголовок и возвращает соответствующий ответ.

4. Настройка FakeAPI

Создаём эндпоинт POST /auth/login и добавляем к нему 5 ответов с условиями матчинга.

Ответ 1 — успешный вход. Условие: body содержит "correct123". Статус 200, тело:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
  "user": {
    "id": 1,
    "email": "user@example.com"
  }
}

Ответ 2 — неверный пароль. Условие: body не содержит "correct123". Статус 401, тело:

{
  "error": "Invalid credentials",
  "message": "Неверный логин или пароль"
}

Ответ 3 — долгий ответ. Условие: заголовок X-Test-Scenario: slow. Задержка 3000 ms, статус 200, тело как у ответа 1.

Ответ 4 — сервер упал. Условие: заголовок X-Test-Scenario: server-error. Статус 500, тело:

{
  "error": "Internal Server Error"
}

Ответ 5 — rate limit. Условие: заголовок X-Test-Scenario: too-many-requests. Статус 429, тело:

{
  "error": "Too Many Requests",
  "retryAfter": 60
}

Ещё одна полезная возможность при настройке эндпоинта — указать JSON Schema для каждого кода ответа. Она становится контрактом: сервис будет валидировать тело каждого добавляемого ответа на соответствие схеме. Это удобно когда над проектом работает несколько человек — случайно положить невалидный JSON в ответ не получится.

5. Пример приложения — форма логина

С созданием формы вы, думаю, справитесь сами. Главное — не забудьте добавить data-testid атрибуты на все интерактивные элементы: поля ввода, кнопку и блок с сообщением. Именно по ним Playwright будет находить элементы — это надёжнее чем искать по тексту или CSS-классам, которые могут меняться.

6. Пишем тесты на Playwright

Устанавливаем Playwright, если ещё не стоит:

npm init playwright@latest

Настраиваем playwright.config.ts:

import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  testDir: './tests',
  use: {
    // Адрес нашего приложения
    baseURL: 'http://localhost:5173',
  },
  // Запускаем dev-сервер перед тестами
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

Теперь сам файл тестов tests/login.spec.ts:

import { test, expect, Page } from '@playwright/test';
 
// Хелпер: перехватываем запрос к /auth/login
// и добавляем нужный заголовок перед отправкой в FakeAPI
async function setTestScenario(page: Page, scenario: string) {
  await page.route('**/auth/login', async route => {
    await route.continue({
      headers: {
        ...route.request().headers(),
        'X-Test-Scenario': scenario,
      },
    });
  });
}
 
// Хелпер: открываем страницу логина
async function openLoginPage(page: Page) {
  await page.goto('/');
  await expect(page.getByTestId('login-form')).toBeVisible();
}
 
// Хелпер: заполняем форму и жмём войти
async function submitForm(page: Page, email: string, password: string) {
  await page.getByTestId('email-input').fill(email);
  await page.getByTestId('password-input').fill(password);
  await page.getByTestId('submit-btn').click();
}

Кейс 1 — успешная аутентификация

test('успешный вход с верными данными', async ({ page }) => {
  await openLoginPage(page);
  await submitForm(page, 'user@example.com', 'correct123');
 
  // Матчинг сработает по body — пароль совпадает
  await expect(page.getByTestId('result-message')).toHaveText('Добро пожаловать!');
});

Кейс 2 — неверный пароль

test('ошибка при неверном пароле', async ({ page }) => {
  await openLoginPage(page);
 
  // Вводим заведомо неверный пароль
  await submitForm(page, 'user@example.com', 'wrongpassword');
 
  await expect(page.getByTestId('result-message')).toHaveText('Неверный логин или пароль');
});

Кейс 3 — долгий ответ (latency)

test('кнопка показывает загрузку при долгом ответе', async ({ page }) => {
  await openLoginPage(page);
 
  // Включаем сценарий slow — FakeAPI добавит задержку 3 секунды
  await setTestScenario(page, 'slow');
 
  await submitForm(page, 'user@example.com', 'correct123');
 
  // Сразу после клика кнопка должна показать состояние загрузки
  await expect(page.getByTestId('submit-btn')).toHaveText('Входим...');
 
  // После ответа — сообщение об успехе
  await expect(page.getByTestId('result-message')).toHaveText('Добро пожаловать!', {
    timeout: 10_000, // ждём до 10 секунд
  });
});

Кейс 4 — сервер вернул 500

test('показываем ошибку сервера при 500', async ({ page }) => {
  await openLoginPage(page);
 
  await setTestScenario(page, 'server-error');
 
  await submitForm(page, 'user@example.com', 'correct123');
 
  await expect(page.getByTestId('result-message')).toHaveText('Ошибка сервера. Попробуйте позже.');
});

Кейс 5 — Too Many Requests (429)

test('показываем предупреждение при 429', async ({ page }) => {
  await openLoginPage(page);
 
  await setTestScenario(page, 'too-many-requests');
 
  await submitForm(page, 'user@example.com', 'correct123');
 
  await expect(page.getByTestId('result-message'))
    .toHaveText('Слишком много попыток. Попробуйте позже.');
});

7. Смотрим логи

После прогона тестов открываем раздел логов в FakeAPI — и видим историю всех запросов: что пришло, что совпало, что вернулось.

Каждый лог-запись содержит полный snapshot:

  • Метод и путь запроса

  • Все заголовки (включая X-Test-Scenario)

  • Тело запроса

  • Какое условие матчинга сработало

  • Статус и тело ответа

  • Время ответа (включая задержку)

8. Итог — когда это полезно

Мы построили связку, в которой:

  1. FakeAPI хранит всю логику мок-поведения — один раз настроил, используй из любого инструмента

  2. Playwright управляет сценарием через заголовки — чисто и без дублирования

  3. Логи FakeAPI дают прозрачность — видно что именно ушло на сервер

Когда такой подход оправдан:

  • Бэкенд ещё не готов. Фронтенд-разработка и тесты не ждут.

  • Нужно тестировать граничные случаи. 500, 429, latency — в реальном API их сложно воспроизвести стабильно.

  • Несколько команд работают с одним API. Мок-сервер шарится между разработчиками, QA и автотестами.

  • Хочется видеть историю запросов. Логи FakeAPI дают это из коробки.

Когда page.route() всё ещё лучше:

  • Тест изолированный и моки нигде больше не нужны

  • Нет желания поднимать внешний сервис

  • Логика мока меняется от теста к тесту и не имеет смысла выносить её наружу

Оба подхода не исключают друг друга — в реальных проектах их комбинируют. FakeAPI для стабильных сценариев и интеграций, route.fulfill() для быстрых изолированных проверок.