Перехоплення й мокання мережі
Зміст
Автотест, який відкриває сторінку в браузері, майже ніколи не працює сам із собою. Сторінка робить десятки запитів — по профіль користувача, список замовлень, конфігурацію , аналітику, — і кожен із них іде у справжню систему з власною базою, власними й власними збоями. Звідси головний клас болю в наскрізному (end-to-end) тестуванні: тест червоний не тому, що фронтенд зламався, а тому, що API відповів на 300 мілісекунд повільніше, віддав інший порядок елементів або «моргнув» пʼятисоткою.
(network interception) — це вклинювання тесту між браузером і мережею: замість покладатися на реальний сервер, тест сам вирішує, пропустити запит, підмінити відповідь, сповільнити її чи обірвати зʼєднання. Підміна відповіді власними даними називається (mocking). Це канонічна глава теми: тут повний виклад техніки та її меж, решта глав розділу посилаються сюди.
Де стоїть точка перехоплення
Чому «між браузером і мережею», а не «у коді сторінки»? Браузер — не одна програма, а набір підсистем із власним темпом роботи: одна парсить розмітку й малює, друга виконує JavaScript, третя ходить у мережу (Архітектура браузера й рендеринг). Мережевий шар — саме та підсистема, якою тест маніпулює ззовні: він підміняє те, що приходить із сервера, не чіпаючи ні код сторінки, ні DOM.
Наслідок практичний і важливий. Код застосунку про підміну не знає взагалі — для нього рукотворна відповідь виглядає як звичайний мережевий обмін, тож перевіряється справжня гілка коду, а не спеціальна тестова. Під контроль потрапляють усі запити сторінки: і XMLHttpRequest, і fetch. Окремої інфраструктури для цього не треба — достатньо оголосити обробник маршруту (route) для або звузити його до однієї сторінки. І перевірка не обмежена HTTP: WebSocket інструмент так само вміє інспектувати й підміняти (WebSockets і реалтайм).
Модель: шаблон, обробник, три рішення
Модель однакова незалежно від інструмента: тест реєструє правило «для запитів за цим шаблоном URL викликай ось цей обробник», а обробник ухвалює одне з трьох рішень.
| Рішення | Що відбувається | Типове застосування |
|---|---|---|
Пропустити (continue) | Запит іде на реальний сервер; дорогою можна змінити URL, метод, заголовки чи тіло | Логування, підміна заголовка, часткова модифікація |
Підмінити (fulfill) | Сервер не викликається взагалі, браузер отримує рукотворну відповідь | Дані, помилки, порожні стани |
Обірвати (abort) | Запит завершується мережевою помилкою | Недоступність сервісу, втрата мережі |
await page.route('**/api/orders', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ orders: [] }),
});
});
Чотири властивості моделі, які пояснюють більшість «мок не працює». Перше: правило спрацьовує лише для запитів, зроблених після його реєстрації. Друге: glob-шаблон має збігтися з усім URL, а не з його частиною, — для складніших умов беруть . Третє: перехоплення вибіркове, і те, що не підійшло під жоден шаблон, спокійно йде на реальний сервер. Четверте: якщо на один шаблон зареєстровано кілька обробників, вони спрацьовують у зворотному до реєстрації порядку, і останній зареєстрований перекриває попередні; передати керування далі по ланцюгу можна явно через route.fallback(), тоді як route.continue() ланцюг зупиняє. Звідси й прийом: загальний мок кладуть у налаштування, а вужче правило реєструють пізніше — у самому тесті. Маршрут, заданий на рівні контексту, діє й на попапи, тож глушіння аналітики зручно вішати саме туди.
Наша практика (не канон). Припису «спершу маршрут, потім дія» джерело прямо не формулює — воно лише показує цей порядок у прикладі; вимога наша. Наше й правило прибирати маршрут наприкінці тесту: залишений маршрут перехоплюватиме запити наступних тестів у тому самому й дасть плаваючі падіння, тож зняття моку — частина ізоляції стану, а не косметика.
Чотири сценарії: підміна, затримка, помилка, обрив
Підміна даних. Повний мережі означає, що запит до API не відбувається взагалі — відповідь цілком складає тест. Є й мʼякший режим: виконати справжній запит, узяти реальну відповідь і перевизначити в ній лише потрібні поля. Так перевіряють порожній стан, дуже великий список, профіль з адміністраторською роллю чи ввімкнений фіча-флаг — не домовляючись про особливий стан у базі.
Затримка. Щоб побачити спінер, скелетон і заблоковану на час запиту кнопку, відповідь треба сповільнити: обробник витримує паузу й лише потім віддає керування далі.
Помилка. Це той самий fulfill, тільки зі статусом помилки й тілом помилки; що означає кожен код — у главі HTTP статус-коди.
Обрив. Аргумент abort — код помилки із закритого переліку, і коди не синоніми: connectionrefused означає «у зʼєднанні відмовлено», namenotresolved — «імʼя хоста не вдалося розвʼязати», internetdisconnected — «інтернет-зʼєднання втрачено», connectionreset — скидання, що відповідає TCP RST. Тобто «сервіс лежить», «DNS не резолвиться» і «мережі немає» — три різні негативні сценарії, а не один.
Дві пастки при зміні запиту, які дока називає прямо. Опція headers застосовується і до самого запиту, і до редиректів, які він ініціює, а от url, method і postData на редиректи не переносяться. І заборонені заголовки (Cookie, Host, Content-Length та інші) перевизначити не вийде: підміну буде проігноровано, піде оригінальний заголовок. Тест, який «підмінив» Cookie, зеленіє на непідміненому запиті.
Наша практика (не канон). Сам прийом «пауза в обробнику перед continue або fulfill» канонічного джерела не має — джерельна тут лише механіка обробника. Наше й спостереження, який клас багів ця пауза ловить: спінер, що не зникає; кнопка, яку встигають натиснути двічі й відправити дубль; , налаштований занадто оптимістично.
Навіщо це QA
Мокання відповіді дає детермінізм: замокана відповідь однакова щоразу, тож результат тесту залежить від коду фронтенду, а не від стану чужої системи. Другий бік того самого — незалежність від бекенда: при повному стабі запит до API не відбувається взагалі, тож недоступний, ще не написаний або просто «моргаючий» сервіс перестає бути умовою зеленого тесту. Третій — доступ до станів, які в живій системі трапляються раз на місяць: порожня видача, пʼятисотка, обрив.
Наша практика (не канон). Сам перелік вигод зібраний нами — жодне джерело такого списку не дає. У робочому вигляді він такий: детермінізм, ізоляція стану між тестами, швидкість (локальна відповідь замість мережевої), доступ до і незалежність від готовності бекенда — за узгодженим контрактом фронтенд-тести пишуться ще до того, як реалізували.
Стаб чи мок: що саме ви ставите
Дублер (test double) замінює залежність «специфічним для тесту еквівалентом» і зобовʼязаний повторити її API, а не поведінку; при цьому реалізовувати весь інтерфейс він не мусить — лише те, що потрібно конкретному тесту. Різновиди розрізняються не «розумністю», а місцем і моментом перевірки: стаб (stub) — точка керування, він заганяє систему в гілку, у яку інакше не потрапити, і зазвичай ніяк не реагує на те, чого в ньому не запрограмували; (spy) — стаб із записом, перевірка записаного відбувається пізніше; мок (mock) перевіряє взаємодію під час неї і сам є специфікацією очікуваних викликів.
У мережі обидва режими дає той самий обробник маршруту: fulfill з готовим тілом — це стаб, а читання route.request().postDataJSON() із наступним після дії — уже спай. Повна таксономія дублерів (разом із і дамі) — канон розділу про автоматизацію; тут лише браузерна механіка. Корисна деталь для співбесіди: у силабусі ISTQB CTFL таксономії дублерів немає взагалі — стаби, драйвери, симулятори й названі там елементами тестового оточення.
Межа техніки: чого мок не перевіряє
Замокавши все, ви тестуєте свої припущення про бекенд, а не сам бекенд. Найпідступніший прояв — дрейф контракту: бекенд перейменував поле, реальний застосунок зламався, а тест і далі зелений, бо мок про зміну не знає. Страховка від цього названа канонічно: звіряє кожен застосунок в ізоляції з документованим спільним розумінням, а поширений спосіб його реалізувати — перевірити, що всі виклики до ваших дублерів повертають те саме, що повернув би реальний застосунок.
Звідси дві нормативні межі з першоджерела таксономії дублерів. Перша: має бути хоча б один тест без дублерів — із дублером ви тестуєте іншу конфігурацію, ніж піде в продакшн. Друга: надмірне вживання дублерів названо причиною через переспецифікацію — мок, що звіряє запит до останнього поля, ламається від доданого аналітичного параметра.
Наша практика (не канон). Політика мокання рекомендацією джерела не є — це наш орієнтир: мокати зовнішнє й некероване (платіжні , аналітику), не мокати без потреби власний основний API в і мокати на найтоншому потрібному рівні — один ендпоінт, а не всю мережу.
Спершу подивіться фактичний запит
Мок пишуть не «за документацією», а за тим, що браузер справді відправляє. Кожен рядок панелі Network — один запит сторінки; журнал ведеться автоматично, але лише поки інструменти розробника відкриті, тож панель відкривають перед відтворенням сценарію. Поле фільтра розуміє властивості на кшталт status-code:500, method:POST і domain:, кілька властивостей зʼєднуються тільки через AND, а інверсію дає окремий Invert. Тіло запиту показує вкладка Payload, тіло відповіді — Response.
Звідти ж беруть HAR (HTTP Archive) — JSON-формат експорту зафіксованої мережевої сесії: файл можна прикласти до тікета, завантажити назад у панель і розбирати чужу сесію як власну, а ще використати як джерело підмінених відповідей — це режим запису-відтворення, коли дані реалістичні, бо колись справді прийшли з сервера, але тест від сервера вже не залежить. Типовий експорт із панелі «санітизований»: з нього прибрано Cookie, Set-Cookie й Authorization, тож прикріпити його до тікета безпечно. Детальний розбір самої панелі — у главі DevTools: вкладка Network і дебаг.
Зіставлення маршруту: URL і відсоткове кодування
Шаблон маршруту зіставляється з URL — а URL це структура, а не рядок (URL і кодування). Три наслідки просто з цього. Перший: фрагмент після # відокремлюється ще до звернення до ресурсу й на сервер не їде взагалі, тож у перехопленому URL його не буде — на сервер ідуть authority, шлях і запит. Другий: типові порти в серіалізації опускаються, тому https://example.com:443/ і https://example.com/ — той самий ресурс, а :8443 дає вже інше . Третій: рівність двох URL визначається збігом їхніх серіалізованих форм, тож «схожі» адреси зіставленню не підлягають.
Далі — кодування. Байт записується трійкою % плюс дві шістнадцяткові цифри, регістр яких значення не має (%2F і %2f — той самий байт). Пробіл має дві угоди: %20 у звичайному URL і + у форматі application/x-www-form-urlencoded, а справжній плюс у ньому записується як %2B. Той самий розрив живе всередині браузерного API: обʼєкт URL для шляху вживає %20, а searchParams серіалізує пробіл як +. У JavaScript три інструменти дають три різні результати для одного значення: encodeURIComponent (пробіл у %20, кодує &), encodeURI (пробіл теж у %20, але роздільники ; / ? : @ & = + $ , # лишає як є) і URLSearchParams (пробіл у +). Практичний висновок один: зіставляйте розібрані значення (new URL(...).searchParams.get(...)), а не сирі рядки, інакше результат залежить від того, хто і як закодував значення.
Наша практика (не канон). Те, що порядок параметрів у сирому рядку запиту не гарантований, канонічного джерела не має — це наше робоче припущення, з якого й випливає правило порівнювати розібрані параметри, а не рядок цілком.
CORS у мокнутих відповідях
Заголовки Access-Control-* — інструкція для браузера, а не для сервера (CORS і політика одного походження). Для мокання це означає дві речі.
Перша: один логічний запит дає два записи в мережі. Спершу preflight — окремий запит методом OPTIONS, у якому браузер заявляє Access-Control-Request-Method і Access-Control-Request-Headers; лише потім іде справжній запит. Тіло типу application/json preflight спричиняє, як і Authorization чи методи PUT, PATCH, DELETE. Тому шаблон, який ловить тільки POST /api/..., OPTIONS не перехопить, і тест упаде з CORS-помилкою «якої не мало бути». Окрема пастка: preflight ніколи не надсилається з обліковими даними, тож перевірка авторизації, яку сервер вішає на OPTIONS, завалить preflight сама.
Друга: підміна CORS не скасовує. Мокнута відповідь на крос-оріджин запит має нести потрібні заголовки Access-Control-*, інакше браузер відкине її так само, як відкинув би справжню. І дзеркально — обійти CORS підготовкою стану через програмний HTTP-клієнт можна, але відтворити браузерну CORS-помилку ним не вийде: такі клієнти не реалізують і заголовки Access-Control-* просто ігнорують. Ще одне: заблокований CORS не означає, що дані не змінилися — простий запит сервер виконує повністю, браузер лише не віддає відповідь скрипту.
Наша практика (не канон). Потребу окремо обробляти OPTIONS у маршруті дока прямо не описує — це наш висновок із того, що preflight є окремим запитом.
Посередник як інша точка того самого
Перехоплення у фреймворку живе всередині браузера. Але та сама ідея давно реалізована зовні — (intermediary). Форм посередника три: — агент пересилання, якого обирає клієнт; шлюз (він же reverse proxy) — вузол, що назовні поводиться як origin-сервер, а всередину транслює запити іншим серверам; — сліпий ретранслятор, який повідомлень не змінює, і саме ним TLS протягують крізь спільний фаєрвольний проксі. Окремий випадок — interception proxy, протилежність інструмента, який тестувальник ставить собі свідомо: його клієнт не обирав, і протокольно такий вузол не відрізняється від на шляху (DNS, IP, порти та мережа).
Оскільки проксі бачить увесь трафік, він може не лише передавати його, а й інспектувати та змінювати: побачити реальні запити й відповіді, підмінити їх, зімітувати помилку сервера, уповільнити відповідь — той самий набір, що й у маршруті. Ціна входу — TLS: звичайний проксі зашифрований потік не бачить і не змінює, бо CONNECT лише просить відкрити трубу між клієнтом і сервером; щоб зазирнути всередину, інструмент прикидається сервером для клієнта й клієнтом для сервера, а це вимагає довіри до його власного сертифіката. Коли перехоплення в тесті не дістає (нативний застосунок, стороннє SDK), тести можна пустити через проксі штатно — глобально або на окремий контекст, з автентифікацією й списком хостів-винятків. І дрібниця, що економить години: 502 і 504 віддає сервер «у ролі шлюзу або проксі», а 503 означає, що сервер, який відповів, сам тимчасово не здатен обробити запит — посередника в цьому означенні немає.
OAuth 2.0: потік, який мокають цілком
OAuth 2.0 — фреймворк делегованого доступу: він дає сторонньому застосунку обмежений доступ до сервісу від імені власника ресурсу (Автентифікація та авторизація). Ролей чотири — власник ресурсу, клієнт, сервер авторизації й сервер ресурсів, — а в потоці з кодом авторизації браузер спершу відправляється на ендпоінт авторизації і лише потім клієнт міняє отриманий код на токен, уже сервер-до-сервера; сам токен предʼявляють у заголовку Authorization зі схемою Bearer. Термінологічна межа, яку люблять уточнювати: OAuth 2.0 — про авторизацію, а «Вхід через Google» технічно спирається на надбудову .
Саме тому OAuth — показовий приклад того, що мокають цілком: перевіряти треба, як ваш застосунок обробляє повернутий код або токен, а не роботу чужого провайдера. Провайдера підміняють на рівні мережі або піднімають тестовий сервер ідентичності. І одразу межа техніки: браузерний маршрут бачить кроки 1–3, а крок 4 іде сервер-до-сервера й повз браузер, тож перехопленням у тесті його не дістати. Якщо ж мета — перевірити застосунок після входу, сесію отримують через бекенд або прямим обміном токена, а UI-логін лишають одному окремому -тесту.
Наша практика (не канон). Двох речей джерела не стверджують. Перша: провайдери цілеспрямовано детектують автоматизацію — , «підозрілий вхід», блокування headless, — тож повний потік через реальний UI Google чи GitHub у тесті погано відтворюваний. Друга: сам принцип «чужого провайдера не тестують» наш; реф покриває лише механізм мокання мережі.
Гонка станів, затримки й тротлінг
Гонка (race condition) — залежність результату від порядку завершення асинхронних операцій там, де цей порядок не гарантований (Виконання JavaScript та event loop). Кожне завдання обробляється повністю, перш ніж почнеться інше, тож гонка виникає не всередині завдання, а між завданнями. У коді її лікують відстеженням актуальності запиту — скасуванням попереднього через AbortController або ігноруванням застарілих відповідей.
Для мокання це має прямий наслідок. Мокаючи кілька ендпоінтів одразу, ви самі задаєте порядок їхнього завершення — і однаковими миттєвими відповідями можете сховати гонку, яка на живому бекенді відтворюється регулярно. Дзеркально: різні затримки в обробниках роблять порядок керованим, і сценарій «повільна відповідь прийшла після швидкої» стає відтворюваним у кожному прогоні. Синхронізуватися при цьому треба з конкретною відповіддю (очікування мережевої відповіді після дії), а не фіксованою паузою: гонку не усуває, а маскує, бо воно не перевіряє стан.
Сусідній важіль — (throttling): панель мережі ріже за пресетами, які змінювалися між версіями браузера, тому для відтворюваних умов надійніше задати власний профіль із фіксованими значеннями; поруч живуть режим Offline і прапорець Disable cache. Різниця з паузою в обробнику принципова: пауза стосується одного запиту, тротлінг — усього каналу.
Наша практика (не канон). Наші тут три речі. Стисла формула гонки — «перемагає останній, що відповів, хоча має перемагати останній, що надіслали». Спостереження, що однакові миттєві моки гонку ховають, а різні затримки в обробниках роблять її відтворюваною, теж наше: джерело описує лише механіку обробника, а не цей ефект. І робоче правило про тротлінг: якщо тест падає на повільному профілі, він не «зафлакне колись на швидкому», він просто ще не впав.
Типові помилки
- «Маршрут зареєстровано, а запит усе одно пішов на сервер — баг інструмента». Насправді причин зазвичай дві: правило діє лише на запити, зроблені після реєстрації, або glob не збігся з усім URL, а неспійманий запит тихо йде на сервер без жодної помилки.
- «Мій мок у тесті не спрацював, хоча він точно є». Насправді його перекрив інший обробник на той самий шаблон: спрацьовують вони у зворотному до реєстрації порядку, і виграє останній зареєстрований.
- «Ми підмінили
Cookieв запиті — авторизація тепер інша». Насправді заборонені заголовки при перевизначенні ігноруються, і на сервер піде оригінальний. - «Змінили URL у
continue— редирект теж піде на нову адресу». Насправдіheadersпереносяться на редиректи, аurl,methodіpostData— ні. - «
abortєabort, код не має значення». Насправді коди моделюють різні класи збою: в зʼєднанні, нерозвʼязане імʼя хоста, втрачений інтернет і скидання зʼєднання — чотири різні негативні сценарії. - «Мок крос-оріджин ендпоінта готовий, а тест падає з CORS». Насправді preflight
OPTIONS— окремий запит, і мокнута відповідь без заголовківAccess-Control-*справжній запит не пустить. - «У запиті поїхало
?q=search+resultsзамість%20— застосунок псує значення». Насправді+і%20— дві різні угоди про пробіл; порівнювати треба розібрані параметри, а не сирий рядок. - «Набір зелений — інтеграція працює». Насправді зелений мок доводить згоду коду з моком: бекенд міг перейменувати поле ще півроку тому, і жоден мокнутий тест цього не побачить.
Підсумок
- Точка перехоплення — мережевий шар, а не код сторінки. Тому підміна не змінює гілку коду, яку виконує застосунок, і однаково покриває
XMLHttpRequest,fetchі WebSocket. - Модель одна: шаблон URL, обробник, одне з трьох рішень — пропустити, підмінити, обірвати. Усе решта в інструментах — синтаксис навколо цієї моделі.
- Дві найчастіші причини «мок не спрацював» — час і шаблон. Правило діє лише на запити після реєстрації, а glob звіряється з усім URL, тож зіставляти краще розібрані компоненти адреси, а не рядок.
- Мокання прибирає недетермінізм разом із перевіркою інтеграції. Звідси нормативна межа: має бути хоча б один тест без дублерів, а від дрейфу контракту страхує окремий шар контрактних перевірок.
- Затримка й тротлінг — різні важелі. Пауза в обробнику сповільнює один запит, тротлінг ріже весь канал; перше відповідає на «що буде, якщо цей ендпоінт гальмує», друге — на «як застосунок живе в поганій мережі».
Можливі питання
- «Що таке перехоплення мережі і на якому рівні воно працює?» Чекають, що кандидат назве рівень: не код сторінки, а мережевий шар усередині браузера, тож застосунок про підміну не знає. Сильна відповідь додає охоплення — XHR,
fetchі WebSocket однаково. - «Які рішення може ухвалити обробник перехопленого запиту?» Перевіряють, чи є в голові модель, а не завчений синтаксис: пропустити зі змінами, підмінити без звернення до сервера, обірвати мережевою помилкою — і типове застосування кожного.
- «Тест із моком падає: запит пішов на реальний сервер. Де шукати?» Класика на діагностику. Дві причини — маршрут зареєстровано після дії, або шаблон не збігся з усім URL; перевіряють, чи згадає кандидат вибірковість перехоплення, через яку неспійманий запит іде на сервер мовчки.
- «Як відтворити помилку сервера, повільну відповідь і обрив зʼєднання?» Три техніки — три рішення обробника. Хороший кандидат окремо зазначить, що коди обриву не взаємозамінні, бо моделюють різні збої.
- «Чого замокана відповідь перевірити не може?» Питання на зрілість. Чекають дрейф контракту й «зелений мок доводить згоду коду з моком»; сильна відповідь називає страховку — контрактні тести й хоча б один тест без дублерів.
- «Як тестувати вхід через Google?» Дивляться, чи розуміє кандидат межу відповідальності: чужого провайдера не тестують, потік мокають або піднімають тестовий сервер ідентичності, а обмін коду на токен іде сервер-до-сервера й браузерним маршрутом не перехоплюється.
- «Чим затримка в обробнику відрізняється від тротлінгу?» Коротке питання з подвійним дном: перше — один запит, друге — увесь канал; звідси й різні питання, на які вони відповідають.
Джерела
- Chrome for Developers — Inside look at modern web browser (part 1) — браузер як набір підсистем, а не одна програма.
- Chrome for Developers — Inside look at modern web browser (part 3) — мережевий шар як окрема підсистема, якою тест маніпулює ззовні.
- Playwright — Network — спостереження й зміна трафіку браузера, охоплення XHR і
fetch, маршрут на рівні контексту чи сторінки, й підміна WebSocket. - Playwright — Mock APIs — підміна відповіді без окремої інфраструктури.
- mitmproxy — How mitmproxy works — перехоплення як вклинювання посередника в обмін.
Модель: шаблон, обробник, три рішення
- Playwright — Network — три рішення обробника, вибірковість перехоплення, вимога збігу glob із усім URL і регулярний вираз для складніших умов, дія контекстного маршруту на попапи.
- Playwright API — class: Route — зворотний до реєстрації порядок обробників, різниця
route.fallback()іroute.continue(). - Playwright — class Page — маршрут діє лише на запити, зроблені після його реєстрації.
Чотири сценарії: підміна, затримка, помилка, обрив
- Playwright — Mock APIs — повний стаб без звернення до API і режим «виконати справжній запит і пропатчити відповідь».
- Playwright — Network — підміна на основі справжньої відповіді, отриманої окремим запитом.
- Playwright API — class: Route — закритий перелік кодів обриву з описом кожного, перенесення
headersна редиректи й ігнорування заборонених заголовків.
- Playwright — Mock APIs — повний стаб мережі як спосіб отримати детерміновані дані без звернення до API.
- Martin Fowler — Mocks Aren't Stubs — дублер як спосіб прибрати залежність тесту від реальної системи.
Стаб чи мок: що саме ви ставите
- xUnit Test Patterns — Test Double — дублер повторює API, а не поведінку; стаб як точка керування, спай як стаб із записом, мок як специфікація очікуваних викликів.
- Martin Fowler — Mocks Aren't Stubs — стаб віддає заготовлені відповіді, мок несе заздалегідь задані очікування.
- ISTQB® Certified Tester Foundation Level Syllabus v4.0.1 — §1.4.3: стаби, драйвери, симулятори й віртуалізація сервісів як елементи тестового оточення (таксономії дублерів силабус не дає).
Межа техніки: чого мок не перевіряє
- Ian Robinson — Consumer-Driven Contracts: A Service Evolution Pattern — дрейф контракту між і постачальником як ціна підміни реального бекенда.
- Pact — офіційна документація (Introduction) — контрактне тестування як звірка кожного застосунку в ізоляції та перевірка дублерів проти реальної поведінки.
- xUnit Test Patterns — Test Double — вимога мати хоча б один тест без дублерів і переспецифікація як причина крихких тестів.
Спершу подивіться фактичний запит
- Chrome DevTools — Network features reference — запис журналу лише за відкритих інструментів, властивості фільтра,
ANDбезORі прапорецьInvert, вкладкиPayloadіResponse, експорт та імпорт HAR і його санітизація. - Playwright — Network — записаний HAR як джерело підмінених відповідей у режимі запису-відтворення.
Зіставлення маршруту: URL і відсоткове кодування
- RFC 3986 — URI Generic Syntax — компоненти URL, фрагмент дереференсує лише клієнт, на сервер ідуть authority, шлях і запит; механізм відсоткового кодування й регістронезалежність його цифр.
- WHATWG URL Standard — рівність URL за серіалізованими формами, опускання типових портів, пробіл як
+уapplication/x-www-form-urlencoded, розбіжністьURLіsearchParams. - MDN — URL (Web API) — розбір адреси й читання параметрів запиту замість роботи з сирим рядком.
- MDN — encodeURIComponent() — три інструменти JavaScript із трьома різними результатами для того самого значення.
- MDN — encodeURI() — перелік символів, які
encodeURIне кодує, і правило вибору між ним іencodeURIComponent. - Playwright — Network — зіставлення перехоплених запитів за розібраним URL.
- MDN — Cross-Origin Resource Sharing (CORS) — заголовки як інструкція браузеру, preflight методом
OPTIONSі його заголовки,application/jsonпоза безпечним переліком, два записи на один логічний запит. - WHATWG Fetch Standard — умови, за яких запит не потребує preflight, і заборона надсилати preflight з обліковими даними.
- Playwright — Mock APIs — підміна крос-оріджин відповіді разом із її заголовками.
- Playwright — class APIRequestContext — програмний HTTP-клієнт поза браузерним контекстом і його межі.
- MDN — Same-origin policy — простий запит сервер виконує повністю, навіть коли браузер не віддає відповідь скрипту.
Посередник як інша точка того самого
- RFC 9110 — HTTP Semantics — три форми посередника й визначальна ознака кожної,
interception proxyяк вузол, якого клієнт не обирав, різниця502/504і503. - mitmproxy — How mitmproxy works — інспекція й зміна трафіку посередником, межа звичайного проксі на
CONNECTі потреба власного сертифіката для HTTPS. - Playwright — Network — запуск сторінок через проксі глобально або на контекст, автентифікація й хости-винятки.
OAuth 2.0: потік, який мокають цілком
- RFC 6749 — The OAuth 2.0 Authorization Framework — фреймворк делегованого доступу, чотири ролі, потік з кодом авторизації й обмін коду на токен сервер-до-сервера.
- RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage — предʼявлення токена в заголовку
Authorizationзі схемоюBearer. - OpenID Connect Core 1.0 — шар автентифікації над OAuth 2.0, на який спирається «Вхід через…».
- Playwright — Mock APIs — підміна відповідей провайдера на рівні мережі.
- Playwright — Authentication — отримання сесії поза UI, щоб не проганяти логін у кожному тесті.
Гонка станів, затримки й тротлінг
- MDN — JavaScript execution model — завдання обробляється повністю до наступного, звідки й береться недетермінований порядок завершення операцій.
- MDN — Using the Fetch API — скасування застарілого запиту через
AbortController. - Selenium — Waiting Strategies — фіксована пауза падає, коли спить недостатньо довго, і механізми синхронізації, які дока називає кращими.
- Playwright — Auto-waiting (actionability) — як штатний спосіб гасити частину гонок.
- Playwright — Network — очікування конкретної мережевої відповіді замість фіксованої паузи.
- Chrome DevTools — Network features reference — меню тротлінгу з пресетами, власні профілі, режим Offline і прапорець
Disable cache.
Що таке перехоплення мережі і навіщо воно автотесту?
(network interception) — це здатність тесту стати між сторінкою і зовнішнім світом та особисто розпорядитися кожним запитом: віддати його серверу, відповісти замість сервера або не дати зʼєднанню відбутися взагалі. Окремий випадок цієї техніки — (mocking), коли тест підставляє власну відповідь. Потреба виростає з простої арифметики: типовий екран стукає в мережу десяток разів — профіль, замовлення, , аналітика, — і кожен такий похід додає свій відсоток випадкового падіння. Коли API відповідає повільніше звичного, перетасовує порядок елементів чи віддає пʼятисотку, тест червоніє, хоча фронтенд бездоганний. Перехоплення викреслює чужу систему зі списку змінних: результат прогону починає залежати від коду сторінки, а не від самопочуття бекенда.
На якому рівні працює перехоплення і чому це принципово?
Керування відбувається в тій підсистемі браузера, що відповідає за мережу, а не в коді сторінки. Браузер узагалі не монолітна програма: одна його частина будує й малює документ, друга крутить JavaScript, третя веде обмін із мережею. Тест керує третьою — ззовні, не торкаючись ані застосунку, ані DOM. Звідси головний виграш: продукт не має жодного способу дізнатися, що відповідь рукотворна, тож виконує ту саму гілку логіки, яка поїде до користувачів, а не спеціально написану тестову. Другий виграш — охоплення: під контроль потрапляє все, що сторінка відправляє, незалежно від того, зроблено запит через XMLHttpRequest чи через fetch; WebSocket інструменти теж уміють читати й підміняти. Піднімати щось окремо під це не доводиться — досить оголосити обробник маршруту (route) на весь або звузити його до однієї сторінки.
Які рішення може ухвалити обробник перехопленого запиту?
Модель не залежить від інструмента: тест реєструє правило «запити за таким шаблоном адреси віддавай ось цьому обробнику», а обробник вибирає один із трьох варіантів. Пропустити (route.continue()) — запит рушає до справжнього сервера, і дорогою тест може переписати адресу, метод, заголовки чи тіло; так роблять логування й точкові правки. Підмінити (route.fulfill()) — сервера ніхто не турбує, браузеру віддають те, що зібрав тест; так подають дані, помилки й порожні стани. Обірвати (route.abort()) — запит гине мережевою помилкою, що зображає недоступний сервіс або зниклу мережу. Решта в API інструментів — синтаксична обгортка навколо цієї трійки, тому на співбесіді розумніше показати модель, ніж переказати сигнатуру виклику.
Чим повний стаб мережі відрізняється від часткової підміни відповіді?
Це два режими різної жорсткості. При повному звернення до API не трапляється взагалі: відповідь від першого до останнього байта вигадує тест. Мʼякший варіант — дати запиту дійти до сервера, забрати справжню відповідь і переписати в ній лише ті поля, які потрібні сценарію. Перший режим дає максимальний детермінізм і працює навіть тоді, коли ще не існує в природі; другий залишає дані живими — структуру відповіді віддає сервер, а тест перевизначає лише те, що потрібне сценарію. На практиці першим показують порожню видачу, гігантський список, роль адміністратора чи піднятий фіча-флаг — і все це без домовленостей із командою про особливий стан у базі; другим роблять точкові варіації поверх реальних даних.
Тест із моком червоний, бо запит пішов на реальний сервер. Де шукати причину?
Це класика на діагностику, і підозрюваних зазвичай двоє. Перший — момент: обробник накриває лише ті запити, які сторінка зробила пізніше за його оголошення, тож маршрут, зареєстрований після переходу або кліка, просто не встиг до потрібного запиту. Другий — шаблон: glob звіряється з адресою цілком, а не з її шматком, тому «майже правильний» шаблон не ловить нічого; коли умова складніша за просту маску, беруть . Підступність у тому, що правило працює вибірково, і запит, який не підійшов під жоден шаблон, іде в мережу без єдиного попередження в логах. Тому розслідування починають із журналу мережі, а не з коду тесту: там видно, чи запит узагалі відбувся і за якою саме адресою.
На один шаблон зареєстровано два обробники. Який спрацює?
Переможе той, що оголошений пізніше: обробники перебираються у зворотному до реєстрації порядку. Це не примха, а важіль, на якому будують зручну схему — широкий мок кладуть у налаштування прогону, вужчий пишуть у самому тесті, і він природно перебиває загальний. Передача керування далі по ланцюгу робиться явно: route.fallback() віддає запит наступному обробнику, тоді як route.continue() ланцюг завершує і відправляє запит у мережу. Звідси й вічна скарга «мій мок точно є, але його ніби нема» — зазвичай він не загубився, а виявився перекритим. Ще одна деталь, яку варто мати в голові: маршрут, повішений на контекст, накриває й попапи, тому глушити аналітику зручніше саме там, а не на кожній сторінці окремо.
Як відтворити повільну відповідь і навіщо це QA?
Механіка нехитра: обробник тримає паузу, а вже потім віддає керування далі — пропускає запит або підставляє відповідь. Цінність у тому, що короткоживучі стани інтерфейсу стають спостережуваними: індикатор завантаження, скелетон, кнопка, заблокована на час запиту. На бадьорому стенді вони встигають блимнути так, що до них не доїжджає, і дефекти в цій зоні спокійно потрапляють у прод. Улов у такої паузи впізнаваний: індикатор, який після відповіді залишається назавжди; кнопка, по якій встигають клацнути двічі й породити дубль замовлення; , виставлений із надмірним оптимізмом. Важливо не плутати цю паузу з фіксованим sleep у тілі тесту: тут є частиною сценарію на боці маршруту, а сам тест і далі синхронізується з подіями.
Чим затримка в обробнику відрізняється від тротлінгу?
Питання коротке, але з подвійним дном: різниця в масштабі важеля. Пауза стосується одного маршруту — ви заявляєте, що конкретний ендпоінт віддає відповідь через дві секунди, а вся інша мережа працює звично. (throttling) обрізає каналу цілком, тобто зображає не повільний сервіс, а кепський інтернет. Питання, на які вони відповідають, теж різні: перше — «як поводиться екран, коли гальмує саме цей запит», друге — «чи взагалі придатний застосунок на слабкому мобільному звʼязку». Пастка з тротлінгом: готові пресети в панелі мережі від версії до версії браузера змінювалися, тож заради повторюваності краще завести власний профіль із прописаними числами, ніж довіряти пресету за назвою. Поруч у тій самій панелі є перемикач Offline і галочка Disable cache.
Як змоделювати обрив зʼєднання і чи справді код помилки має значення?
Обрив дає route.abort(), і код помилки береться зі скінченного переліку — це не декоративний рядок. connectionrefused описує у встановленні зʼєднання; namenotresolved — провал розвʼязання імені хоста; internetdisconnected — втрату мережі як такої; connectionreset — скидання, тобто TCP RST посеред обміну. Перед вами чотири різні негативні сценарії, і продукт цілком може обробляти їх по-різному — окремим повідомленням, окремою логікою повтору чи взагалі мовчанням. Тест, що зводить усе до одного коду, покриває один шлях і створює ілюзію решти трьох. Помилку сервера, до речі, моделюють не обривом, а fulfill зі статусом і тілом: там відповідь є і її можна розібрати, а при обриві її немає взагалі.
Які заголовки не вийде підмінити в перехопленому запиті?
Службовий перелік — Cookie, Host, Content-Length і компанія — перевизначенню не піддається: ваше значення тихо відкинуть, а до сервера доїде оригінальне. З Cookie це особливо підступно, бо тест виглядає осмисленим і навіть зеленіє — от тільки зеленіє він на запиті, у якому нічого не змінилося, тобто перевіряє зовсім не той сценарій авторизації, який задумувався. Симптом упізнати легко: «ми підставили чужу сесію, а продукт поводиться так, ніби ми під своєю». Дорога до іншої сесії лежить не через маршрут, а через окремо підготовлений стан автентифікації, отриманий бекендом. Загальне правило перевірки просте: якщо підміна начебто застосувалася, а поведінка не змінилася, дивіться фактичний запит у панелі мережі, а не перечитуйте код тесту.
Змінили адресу в continue — чи піде редирект туди ж?
Ні, і це друга пастка при правці запиту. З усього, що можна перевизначити, на породжені редиректи переходять лише заголовки — саме вони застосовуються і до початкового запиту, і до наступних кроків ланцюжка. Нова адреса, метод і тіло далі першого кроку не подорожують. Тобто після першого ж 3xx ланцюжок піде туди, куди вкаже сервер, а не туди, куди його один раз спрямував тест. Практичний висновок: сценарії з редиректами звіряйте по фактичних записах у мережі, а не по припущенню «я ж переписав адресу». Коли потрібна стійка підміна кінцевої точки, надійніше або накрити маршрутом і адресу редиректу теж, або взагалі відповісти через fulfill, не даючи ланцюжку початися.
Стаб, спай і мок — у чому різниця і як вона виглядає в мережі?
Усі троє — дублери (test double), тобто підстановки, що заміняють залежність спеціально зробленим для тесту еквівалентом. Від дублера вимагається повторити інтерфейс залежності, а не її поведінку, причому реалізовувати інтерфейс повністю не обовʼязково — досить тієї частини, якої торкається конкретний тест. Різниця між різновидами не в «розумності», а в тому, де і коли відбувається перевірка. Стаб (stub) працює точкою керування: він проштовхує систему в ту гілку, куди звичайним шляхом не дістатися, і на все незапрограмоване зазвичай ніяк не відгукується. (spy) — той самий стаб, що додатково веде протокол отриманого, а звірка протоколу відкладається на потім. Мок (mock) контролює взаємодію просто в момент її перебігу і фактично сам виступає описом того, які виклики очікуються. У мережі обидва перші режими дає один обробник: віддали готове тіло через fulfill — це стаб; прочитали route.request().postDataJSON(), а після дії поставили асерт — уже спай.
Чого замокана відповідь перевірити не може?
Найголовнішого: чи справді ваш застосунок домовився з реальним бекендом. Замокавши все, ви перевіряєте власне уявлення про сервер, і найпідступніший наслідок цього — дрейф контракту, коли бекенд перейменував поле, продукт у проді розсипався, а далі світиться зеленим, бо підстановка про перейменування не в курсі. Зелений мокнутий тест засвідчує згоду коду з моком, і більше нічого. Страховка називається : кожен застосунок звіряють в ізоляції з тим спільним розумінням інтерфейсу, яке зафіксували документально, а один із поширених способів це зробити — переконатися, що ваші дублери віддають рівно те, що віддав би справжній сервіс. Друга страховка простіша: серед сценаріїв обовʼязково має бути такий, що ходить у живий бекенд без підстановок.
Чи є нормативна межа, скільки можна мокати?
Так, дві, і обидві сформульовані в першоджерелі таксономії дублерів. Перша: набір зобовʼязаний містити принаймні один сценарій, у якому підстановок немає — інакше ви перевіряєте конфігурацію, відмінну від тієї, що поїде до користувачів, і цю різницю нічим іншим не закрити. Друга стосується надмірності: коли дублерами зловживають, тести стають крихкими через переспецифікацію — звірка запиту до останнього поля розсипається від одного доданого аналітичного параметра, хоча продукт при цьому цілком живий. З цих меж і росте робоча політика: підміняти передусім чуже й некероване на кшталт платіжних і аналітики, не глушити без потреби власний основний API в наскрізних сценаріях, а працювати щоразу на найдрібнішому достатньому рівні — один ендпоінт замість усієї мережі.
Мок крос-оріджин ендпоінта готовий, а тест падає з CORS. Чому?
Тому що заголовки Access-Control-* адресовані браузеру, і підміна відповіді їх не скасовує. Причин падіння дві, часто обидві разом. Перша: одне логічне звернення на інше лишає в мережі два сліди — спершу preflight методом OPTIONS, у якому браузер попереджає про свої наміри через Access-Control-Request-Method і Access-Control-Request-Headers, і аж потім справжній запит. Preflight провокують тіло типу application/json, заголовок Authorization, а також методи PUT, PATCH і DELETE, тож шаблон, написаний під самий лише POST, OPTIONS не бачить. Друга: рукотворна відповідь на звернення зобовʼязана сама принести відповідні заголовки Access-Control-*, інакше браузер відкине її з тією ж рішучістю, з якою відкинув би відповідь справжнього сервера. Плюс окрема пастка з боку бекенда: облікові дані з preflight не відправляються ніколи, тому перевірка авторизації, яку сервер вішає на OPTIONS, завалює preflight власними руками. І два суміжні факти, які плутають: втілює браузер, а не протокол, тож програмним HTTP-клієнтом браузерну CORS-помилку не відтворити; а саме блокування зовсім не означає, що на сервері нічого не сталося — простий запит виконується там повністю, браузер лише не пускає відповідь до скрипту.
Асерт на параметр запиту червоний, хоча дані правильні. Що сталося?
Майже напевно ви звіряли текст адреси, а не витягнуте значення. Пробіл кодують двома різними способами залежно від контексту: у звичайній адресі це трійка %20, а у формі, серіалізованій як application/x-www-form-urlencoded, — знак +, тож справжній плюс доводиться там записувати як %2B. Найнеприємніше, що ця роздвоєність відтворена й усередині браузерного API: шлях в обʼєкті URL отримає %20, а серіалізація через searchParams дасть +. У JavaScript строкатість ще більша — encodeURIComponent дає %20 і не щадить &; encodeURI теж дає %20, але службові роздільники адреси лишає недоторканими; URLSearchParams пише +. Додайте сюди байдужість до регістру шістнадцяткових цифр, де %2F дорівнює %2f, — і стане ясно, чому текстове порівняння приречене. Робоче правило: розберіть адресу і звіряйте вже витягнуте значення, наприклад через searchParams.get('q'). Із того, що адреса — структура, а не рядок, ростуть ще три дрібниці: усе після # відрізається до звернення до ресурсу й у перехопленому запиті не зʼявиться; стандартні порти при серіалізації зникають, тож https://example.com:443/ дорівнює https://example.com/, а :8443 дає вже інше походження; рівними вважаються тільки ті адреси, чиї серіалізовані форми збіглися, тому «схожі на око» рядки зіставлення не проходять.
Як тестувати вхід через Google?
Тут перевіряють розуміння межі відповідальності, а не знання протоколу. OAuth 2.0 — фреймворк делегованого доступу з чотирма ролями: власник ресурсу, клієнт, сервер авторизації, сервер ресурсів. У потоці з кодом авторизації браузер спершу вирушає на ендпоінт авторизації, а вже потім клієнт обмінює отриманий код на токен, і цей обмін відбувається між серверами; далі отриманий токен подорожує в заголовку Authorization під схемою Bearer. Термінологічна деталь, яку люблять уточнювати: власне OAuth 2.0 займається авторизацією, а сценарій «Вхід через…» технічно спирається на надбудову . Звідси й відповідь: чужого провайдера ви не тестуєте — тестується ваш продукт і те, як він обробляє повернутий код чи токен. Провайдера або підставляють на мережевому рівні, або замінюють підняттям тестового сервера ідентичності; живий інтерфейс Google чи GitHub у прогоні на практиці відтворюється кепсько — провайдери протидіють автоматизації, і сценарій починає падати з причин, які до вашого продукту стосунку не мають. І одразу межа техніки: браузерний маршрут бачить редирект, повернення й код, а обмін коду на токен проходить повз браузер і перехопленню в тесті недоступний.
Чому однакові миттєві моки можуть сховати справжній баг?
Бо разом із випадковістю вони прибирають і той порядок подій, у якому баг живе. Гонка (race condition) — це коли підсумок залежить від того, яка з асинхронних операцій фінішувала першою, хоча черговість ніхто не гарантував; виникає вона не всередині завдання, а між завданнями, адже кожне завдання доводиться до кінця, перш ніж візьметься наступне. Замокавши три ендпоінти так, що всі відповідають одразу й однаково, ви фактично призначили черговість — і сценарій «повільна відповідь прилетіла після швидкої» більше не існує, хоча на живому бекенді трапляється регулярно. Дзеркальний прийом якраз і рятує: розставте обробникам різні паузи, і послідовність стане керованою та повторюваною в кожному прогоні. У коді таку гонку лікують перевіркою, чи відповідь ще актуальна: попередній запит скасовують через AbortController або просто ігнорують те, що прийшло із запізненням. У тесті ж синхронізуються з конкретною мережевою відповіддю, бо жорстка пауза гонку не усуває, а лише маскує — вона нічого не перевіряє.
Навіщо дивитися панель Network, якщо є документація API?
Бо мок пишуть під те, що браузер справді відправляє, а не під те, як мало би бути. Розбіжність із докою — буденність: зайвий параметр від аналітики, інший Content-Type, несподіваний редирект, окремий preflight. Кожен рядок панелі відповідає одному запиту сторінки, і хоча запис ведеться сам собою, він стартує лише з моменту, коли інструменти розробника відкриті, — тобто панель треба розкрити раніше, ніж ви відтворите сценарій, а не після падіння. Далі корисно знати фільтр: крім підрядка, він розуміє властивості на кшталт status-code:500, method:POST та domain:; складати їх можна тільки кон'юнктивно, а заперечення вмикається окремою галочкою Invert. Що саме поїхало в тілі запиту, показує вкладка Payload; що прийшло у відповіді — Response. З тієї ж панелі вивантажують HAR (HTTP Archive) — запис сесії у форматі JSON: його чіпляють до тікета, затягують назад у панель, щоб розібрати чужу сесію як власну, або використовують джерелом підмінених відповідей у режимі запису й відтворення. Типовий експорт очищений — заголовки Cookie, Set-Cookie й Authorization з нього прибирають.
Чим проксі відрізняється від шлюзу й тунелю і коли тестувальнику потрібен зовнішній посередник?
Форм посередника три, і різняться вони роллю. — агент пересилання, якого клієнт обирає сам. Шлюз (він же reverse proxy) для зовнішнього світу виглядає звичайним origin-сервером, а всередині роздає запити іншим вузлам. нічого в повідомленнях не змінює, працюючи сліпим ретранслятором, — саме через нього TLS проводять крізь корпоративний фаєрвольний проксі. Окремо стоїть interception proxy: його клієнт не обирав, і на рівні протоколу він нічим не відрізняється від на шляху — тобто це пряма протилежність інструмента, який тестувальник ставить собі свідомо. Оскільки посередник бачить увесь трафік, він дає той самий набір можливостей, що й маршрут у тесті: подивитися, підмінити, зобразити помилку, пригальмувати. Платити за це доводиться складністю з TLS: звичайний проксі шифрований потік не читає, адже CONNECT лише просить прокласти трубу, а щоб побачити вміст, інструмент має вдавати сервер перед клієнтом і клієнта перед сервером — а це вже вимагає, щоб його власному сертифікату довіряли. Потрібен зовнішній посередник тоді, коли перехоплення у фреймворку не дістає: нативний застосунок, стороннє SDK, трафік повз браузер. Дрібниця з тієї ж теми, що економить години розбору: 502 і 504 приходять від вузла, який працює шлюзом або проксі, тобто винуватця шукають далі по ланцюгу, а 503 каже, що сам відповідач зараз не в стані обробити звернення — посередника в цьому означенні немає.
Три кейси, у яких перехоплення вирішує, буде тест діагностичним інструментом чи джерелом власного : розбір падіння «мок є, а запит пішов на сервер», чотири негативні сценарії одного екрана в Playwright і , де спотикається об preflight. Скрізь — що робити і чому саме так.
Кейс 1. «Маршрут зареєстровано, а мок не спрацював»
Тест підміняє список замовлень порожнім масивом, щоб перевірити порожній стан. У прогоні екран показує справжні замовлення зі стенда, червоний. Перша реакція — «баг інструмента»; правильна — відкрити журнал мережі й подивитися, чи запит взагалі був спійманий.
Ось код, який виглядає нормальним, але не працює:
test('порожній стан списку замовлень', async ({ page }) => {
await page.goto('/orders'); // запит уже пішов
await page.route('**/api/orders', async (route) => { // правило спізнилося
await route.fulfill({ status: 200, body: '[]' });
});
await expect(page.getByText('Замовлень поки немає')).toBeVisible();
});
Робочий варіант відрізняється порядком двох рядків і точністю шаблона:
test('порожній стан списку замовлень', async ({ page }) => {
await page.route('**/api/orders**', async (route) => { // спершу маршрут
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ orders: [], total: 0 }),
});
});
await page.goto('/orders'); // і лише потім дія
await expect(page.getByText('Замовлень поки немає')).toBeVisible();
await page.unroute('**/api/orders**'); // прибираємо за собою
});
Коли симптом уже є, швидше йти за таблицею, ніж перечитувати код:
| Що бачите | Найімовірніша причина | Як перевірити за пів хвилини |
|---|---|---|
| Запит є в журналі мережі, прийшли справжні дані | Маршрут зареєстрований після дії | Перенести route вище за goto/клік і перезапустити |
| Запит є, дані справжні, порядок правильний | Glob не збігся з усією адресою | Скопіювати URL із панелі Network і звірити його з шаблоном цілком |
| Мок працює локально, у прогоні — ні | Правило перекрив інший обробник | Подивитися, чи є маршрут на той самий шаблон у налаштуваннях прогону |
| Підмінили заголовок, поведінка не змінилася | Заголовок із забороненого переліку | Порівняти фактичний заголовок у Payload з тим, що задав тест |
| Мок «залипає» в наступному тесті | Маршрут не знято після сценарію | Прибрати маршрут наприкінці або звузити його до одного контексту |
Що дивитися і чому:
- Тиша — це теж симптом. Перехоплення вибіркове, тож запит, який не підійшов під шаблон, іде на сервер без попереджень. Жодного «route not matched» у логах не буде — саме тому діагностику починають із журналу мережі, а не з коду тесту.
- Glob звіряється з усією адресою.
**/api/ordersне спіймає/api/orders?page=2, і саме на цьому найчастіше горять списки з . Хвостові**або розвʼязують проблему; для складних умов регулярка чесніша, бо видно, що саме зіставляється. - Останній зареєстрований виграє. Обробники викликаються у зворотному до реєстрації порядку. Це не баг, а важіль: широкий мок кладуть у налаштування прогону, вузький — у тест, і він природно перебиває загальний. Передати керування далі можна
route.fallback(), тоді якroute.continue()ланцюг зупиняє і відправляє запит у мережу. - Знятий маршрут — частина ізоляції стану. Залишене правило доживе до наступних тестів того самого й даватиме падіння, які «відтворюються тільки в CI».
Кейс 2. Чотири негативні сценарії одного екрана
Екран замовлень має чотири стани, які на живому стенді майже не спіймати: порожня видача, довге завантаження, помилка сервера й обрив мережі. Кожен дає окрема техніка — і всі чотири це той самий обробник маршруту.
import { test, expect, type Page } from '@playwright/test';
const ORDERS = '**/api/orders**';
test('спінер видно, поки відповідь у дорозі, і кнопка заблокована', async ({ page }) => {
await page.route(ORDERS, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000)); // пауза в обробнику
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ orders: [{ id: 1, total: 300 }] }),
});
});
await page.goto('/orders');
await expect(page.getByTestId('orders-spinner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Оновити' })).toBeDisabled();
await expect(page.getByTestId('orders-spinner')).toBeHidden(); // і зник
});
test('пʼятисотка показує повідомлення, а не порожній екран', async ({ page }) => {
await page.route(ORDERS, (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'internal' }),
}),
);
await page.goto('/orders');
await expect(page.getByRole('alert')).toContainText('Не вдалося завантажити');
await expect(page.getByRole('button', { name: 'Спробувати ще раз' })).toBeVisible();
});
test('сервіс недоступний і зникла мережа — різні повідомлення', async ({ page }) => {
await page.route(ORDERS, (route) => route.abort('connectionrefused'));
await page.goto('/orders');
await expect(page.getByRole('alert')).toContainText('Сервіс тимчасово недоступний');
await page.unroute(ORDERS);
await page.route(ORDERS, (route) => route.abort('internetdisconnected'));
await page.reload();
await expect(page.getByRole('alert')).toContainText('Немає підключення');
});
Мʼякший режим, коли вигадувати всю структуру відповіді не хочеться: беремо справжню відповідь і переписуємо в ній одне поле.
test('роль адміністратора без окремого користувача в базі', async ({ page }) => {
await page.route('**/api/profile', async (route) => {
// справжня відповідь через APIRequestContext, а не вигадана з нуля
const response = await page.request.get(route.request().url());
const profile = await response.json();
profile.role = 'admin'; // патчимо лише потрібне
await route.fulfill({ response, json: profile });
});
await page.goto('/settings');
await expect(page.getByTestId('admin-panel-link')).toBeVisible();
});
Що дивитися і чому:
- Пауза в обробнику — не
sleepу тесті. тримає маршрут як частину сценарію, а сам тест і далі синхронізується з подіями (спінер зʼявився, спінер зник). Фіксована пауза в тілі тесту нічого не перевіряє й лише маскує проблему. - Асерт «спінер зник» важливіший за «спінер зʼявився». Найчастіший дефект цієї зони — індикатор, який лишається назавжди після відповіді. Перевірка появи без перевірки зникнення його не бачить.
- Коди обриву не взаємозамінні.
connectionrefused— у зʼєднанні відмовлено,namenotresolved— імʼя хоста не розвʼязалося,internetdisconnected— інтернет-зʼєднання втрачено,connectionreset— скидання, що відповідає TCP RST. Якщо продукт має різні повідомлення для «сервіс лежить» і «немає мережі», тест на один код покриває половину. - Помилка сервера — це
fulfill, а неabort. Різниця принципова: при обриві відповіді немає взагалі, приfulfillвона є, має статус і тіло, і застосунок може її розібрати. Плутанина тут дає тест, який перевіряє не той шлях обробки. - Різні затримки роблять гонку відтворюваною. Якщо всі моки відповідають миттєво й однаково, ви самі призначили порядок завершення — і сценарій «повільна відповідь прийшла після швидкої» більше не існує. Дайте двом ендпоінтам різні паузи, і баг «перемагає останній, що відповів» стане стабільно ловимим.
Кейс 3. Крос-оріджин ендпоінт: preflight і спай на тілі запиту
Форма оформлення замовлення шле POST на API іншого . Мок на POST готовий, а тест падає з CORS-помилкою «якої не мало бути». Причина в тому, що один логічний запит дав два записи в мережі, і перший з них не спійманий.
test('оформлення замовлення: preflight + мок + перевірка тіла запиту', async ({ page }) => {
const captured: unknown[] = [];
await page.route('https://api.shop.test/v1/orders', async (route) => {
const request = route.request();
// 1) preflight — окремий запит методом OPTIONS
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://shop.test',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
return;
}
// 2) справжній запит: записуємо тіло (режим спая) і відповідаємо
captured.push(request.postDataJSON());
await route.fulfill({
status: 201,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': 'https://shop.test' },
body: JSON.stringify({ id: 'ord-42', status: 'created' }),
});
});
await page.goto('https://shop.test/checkout');
await page.getByRole('button', { name: 'Оформити' }).click();
await expect(page.getByTestId('order-number')).toHaveText('ord-42');
// асерт на взаємодію — після дії, і лише по полях, які справді важливі
expect(captured).toHaveLength(1);
expect(captured[0]).toMatchObject({ paymentMethod: 'card', currency: 'UAH' });
});
Окремий сюжет — зіставлення параметрів пошуку. Ось так робити не варто:
// крихко: залежить від того, хто і як закодував пробіл
expect(request.url()).toContain('?q=order+42');
А ось так надійно:
const params = new URL(request.url()).searchParams;
expect(params.get('q')).toBe('order 42'); // розібране значення, не сирий рядок
expect(params.get('page')).toBe('2'); // порядок параметрів більше не важить
Що дивитися і чому:
- Один логічний крос-оріджин запит — два записи. Спершу preflight методом
OPTIONS, у якому браузер заявляє намір черезAccess-Control-Request-MethodіAccess-Control-Request-Headers, і лише потім справжнійPOST. Тілоapplication/jsonpreflight спричиняє, як і заголовокAuthorizationта методиPUT,PATCH,DELETE. Шаблон, написаний під самий лишеPOST,OPTIONSне ловить — звідси й падіння на порожньому місці. - Підміна не скасовує CORS. Рукотворна відповідь зобовʼязана сама принести відповідні заголовки
Access-Control-*— без них браузер відхилить її так само рішуче, як відхилив би відповідь сервера. Заголовки — інструкція для браузера, і те, що відповідь рукотворна, для нього нічого не змінює. - Перевірка авторизації на
OPTIONSзавалює preflight. Облікові дані з preflight не відправляються ніколи, тож обробник, який вимагає токен, відхилить його власними руками — і в моці, і на справжньому сервері. - Записувати тіло — це , а не мок-очікування. Асерт стоїть після дії й перевіряє лише ті поля, які справді визначають сценарій. Мок, що звіряє запит до останнього поля, розсиплеться від одного доданого аналітичного параметра, хоча продукт при цьому цілком справний.
- Порівнюйте розібрані значення. Пробіл має дві угоди —
%20у звичайному URL і+вapplication/x-www-form-urlencoded, — тож текстове порівняння адреси залежить від того, який інструмент кодував значення. Розбір черезURLзнімає і це питання, і питання порядку параметрів. - Зелений набір із моками не доводить, що інтеграція жива. Він доводить згоду коду з моком. Від дрейфу контракту страхують окремі контрактні перевірки й обовʼязково хоча б один сценарій, що ходить у справжній бекенд без жодних дублерів.
Точка перехоплення й модель
- Розумію, що перехоплення (network interception) працює в мережевій підсистемі браузера, а не в коді сторінки: продукт виконує ту саму гілку логіки, що поїде до користувачів, а під маршрут потрапляє все, що шле сторінка — і через
XMLHttpRequest, і черезfetch, і по WebSocket. - Можу назвати три рішення обробника — пропустити (
continue), підмінити (fulfill), обірвати (abort) — і памʼятаю, що маршрут на рівні контексту накриває ще й попапи. - Знаю різницю між повним (звернення до API немає взагалі) і мʼякшим режимом, коли справжню відповідь забирають і переписують у ній лише потрібні поля.
Чому мок не спрацював
- Памʼятаю, що обробник накриває тільки той трафік, який виник пізніше за його оголошення, тож маршрут реєструють до дії, а не після неї.
- Знаю, що glob звіряється з адресою цілком, а не з її шматком, і що для складнішої умови беруть .
- Розумію вибірковість правила: запит, який не підійшов під шаблон, іде в мережу без єдиного попередження в логах.
- Знаю, що при кількох обробниках на один шаблон перебір іде у зворотному до реєстрації порядку, а
route.fallback()віддає запит далі по ланцюгу, тоді якroute.continue()ланцюг завершує. - Розумію, чому маршрут знімають після сценарію: не знятий, він далі ловить трафік сусідніх тестів того ж , а це прямий шлях до плаваючих падінь.
Сценарії й пастки підміни запиту
- Можу відтворити повільну відповідь паузою в обробнику, назвати її улов (залиплий індикатор, подвійний клік, надто оптимістичний ) і пояснити різницю vs : перша стосується одного маршруту, другий обрізає канал цілком.
- Розумію, що коди
abortне синоніми (connectionrefused,namenotresolved,internetdisconnected,connectionreset) і що помилку сервера моделюють не обривом, аfulfillзі статусом і тілом. - Памʼятаю службовий перелік (
Cookie,Host,Content-Lengthі компанія), який перевизначенню не піддається, і те, що з правок запиту на породжені редиректи переходять лише заголовки.
Дублери й межа техніки
- Знаю різницю стаб vs vs і можу показати перші два на маршруті:
fulfillіз готовим тілом — точка керування, читанняroute.request().postDataJSON()з після дії — запис. - Можу пояснити дрейф контракту (бекенд перейменував поле, продукт зламався, а на підстановках зелена) і назвати дві нормативні межі: принаймні один сценарій має обходитися без підстановок узагалі, а переспецифікована звірка розсипається від одного зайвого параметра.
URL, кодування й CORS
- Розумію, що звіряти треба витягнуті компоненти адреси, а не її текст: пробіл дає
%20у звичайному URL і+вapplication/x-www-form-urlencoded, тож підсумок залежить від того, чий інструмент кодував значення. - Знаю, що одне звернення лишає в мережі два сліди, і шаблон під самий лише
POSTpreflightOPTIONSне бачить (а спричиняють йогоapplication/json,Authorization,PUT,PATCH,DELETE). - Розумію, що рукотворна крос-оріджин відповідь зобовʼязана сама принести заголовки
Access-Control-*, і що облікові дані з preflight не відправляються ніколи. - Не плутаю рівні: програмний HTTP-клієнт не втілює, тож браузерну CORS-помилку ним не відтворити, а блокування з боку CORS не означає, що дані на сервері лишилися незмінними.
Діагностика й гонки
- Знаю, що запис у панелі Network стартує з моменту відкриття інструментів розробника, тож розкриваю панель раніше, ніж відтворюю сценарій, а вивантажений HAR умію прикласти до тікета й затягнути назад у панель.
- Розумію, що однакові миттєві моки ховають гонку, різні затримки роблять її повторюваною, а синхронізуватися треба з конкретною мережевою відповіддю, бо жорстка пауза нічого не перевіряє.
- Можу пояснити, чому OAuth-провайдера підміняють цілком і чому обмін коду на токен браузерним маршрутом не перехопити — він іде між серверами, повз браузер.
Квіз
Перед стартом
- Питань: 17
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Перехоплення мережі (network interception) — що це одним реченням?

