Класичні інструменти веб-автоматизації (Cypress, застарілий Selenium) історично мокували мережу шляхом ін'єкції JavaScript-скриптів у контекст сторінки (Monkey-patching глобальних методів window.fetch та XMLHttpRequest). Цей клієнтський підхід має фундаментальні архітектурні обмеження:
window.fetch неспроможний перехопити запити, ініційовані Service Workers, Web Workers, HTML-тегами <img src="...">, <link rel="stylesheet">, шрифтами @font-face або методом navigator.sendBeacon.ECONNRESET), зависання TCP-з'єднання за таймаутом або відключення мережевого адаптера на рівні операційної системи.+-----------------------------------------------------------------------------+
| Старий підхід (Monkey Patching у JavaScript-середовищі DOM) |
| |
| DOM Window -> [window.fetch Override] -> Повернення Promise.resolve() |
| (Service Workers, Шрифти, Теги зображень, 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) у Chromium та низькорівневі хуки у 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: | | рівні мережевого | | на реальний |
| status, headers, | | драйвера | | сервер бекенду |
| json payload | | (Network Error) | | без змін |
+-------------------+ +------------------+ +------------------+
│ │ │
▼ ▼ ▼
+-------------------------------------------------------------+
| Рендерер браузера отримує результат |
+-------------------------------------------------------------+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>;
/** Виконання реального HTTP-запиту з процесу Node.js із подальшим аналізом або модифікацією відповіді */
fetch(options?: {
url?: string;
method?: string;
headers?: Record<string, string>;
postData?: string | Buffer;
}): Promise<APIResponse>;
}В автоматизованих E2E тестах неприпустимо списувати реальні кошти через платіжні шлюзи на кшталт Stripe. Перехоплення дозволяє детерміновано перевірити реакцію інтерфейсу на збій шлюзу:
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 помилку відповідно до схеми бекенду
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() після page.goto('/'), первинні запити застосунку (завантаження конфігурації, профілю користувача або початкових даних) встигають надійти на реальний сервер до ініціалізації моку. Перехоплювач обробить лише наступні дії, створюючи умови перегонів.
// ❌ АНТИПАТЕРН: Сторінка вже завантажується, реєстрація моку запізнилася
await page.goto('/dashboard');
await page.route('**/api/user', async (route) => { ... });
// ✅ ПРАВИЛЬНО: Реєструвати правила перехоплення ДО навігації
await page.route('**/api/user', async (route) => { ... });
await page.goto('/dashboard');Якщо всередині асинхронного обробника page.route(url, handler) не викликано жодного з методів route.fulfill(), route.continue() або route.abort(), запит браузера залишається в підвішеному стані (Pending). Інтерфейс клієнта зависає, і тест завершується аварійно за таймаутом.
// ❌ АНТИПАТЕРН: Запит зависає, якщо метод не POST
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 не перехоплює адреси вигляду https://domain.com/api/users?page=2&limit=10, оскільки query-параметри не завжди відповідають простому слешу.
Рішення: використовувати шаблон із підстановочними знаками **/api/users* або регулярні вирази /api\/users/.
Якщо мокувати 100% мережевих викликів у наскрізних тестах, вони перетворюються на повільні компонентні тести інтерфейсу. Розбіжності контрактів між бекендом та фронтендом неминуче просочаться в продакшн. Рішення: мокувати лише сторонні нестабільні сервіси (SMS, платіжні шлюзи, зовнішні CRM) та крайові збої (500, 503, Offline), а критичні бізнес-сценарії запускати проти реального тестового API.
Виклик page.route() зв'язує шаблон URL із асинхронною функцією-обробником у Playwright.
Клієнтський код викликає fetch(). Запит надходить у низькорівневий стек рушія браузера.
Браузер призупиняє сокет і надсилає подію Fetch.requestPaused через WebSocket у Playwright.
Тестовий код перевіряє заголовки чи тіло запиту та викликає fulfill(), abort() або continue().
Playwright надсилає команду в браузер, і клієнтський JavaScript Promise успішно резолвиться або реджектиться.