Замокировать сетевой запрос в Playwright занимает три строки, но сложнее другой вопрос: какие запросы мокировать. Замоканный платёжный эндпоинт заставит тест платёжного флоу проходить, скрывая при этом реальный режим отказа который видят пользователи. page.route() с route.fulfill() управляет полным ответом; route.fallback() позволяет замокировать один конкретный эндпоинт точечно, пропуская всё остальное на реальный сервер. Эта статья разбирает полный API, паттерны для тестирования состояний ошибок которые проверимы только через моки, и конкретные категории тестов где моки создают ложную уверенность вместо реального покрытия.
Зачем мокировать сетевые запросы
Аргумент в пользу моков не в том чтобы избегать реального тестирования. В том чтобы тестировать правильные вещи на правильном уровне.
Когда UI-тест обращается к реальному API, ты зависишь от сетевых задержек, состояния сервера и доступности сторонних сервисов. Тест который проверяет как таблица рендерится когда /api/items возвращает пустой массив проверяет фронтенд. Конкретное состояние базы данных ему не нужно. Моки позволяют полностью отделить эту зависимость.
Три основных причины мокировать запросы.
Скорость: очевидная причина. Тест который перехватывает сетевые вызовы и возвращает готовый ответ работает за миллисекунды вместо ожидания реального round-trip.
Надёжность: важнее скорости. Тесты которые обращаются к реальным бэкендам падают по причинам не связанным с тем что ты тестируешь: staging-окружение недоступно, прошла миграция, кто-то удалил тестовые данные. Мокированные ответы детерминированы по определению.
Состояния ошибок: самая недооценённая причина. Стабильно вызвать 503 или сетевой таймаут против реального сервера в тест-сьюте невозможно. С page.route() эти условия воспроизводятся по запросу.
page.route(): паттерн перехвата
page.route() принимает URL-паттерн (строку, glob или regex) и функцию-обработчик. Каждый совпадающий запрос проходит через обработчик до того как попасть в сеть.
import { test, expect } from '@playwright/test';
test('intercepts a network request', async ({ page }) => {
await page.route('https://lab.becomeqa.com/api/items', route => {
// Обработчик получает route — ты решаешь что с ним делать
console.log('Request intercepted:', route.request().url());
route.continue(); // Пропускаем без изменений
});
await page.goto('https://lab.becomeqa.com');
});Обработчик получает объект Route с четырьмя основными методами. fulfill() возвращает мокированный ответ, abort() блокирует запрос полностью, continue() пропускает его насквозь, fallback() передаёт управление следующему совпадающему обработчику. Все четыре понадобятся.
Glob-паттерны работают предсказуемо:
// Любой запрос на путь /api/
await page.route('**/api/**', route => route.continue());
// Конкретный эндпоинт независимо от origin
await page.route('**/api/items', route => route.continue());page.route() перехватывает запросы только от конкретной страницы. Для перехвата запросов со всех страниц в контексте используй browserContext.route().Возврат мокированных JSON-ответов через fulfill()
route.fulfill() обрывает запрос и возвращает указанный тобой ответ. Это рабочая лошадка UI-мокирования.
test('table renders with mocked API data', async ({ page }) => {
const mockItems = [
{ id: '1', destination: 'Tokyo', status: 'planned', notes: 'Cherry blossom season' },
{ id: '2', destination: 'Lisbon', status: 'completed', notes: '' },
];
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockItems),
});
});
await page.goto('https://lab.becomeqa.com');
// Шаги входа пропущены для краткости
await expect(page.getByText('Tokyo')).toBeVisible();
await expect(page.getByText('Lisbon')).toBeVisible();
});Контролируешь каждую часть ответа: статус-код, заголовки, content type и тело. Если приложение проверяет заголовки ответа (например Content-Type), включай их явно.
Для больших мокированных данных загружай из JSON-файла фикстуры:
import { readFileSync } from 'fs';
import path from 'path';
test('table renders with fixture data', async ({ page }) => {
const fixtureBody = readFileSync(
path.join(__dirname, 'fixtures/items.json'),
'utf-8'
);
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: fixtureBody,
});
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByRole('table')).toBeVisible();
});Это сохраняет читабельность тест-файлов когда мокированные данные сложные. Реалистичная платёжная запись или глубоко вложенный профиль пользователя не должны жить инлайн.
Блокировка запросов через abort()
Иногда нужно проверить что происходит когда запрос вообще не выполняется: изображение не загружается, сторонний аналитический скрипт таймаутит, или некритичный API-вызов который приложение должно обрабатывать без краша.
test('app shows error state when API is unreachable', async ({ page }) => {
await page.route('**/api/items', route => {
route.abort('failed'); // Симулирует ошибку соединения
});
await page.goto('https://lab.becomeqa.com');
// Вход, навигация к списку элементов...
// Приложение должно показать сообщение об ошибке, а не упасть
await expect(page.getByText('Unable to load items')).toBeVisible();
await expect(page.getByRole('table')).not.toBeVisible();
});Метод abort() принимает код ошибки: 'failed' для общей ошибки соединения, 'timedout' для поведения таймаута, 'blockedbyclient' для симуляции блокировки в стиле ad-blocker. Для тестирования обработки таймаута:
test('shows timeout message after slow response', async ({ page }) => {
await page.route('**/api/items', route => {
route.abort('timedout');
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByText('Request timed out')).toBeVisible();
});Блокировка также полезна для ускорения тестов: отброс ненужных запросов может сэкономить секунды на сьют.
test.beforeEach(async ({ page }) => {
// Блокируем аналитику — она не нужна и замедляет тесты
await page.route(/google-analytics\.com|segment\.io/, route => route.abort());
});Модификация запросов на лету через continue()
route.continue() пропускает запрос на сервер, но позволяет переопределить любую его часть до отправки: URL, метод, заголовки или тело. Удобно для инъекции auth-заголовков без изменения кода приложения, или для проверки как бэкенд обрабатывает конкретные комбинации заголовков.
test('injects auth header into every API request', async ({ page }) => {
await page.route('**/api/**', route => {
route.continue({
headers: {
...route.request().headers(),
'Authorization': 'Bearer test-token-for-e2e',
},
});
});
await page.goto('https://lab.becomeqa.com/dashboard');
await expect(page.getByRole('table')).toBeVisible();
});Можно также переписать URL запроса, что удобно для редиректа продакшн API-вызовов на staging без изменения конфига:
test('redirects API calls to staging', async ({ page }) => {
await page.route('https://api.production.com/**', route => {
const newUrl = route.request().url().replace(
'api.production.com',
'api.staging.becomeqa.com'
);
route.continue({ url: newUrl });
});
await page.goto('https://lab.becomeqa.com');
});continue() с кастомными заголовками всегда разворачивай route.request().headers() первым. Полная замена заголовков удалит такие вещи как Content-Type и Accept которые сервер может требовать.Перехват и инспекция через waitForRequest и waitForResponse
Иногда цель не в том чтобы что-то замокировать. Нужно проверить что конкретный запрос действительно произошёл, или перехватить данные ответа для ассёрта. page.waitForRequest() и page.waitForResponse() возвращают промисы которые резолвятся когда видят совпадающий запрос или ответ.
test('clicking Save sends the correct payload', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
// Шаги входа...
// Настраиваем слушатель ДО запуска действия
const requestPromise = page.waitForRequest(request =>
request.url().includes('/api/items') && request.method() === 'POST'
);
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByLabel('Destination').fill('Berlin');
await page.getByRole('button', { name: 'Save' }).click();
const request = await requestPromise;
const payload = request.postDataJSON();
expect(payload.destination).toBe('Berlin');
expect(payload.status).toBeDefined();
});Ключевой момент: настраивай слушатель до запуска действия. Если сначала ждать клик кнопки, а потом waitForRequest, запрос может уже сработать и ты будешь ждать запрос который никогда не придёт.
waitForResponse работает так же, но резолвится с ответом:
test('payment form shows success message after API confirms', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/payment');
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/payments') && response.status() === 200
);
await page.getByLabel('Card Number').fill('4111111111111111');
await page.getByRole('button', { name: 'Pay' }).click();
const response = await responsePromise;
const body = await response.json();
expect(body.status).toBe('success');
await expect(page.getByText('Payment confirmed')).toBeVisible();
});Тестирование состояний ошибок: 500, 401 и таймауты
Именно в тестировании ошибок page.route() особенно ценен. Это сценарии которые почти невозможно стабильно воспроизвести против реального бэкенда, но легко замокировать.
500 Internal Server Error
test('shows error banner on server failure', async ({ page }) => {
await page.route('**/api/items', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('https://lab.becomeqa.com');
// Вход...
await expect(page.getByRole('alert')).toContainText('Something went wrong');
});401 Unauthorized (сессия истекла)
test('redirects to login when session expires', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/items', route => {
requestCount++;
if (requestCount === 1) {
// Первый запрос успешен — пользователь "залогинен"
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: '1', destination: 'Madrid', status: 'planned' }]),
});
} else {
// Последующие запросы возвращают 401 — сессия истекла
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'Session expired' }),
});
}
});
await page.goto('https://lab.becomeqa.com');
// Проверяем начальную загрузку, потом перезагружаем...
await page.reload();
await expect(page).toHaveURL(/\/login/);
});Сетевой таймаут
test('shows retry button after network timeout', async ({ page }) => {
await page.route('**/api/items', async route => {
// Задержка и затем abort — симулирует медленную сеть с таймаутом
await new Promise(resolve => setTimeout(resolve, 8000));
route.abort('timedout');
});
await page.goto('https://lab.becomeqa.com');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({
timeout: 15000,
});
});route.fallback() для точечного мокирования
route.fallback() позволяет обработчику уступить место следующему совпадающему обработчику (или реальной сети). Это правильный инструмент когда нужно замокировать конкретные эндпоинты оставляя всё остальное на реальный сервер.
test('mocks only the payments endpoint', async ({ page }) => {
// Первый обработчик: мокируем платёжный эндпоинт
await page.route('**/api/payments', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'success', transactionId: 'mock-txn-001' }),
});
});
// Второй обработчик: пропускаем всё остальное
await page.route('**', route => route.fallback());
await page.goto('https://lab.becomeqa.com');
// Реальный вход, реальная загрузка данных — только платежи замоканы
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.goto('https://lab.becomeqa.com/payment');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Payment confirmed')).toBeVisible();
});route.fallback() отличает хирургическое мокирование от мокирования по принципу «всё или ничего». Можно заменить одну хрупкую внешнюю зависимость, оставив остальной тест на реальном поведении.
Несколько обработчиков для одного маршрута вычисляются в обратном порядке регистрации: зарегистрированный последним вычисляется первым. Когда обработчик вызывает fallback(), Playwright переходит к предыдущему зарегистрированному обработчику.
// Зарегистрирован первым — выступает как дефолт
await page.route('**/api/**', route => route.continue());
// Зарегистрирован вторым — вычисляется первым
await page.route('**/api/payments', route => {
route.fulfill({ status: 200, body: JSON.stringify({ status: 'success' }) });
});Когда НЕ нужно мокировать
У моков есть цена: тесты настолько хороши насколько хороши твои мокированные данные. Если реальный API возвращает структуру которую ты не предусмотрел в фикстуре, мокированные тесты пройдут пока в продакшне будет поломка.
Есть категории тестов где мокирование активно вредит.
Контрактные тесты должны обращаться к реальному API. Если проверяешь что фронтенд-запрос соответствует ожидаемому контракту бэкенда (имена полей, обязательные заголовки, структура ответа), мок не поймает несоответствие. Именно для этого нужен реальный API. Интеграционные тесты для критических флоу должны использовать реальный бэкенд. Флоу входа, платёжный флоу, отправка данных которая движет основным бизнесом, требуют реальной интеграции чтобы поймать настоящие режимы отказа. Замокируй их и будешь тестировать уверенность, а не поведение. При отладке реального бага мокирование не даёт увидеть настоящую проблему. Если пользователи сообщают о проблеме со страницей подтверждения платежа, последнее что нужно: замоканный платёжный эндпоинт скрывающий реальный ответ.Практическое правило: мокируй чтобы сделать тест детерминированным и быстрым когда сетевой запрос сам по себе не тестируется. Не мокируй когда сам запрос или сервер который его обрабатывает находится под тестом. Полноценный тест-сьют использует оба подхода: реальные API-вызовы для интеграционных тестов и контрактов, мокированные ответы для UI-рендеринга и тестов состояний ошибок.
FAQ
Влияет ли page.route() на запросы которые стартовали до регистрации обработчика?
Нет. Обработчики перехватывают только запросы инициированные после регистрации. Всегда вызывай page.route() перед page.goto() или перед действием которое запускает запрос.
Можно ли настроить несколько обработчиков для одного URL?
Да. Playwright вычисляет обработчики в обратном порядке регистрации. Используй route.fallback() чтобы передать управление следующему обработчику. page.unroute() удаляет обработчик когда он больше не нужен.
Как замокировать стриминговый ответ?
route.fulfill() не поддерживает стриминг нативно: отправляет тело целиком за раз. Для стриминговых сценариев нужен локальный тестовый сервер или инструмент вроде msw (Mock Service Worker) интегрированный рядом с Playwright.
Данные мока хранить в тест-файлах или файлах фикстур?
Короткие мок-объекты (2-3 поля) можно инлайн. Всё что крупнее или переиспользуется между тестами кладут в директорию fixtures/. Тест-файлы должны быть сосредоточены на поведении, а не на настройке данных.
В чём разница между page.route() и Service Workers для мокирования?
page.route() перехватывает на уровне Playwright, до сетевого стека браузера. Service Workers перехватывают внутри браузера. Для Playwright-тестов page.route() проще, надёжнее и не требует настройки в коде приложения. Service Workers полезны когда мок должен сохраняться между навигациями или влиять на кэш Service Worker.
→ See also: API-тестирование в Playwright: выходим за рамки UI | Создание масштабируемого фреймворка тестов Playwright с нуля | API-тестирование с Playwright APIRequestContext (без Postman)