Playwright + MockAPI: тестируем форму логина без живого бэкенда
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. Итог — когда это полезно
Мы построили связку, в которой:
FakeAPI хранит всю логику мок-поведения — один раз настроил, используй из любого инструмента
Playwright управляет сценарием через заголовки — чисто и без дублирования
Логи FakeAPI дают прозрачность — видно что именно ушло на сервер
Когда такой подход оправдан:
Бэкенд ещё не готов. Фронтенд-разработка и тесты не ждут.
Нужно тестировать граничные случаи. 500, 429, latency — в реальном API их сложно воспроизвести стабильно.
Несколько команд работают с одним API. Мок-сервер шарится между разработчиками, QA и автотестами.
Хочется видеть историю запросов. Логи FakeAPI дают это из коробки.
Когда page.route() всё ещё лучше:
Тест изолированный и моки нигде больше не нужны
Нет желания поднимать внешний сервис
Логика мока меняется от теста к тесту и не имеет смысла выносить её наружу
Оба подхода не исключают друг друга — в реальных проектах их комбинируют. FakeAPI для стабильных сценариев и интеграций, route.fulfill() для быстрых изолированных проверок.