Классические инструменты веб-автоматизации (Cypress, Selenium) исторически мокировали сеть путем внедрения JavaScript-скриптов в контекст страницы (Monkey-patching глобальных методов window.fetch и XMLHttpRequest). У этого подхода есть фундаментальные ограничения:
fetch не способен перехватить запросы, инициируемые Service Workers, Web Workers, HTML-тегами <img src="...">, <link rel="stylesheet">, шрифтами @font-face или методом navigator.sendBeacon.ECONNRESET), зависание сокета по таймауту или отсутствие интернет-соединения на уровне ОС.+-----------------------------------------------------------------------------+
| Старый подход (Monkey Patching в runtime страницы) |
| |
| DOM Window -> [window.fetch Override] -> Возврат Promise.resolve() |
| (Service Worker, Fonts, Image Tags, Beacons — НЕ ПЕРЕХВАТЫВАЮТСЯ) |
+-----------------------------------------------------------------------------+
+-----------------------------------------------------------------------------+
| Playwright 2026: Сетевой перехват на уровне движка |
| |
| DOM / Worker / Service Worker / Tags |
| │ |
| ▼ |
| [Браузерный сетевой сокет (Chromium Network Stack / WebKit LibWebSockets)] |
| │ |
| ▼ (Событие CDP: Fetch.requestPaused / WebSocket Frame) |
| [Playwright Route Interceptor: page.route() / context.route()] |
| │ |
| ├──► route.fulfill() ──► Инжекция синтетического HTTP-ответа |
| ├──► route.abort() ──► Физический обрыв сокета (Connection Reset) |
| ├──► route.continue() ──► Пропуск запроса во внешнюю сеть |
| └──► route.fetch() ──► Запрос на реальный сервер с модификацией |
+-----------------------------------------------------------------------------+Playwright оперирует на уровне сетевого стека браузерного ядра через протокол CDP (домен Fetch) и специализированные хуки в Firefox и WebKit. Запрос перехватывается до выхода в физическую сеть, что гарантирует 100% покрытие любого типа сетевого трафика.
При вызове page.route() или context.route() Playwright регистрирует фильтр сетевого конвейера.
+--------------------------------+
| Браузер инициирует HTTP |
| запрос GET /api/v1/user |
+--------------------------------+
│
▼
+--------------------------------+
| Срабатывает фильтр паттерна |
| page.route('**/api/v1/**', r) |
+--------------------------------+
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
+-------------------+ +------------------+ +------------------+
| route.fulfill() | | route.abort() | | route.continue() |
+-------------------+ +------------------+ +------------------+
| Синтезирует ответ | | Обрывает сокет на| | Пропускает пакет |
| прямо в памяти | | уровне сетевого | | на реальный |
| Node.js: status, | | драйвера | | сервер бекенда |
| headers, json | | (Network Error) | | без изменений |
+-------------------+ +------------------+ +------------------+
│ │ │
▼ ▼ ▼
+-------------------------------------------------------------+
| Рендерер браузера получает результат |
+-------------------------------------------------------------+Вызов page.route() связывает шаблон URL с асинхронной функцией-обработчиком в Playwright.
Код фронтенда вызывает fetch(). Запрос достигает сетевого стека движка браузера.
Браузер приостанавливает отправку фрейма и шлет событие Fetch.requestPaused через WebSocket в Playwright.
Инженерный код проверяет тело/заголовки и вызывает fulfill(), abort() или continue().
Playwright отправляет команду в браузер, и Promise в клиентском JavaScript успешно резолвится или реджектится.
Чем перехват через page.route() в Playwright принципиально отличается от клиентского monkey-patching window.fetch (применявшегося в Cypress v9 и старых раннерах)?
Route#Каждый зарегистрированный перехватчик получает экземпляр Route и оригинальный Request:
interface Route {
/** Возврат синтетического ответа клиенту */
fulfill(options?: {
status?: number;
headers?: Record<string, string>;
contentType?: string;
body?: string | Buffer;
json?: unknown;
response?: APIResponse;
}): Promise<void>;
/** Пропуск запроса на реальный сервер с возможной подменой заголовков или метода */
continue(options?: {
url?: string;
method?: string;
headers?: Record<string, string>;
postData?: string | Buffer;
}): Promise<void>;
/** Имитация сетевого сбоя на транспортном уровне */
abort(errorCode?:
| 'aborted'
| 'accessdenied'
| 'addressunreachable'
| 'blockedbyclient'
| 'connectionaborted'
| 'connectionclosed'
| 'connectionfailed'
| 'connectionrefused'
| 'connectionreset'
| 'internetdisconnected'
| 'nameNotResolved'
| 'timedout'
): Promise<void>;
/** Выполнение реального сетевого запроса из Node.js с последующей модификацией ответа */
fetch(options?: {
url?: string;
method?: string;
headers?: Record<string, string>;
postData?: string | Buffer;
}): Promise<APIResponse>;
}В E2E тестах недопустимо совершать реальные списания денег через боевой шлюз Stripe. Перехват позволяет детерминированно проверить реакцию UI на сбой платежной системы:
import { test, expect } from '@playwright/test';
test('пользователь видит информативный баннер при сбое платежного шлюза', async ({ page }) => {
// 1. ВАЖНО: Регистрируем перехватчик ДО навигации на страницу
await page.route('**/api/v1/payments/charge', async (route) => {
// Верифицируем метод входящего запроса
expect(route.request().method()).toBe('POST');
// Возвращаем контролируемую 500 ошибку с JSON-структурой бекенда
await route.fulfill({
status: 500,
contentType: 'application/json',
json: {
success: false,
errorCode: 'GATEWAY_TIMEOUT',
message: 'Платежный шлюз временно недоступен. Средства не списаны.',
},
headers: {
'x-mocked-by': 'playwright-e2e',
},
});
});
await page.goto('/checkout/payment');
// Заполняем форму и нажимаем кнопку оплаты
await page.getByRole('button', { name: 'Оплатить 9 900 ₽' }).click();
// Проверяем, что интерфейс корректно отобразил сообщение об ошибке, а не упал в белый экран
const errorAlert = page.getByRole('alert');
await expect(errorAlert).toBeVisible();
await expect(errorAlert).toContainText('Платежный шлюз временно недоступен');
// Убеждаемся, что кнопка оплаты снова стала доступна для повторной попытки
await expect(page.getByRole('button', { name: 'Оплатить 9 900 ₽' })).toBeEnabled();
});route.fetch()#Часто требуется протестировать интерфейс с реальными данными от бэкенда, но с искусственно измененным признаком (например, включить пользователю экспериментальный feature flag или права супервизора), не меняя запись в базе данных.
import { test, expect } from '@playwright/test';
test('интерфейс отображает AI-ассистента, если включен экспериментальный флаг', async ({ page }) => {
await page.route('**/api/v1/user/features', async (route) => {
// 1. Выполняем реальный сетевой запрос к серверу через Playwright
const response = await route.fetch();
const originalJson = (await response.json()) as { features: Record<string, boolean> };
// 2. Модифицируем payload "на лету"
const modifiedJson = {
...originalJson,
features: {
...originalJson.features,
experimentalAiCopilot: true, // Включаем флаг
},
};
// 3. Отдаем клиенту модифицированный ответ со статусом и заголовками реального сервера
await route.fulfill({
response,
json: modifiedJson,
});
});
await page.goto('/editor');
// Проверяем появление плавающей панели AI Copilot
await expect(page.getByTestId('ai-copilot-panel')).toBeVisible();
});Проверка работы локального кэша IndexedDB и Service Worker при потере интернет-соединения:
import { test, expect } from '@playwright/test';
test('приложение сохраняет черновик статьи в IndexedDB при внезапном отключении сети', async ({
page,
context,
}) => {
await page.goto('/articles/new');
await page.getByLabel('Заголовок статьи').fill('Архитектура распределенных систем 2026');
await page.getByRole('textbox', { name: 'Контент' }).fill('Текст черновика статьи...');
// 1. Физически переводим браузерный контекст в оффлайн-режим
await context.setOffline(true);
// Нажимаем "Сохранить черновик"
await page.getByRole('button', { name: 'Сохранить черновик' }).click();
// 2. Интерфейс должен отобразить индикатор работы без сети
await expect(page.getByText('Оффлайн-режим: черновик сохранен локально')).toBeVisible();
// Перезагружаем страницу в оффлайне (Service Worker должен отдать закэшированный shell)
await page.reload();
// 3. Данные не должны потеряться
await expect(page.getByLabel('Заголовок статьи')).toHaveValue('Архитектура распределенных систем 2026');
// 4. Восстанавливаем соединение
await context.setOffline(false);
await expect(page.getByText('Синхронизировано с облаком')).toBeVisible({ timeout: 5_000 });
});Внешние скрипты (Google Tag Manager, Datadog RUM, Hotjar, рекламные пиксели) замедляют выполнение E2E тестов на 30–50% и создают лишний внешний шум в CI. Блокировка трекеров ускоряет прогон, а механизм routeFromHAR позволяет воспроизводить стабильные снапшоты бэкенда.
import { test, expect } from '@playwright/test';
import path from 'path';
test.describe('Каталог с воспроизведением сети из HAR-архива', () => {
test.beforeEach(async ({ context }) => {
// 1. Глобальная блокировка внешних трекеров и аналитики на уровне контекста
await context.route(
/(googletagmanager|google-analytics|hotjar|segment|datadoghq)\.com/,
(route) => route.abort('blockedbyclient')
);
});
test('быстрый детерминированный рендеринг каталога из сетевого архива', async ({ page }) => {
// 2. Воспроизведение сетевых ответов из файла HAR
// update: false — только чтение архива; notFound: 'abort' — запрет не записанных запросов
await page.routeFromHAR(path.join(__dirname, '../__fixtures__/catalog-network.har'), {
url: '**/api/v1/catalog/**',
update: false,
notFound: 'abort',
});
await page.goto('/shop');
// Проверяем отображение каталога товаров (ответы приходят из локального HAR за 1-2 мс)
const items = page.getByRole('listitem');
await expect(items).toHaveCount(12);
});
});Что произойдет, если в обработчике page.route() для определенного запроса не будет вызван ни один терминальный метод (fulfill, continue, abort)?
Если вы вызываете page.route() после page.goto('/'), первичные сетевые запросы приложения (загрузка конфигурации, профиля пользователя, стартовых данных) успевают уйти на реальный сервер. Мок перехватит только последующие клики, что приводит к состояниям гонки.
// ❌ Антипаттерн: страница уже загружается, мок опоздал
await page.goto('/dashboard');
await page.route('**/api/user', async (route) => { ... });
// ✅ Правильное решение: регистрировать правила перехвата ДО перехода по URL
await page.route('**/api/user', async (route) => { ... });
await page.goto('/dashboard');Если внутри функции-обработчика page.route(url, handler) не вызывается ни route.fulfill(), ни route.continue(), ни route.abort(), браузерный запрос остается в подвешенном состоянии (Pending). Страница бесконечно ожидает ответ, и тест падает по глобальному таймауту.
// ❌ Антипаттерн: запрос зависает в очереди
await page.route('**/api/data', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 200, json: { ok: true } });
}
// Забыли ветку else: GET-запросы зависнут навсегда!
});
// ✅ Правильное решение: все ветки кода должны завершать маршрут
await page.route('**/api/data', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 200, json: { ok: true } });
} else {
await route.continue();
}
});Паттерн **/api/users не перехватывает URL вида https://domain.com/api/users?page=2&limit=10, так как вопросительный знак и query-параметры не всегда матчатся простым слэшем.
Решение: использовать шаблон с подстановочными знаками **/api/users* или регулярные выражения /api\/users/.
Если разработчики мокируют 100% сетевых вызовов в сквозных тестах, тесты перестают быть End-to-End и превращаются в медленные интеграционные тесты интерфейса. В таком сценарии расхождение контрактов между бэкендом и фронтендом будет пропущено в продакшн. Решение: мокировать только сторонние нестабильные сервисы (SMS, платежи, внешние CRM) и граничные ошибки (500, 503, Offline), а основные бизнес-флоу прогонять по живому тестовому API.