📖 Про цей гайд
Для кого
Для QA інженерів які готуються до технічної співбесіди на позицію з Playwright. Підходить якщо ти вже знаєш основи і хочеш систематизувати знання — від базових концептів до архітектурних рішень Senior рівня.
Як користуватись
Фільтруй питання по рівню — Junior, Middle або Senior. Відкривай картку, читай концепт і код. Потім закрий код і прочитай вголос з блоку 🗣 Як читати вголос — це тренує відповідь в живій розмові, а не тільки розуміння очима.
Три базові якорі — з них випливає більшість відповідей
ЯКІР 1 · Ізоляція
Browser → Context → Page. Кожен тест в своєму контексті зі своїми cookies і станом.
ЯКІР 2 · Auto-waiting
Locator — це рецепт, не посилання. Дії самі чекають готовності елемента.
ЯКІР 3 · Явність
Strict mode, теги, явний стан. Явне завжди краще за неявне.
питань
3
рівні
voice-блоки
TypeScript
e-commerce
📚 10 РОЗДІЛІВ
Junior
Middle
Senior
🧭 3 базові якорі — з них випливає все інше
Якір 1 — Ізоляція: Browser → Context → Page → Worker

Уяви, що у тебе є фізичний комп'ютер з браузером. Ти відкриваєш Chrome — це Browser. Всередині Chrome ти можеш відкрити звичайне вікно і окреме вікно "інкогніто" — вони не бачать cookies і стан одне одного. Ось це окреме вікно — це BrowserContext. А конкретна вкладка всередині цього вікна — це Page.

Playwright для кожного тесту автоматично створює новий BrowserContext (своє "інкогніто-вікно") і всередині нього одну Page. Тому тест А, який залогінився під admin, ніяк не впливає на тест Б, який тестує гостьовий режим — у них фізично різні "браузерні сесії", різні cookies, різний localStorage.

Окремий шар — Worker. Коли Playwright запускає тести паралельно, він не просто паралелить вкладки — він запускає окремі Node.js процеси (workers). Це як відкрити два абсолютно різних Chrome на двох різних комп'ютерах. Тому змінна let count = 0 в одному файлі існує тільки в пам'яті свого процесу і є абсолютно недосяжною з іншого.

З цього випливає все: чому fixtures (Playwright сам збирає ізольовану структуру і передає готовою), чому storageState (щоб пронести залогіненість "через" ізоляцію, треба фізично зберегти сесію у файл), чому глобальна змінна не шариться між файлами (окремі Node-процеси, окрема пам'ять), чому describe.serial() — навмисний виняток, де ми свідомо ламаємо ізоляцію заради залежного флоу.

Якір 2 — Auto-waiting: Locator це рецепт, не посилання

Коли ти пишеш page.locator('.buy-button') — Playwright не виконує DOM lookup в цей момент. Він запам'ятовує інструкцію "знайди елемент з класом buy-button". DOM lookup виконується тільки під час операції — коли ти викликаєш дію: .click(), .fill(), .toBeVisible(). І щоразу — заново.

Чому це важливо? Тому що сторінка жива: React може перерендерити список, DOM може змінитись між рядками твого тесту. Якби Playwright знайшов елемент одразу і зберіг посилання на конкретний DOM-вузол (як це робить ElementHandle) — після перерендеру це посилання б "протухло" і вказувало б на вузол, якого вже немає. Locator цієї проблеми позбавлений, бо знаходить елемент заново прямо перед дією.

Більше того — прямо перед тим як виконати дію, Playwright сам чекає, поки елемент стане готовим: видимим, не перекритим іншим елементом, не заблокованим, стабільним (не анімується). Це і є auto-waiting — ти не пишеш жодного wait, а Playwright просто не виконує дію, поки умови не виконані.

З цього випливає: waitForTimeout(2000) — антипатерн, бо чекаєш час "на удачу" замість конкретного стану. expect(locator).toBeVisible() — web-first assertion, яка сама ретраїться, на відміну від expect(await locator.isVisible()).toBe(true), де ти знімаєш знімок стану і перевіряєш застиглий true/false без жодних ретраїв. networkidle — теж антипатерн: чекаємо абстрактну "тишу в мережі" замість конкретного елемента чи відповіді API.

Якір 3 — Явність замість магії

Playwright як фреймворк постійно робить один і той самий вибір: краще явна помилка зараз, ніж прихована проблема пізніше. Це філософія, а не технічна деталь.

Strict mode: Selenium, якщо знайшов 10 кнопок за твоїм селектором, мовчки бере першу і йде далі. Тест "зелений", але ти клікнула не туди. Playwright замість цього одразу падає з помилкою "знайдено 12 елементів" — змушує тебе бути конкретною про те, який саме елемент ти маєш на увазі.

Теги @smoke/@critical/@regression: замість того щоб магічно вирішувати "які тести запустити зараз", ти явно позначаєш кожен тест і явно кажеш в CI команді --grep @smoke. Нічого неявного.

По суті всі три якорі — це одна думка з різних боків: Playwright будує систему, де кожна річ явна, ізольована і чекає правильного стану, а не покладається на удачу, таймінг чи поведінку "за замовчуванням". Як тільки ти це засвоїла — більшість питань на співбесіді перестають бути окремими фактами і стають очевидними наслідками цієї ідеї.

1 9
1 Що таке Playwright і чим він відрізняється від Selenium?
Основи Junior

Playwright — це open-source фреймворк від Microsoft для end-to-end тестування веб-застосунків у браузері. Підтримує Chromium, Firefox і WebKit в одному API. З'явився у 2020 і швидко став стандартом завдяки простоті і надійності.

Головна технічна різниця — спосіб керування браузером:

Selenium + WebDriver:

WebDriver — це окрема програма-посередник яку кожен виробник браузера пише для свого продукту: ChromeDriver пише Google, GeckoDriver пише Mozilla, SafariDriver вбудований Apple в macOS. Це офіційний стандарт W3C (World Wide Web Consortium — міжнародна організація що розробляє стандарти для вебу). ChromeDriver запускається як окремий процес на твоїй машині і слухає HTTP запити.

Твій тест → HTTP запит → ChromeDriver (окремий процес) → Chrome
                ↑
   кожна дія = окремий запит: відкрити → надіслати → отримати → закрити
   click → запит, fill → запит, screenshot → запит

Playwright + власні протоколи:

Playwright підключається до браузера напряму через постійне з'єднання — без окремого драйвера-посередника. Для Chromium це CDP (Chrome DevTools Protocol) — той самий протокол що використовують DevTools. Для Firefox і WebKit Playwright використовує власні протоколи, реалізовані в модифікованих збірках браузерів які він поставляє разом із собою. Жодного окремого драйвера — npx playwright install і браузери є.

Твій тест → WebSocket → Chromium (CDP)
Твій тест → власний протокол → Firefox (модифікована збірка)
Твій тест → власний протокол → WebKit (модифікована збірка)
                ↑
   одне постійне з'єднання на весь тест
   команди передаються без HTTP WebDriver stack — менше накладних витрат

CDP дає доступ до всіх внутрішніх можливостей браузера — перехоплення мережі, clock, геолокація — бо це той самий канал що й DevTools. WebDriver має тільки ті команди що описані в W3C (World Wide Web Consortium) стандарті.

Навіщо взагалі WebDriver якщо є CDP?

CDP — це внутрішній протокол Chrome/Chromium. Firefox і Safari його не підтримують нативно. W3C (World Wide Web Consortium) зробили WebDriver офіційним стандартом щоб будь-який інструмент міг керувати будь-яким браузером через один API. Playwright вирішив цю проблему по-своєму — власні модифіковані збірки браузерів з власними протоколами, що дає однаковий API для всіх трьох браузерів.

WebDriver BiDi — варто знати для інтерв'ю: Selenium поступово переходить на WebDriver BiDi (Bidirectional) — новий стандарт W3C який дозволяє двосторонню комунікацію як у CDP. Це наближає Selenium до можливостей Playwright. Але поки що це в процесі розробки.

Хто використовує WebDriver сьогодні: Selenium (мільйони корпоративних тестів написані роками), Appium (мобільне тестування Android/iOS), WebdriverIO, і хмарні сервіси BrowserStack та Sauce Labs — їхня інфраструктура побудована на WebDriver стандарті. Це як USB-A: є новіший USB-C — швидший і потужніший — але USB-A досі скрізь бо мільярди пристроїв його підтримують.

Практичні переваги Playwright:

  • Auto-waiting — не треба писати явні очікування, Playwright сам чекає поки елемент готовий
  • Network interception — вбудований перехват HTTP запитів, можна мокати відповіді
  • Ізоляція через BrowserContext — кожен тест в окремому "профілі" без впливу на інші
  • Trace Viewer — вбудований запис всіх дій з скріншотами і мережевими запитами для дебагу
🔧 TypeScript / JS патерни в цьому прикладі Теоретична картка без коду. Ключові терміни: protocol — набір правил обміну даними між програмами; HTTP request — одноразовий запит-відповідь; WebSocket — постійне з'єднання (детально в Q2-Q3); headless — браузер без графічного інтерфейсу, стандарт для CI.
🗣 Як читати вголос Playwright — це фреймворк від Microsoft для end-to-end тестування веб-застосунків у браузері, підтримує Chromium, Firefox і WebKit в одному API. Головна технічна відмінність від Selenium — протокол керування браузером і наявність посередника. У Selenium між тестом і браузером стоїть WebDriver — окрема програма яку кожен виробник браузера пише для свого продукту: Google пише ChromeDriver, Mozilla пише GeckoDriver. Кожна команда — це окремий HTTP запит. Playwright підключається до браузера напряму через постійне з'єднання без посередника. Для Chromium це CDP — Chrome DevTools Protocol — той самий протокол що використовують DevTools. Для Firefox і WebKit Playwright використовує власні протоколи реалізовані в модифікованих збірках браузерів які він поставляє разом із собою. Варто знати про WebDriver BiDi — новий стандарт W3C який дозволяє двосторонню комунікацію як у CDP — Selenium поступово на нього переходить. Практично переваги Playwright: авто-очікування, вбудований перехват мережі, ізоляція через BrowserContext і trace viewer для дебагу.
2 Playwright vs Playwright Test — чим бібліотека відрізняється від test runner?
Основи Middle

Уяви, що Playwright Test ще не існує — є тільки сама бібліотека playwright:

import { chromium } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');

Це не тест — це просто Node.js-програма що відкриває браузер. На цьому рівні Playwright — бібліотека автоматизації браузера, як Express для HTTP чи React для UI. Вона нічого не знає про test(), expect(), beforeEach, retries чи паралельний запуск.

Це і є причина плутанини: у npm справді існують два окремі пакети. playwright — бібліотека автоматизації браузера (Browser, BrowserContext, Page, Locator). @playwright/test — test runner, побудований поверх цієї бібліотеки: додає test(), expect(), fixtures, hooks, workers, retries, projects, reporters. Коли встановлюєш npm init playwright@latest — отримуєш саме @playwright/test, який використовує automation API всередині себе.

┌─────────────────────────────┐
│       Playwright Test        │   test(), expect(), fixtures,
│                               │   hooks, workers, retries,
│                               │   projects, reporters
└──────────────┬────────────────┘
               │ використовує
               ▼
┌─────────────────────────────┐
│   Browser Automation API     │   BrowserType, Browser,
│                               │   BrowserContext, Page, Locator
└─────────────────────────────┘

Що насправді відбувається коли пишеш npx playwright test: спочатку запускається не браузер, а Test Runner — звичайна Node.js-програма. Вона читає playwright.config.ts, знаходить файли з тестами, завантажує їх щоб дізнатись які test(...) там оголошені, розподіляє роботу і тільки після цього створює workers, які вже виконують самі тести. На цьому етапі браузера може ще не існувати взагалі — він з'явиться лише коли якийсь тест реально попросить browser чи page.

"Завантажує" і "реєструє" — це два різні кроки, які легко сплутати:

  • Імпортує — Node.js виконує файл checkout.spec.ts від початку до кінця, як звичайний JS-файл. Усе на верхньому рівні файлу виконується одразу: імпорти, змінні, і кожен виклик test(...).
  • Реєструє — відбувається під час імпорту, коли виконання доходить до рядка test('назва', callback). Сам виклик test() НЕ запускає callback (тіло тесту з goto, click) — він лише додає запис у список Test Runner'а: "є тест з такою назвою, ось його код, запусти коли настане час".

Якщо в файлі 5 тестів — під час імпорту всі 5 викликів test(...) виконаються одразу (бо це просто виконання файлу), і всі 5 з'являться у списку зареєстрованих тестів. Але жоден з їхніх callback-ів ще не запустився.

Аналогія: імпорт файлу — це ніби читати зміст книги рецептів сторінка за сторінкою. Кожен test(...) — рядок у змісті: "Рецепт: Паста, дивись стор. 12". Ти вже знаєш що рецепт існує і де його шукати (зареєстрував), але поки що нічого не приготував (не виконав). Тільки після того як усі файли імпортовані й усі тести зареєстровані — Test Runner знає повний список того, що треба виконати, і роздає ці задачі по workers, де вже реально запускаються callback-и.

Ключовий інсайт: test('...', callback) нічого не виконує одразу. Він лише реєструє тест у Test Runner — приблизно як runner.addTest(callback). Сам callback запускається пізніше, коли Test Runner вирішить що час його виконувати.

Де живе Worker — він не частина Browser API
Playwright Test Runner
        │
        ├── Worker 1 (окремий Node.js процес)
        │        └── Browser (запускається лише якщо тест його попросив)
        │              └── BrowserContext (новий для кожного тесту)
        │                    └── Page
        │
        └── Worker 2 (окремий Node.js процес)
                 └── Browser
                       └── BrowserContext
                             └── Page

Worker — не частина браузерного API, його створює Test Runner для розподілу тестів по паралельних Node.js процесах. Вже всередині worker-процесу, коли тест попросить фікстуру browser, вона запускає браузер — і саме тому browser є worker-scoped fixture: один браузерний процес живе всередині одного worker, а не сам по собі.

Підсумок двома рівнями: Рівень 1 (Browser Automation API) відповідає на питання "як керувати браузером" — Browser, BrowserContext, Page, Locator. Рівень 2 (Playwright Test) відповідає на питання "як запускати, ізолювати, масштабувати й координувати тести" — test(), fixtures, workers, retries, projects. Тест не керує браузером напряму — він лише описує що треба зробити, а всю координацію бере на себе Test Runner.

🔧 TypeScript / JS патерни в цьому прикладі import { chromium } from 'playwright' — імпорт з базової бібліотеки (не з @playwright/test); test('...', async () => {}) — виклик функції що реєструє callback, а не виконує його одразу; сам callback — це той самий патерн callback з попередньої картки, тільки викликає його не браузерна подія, а Test Runner коли настає час.
🗣 Як читати вголос Плутанина між Playwright і Playwright Test — одна з тих речей яка заважає зрозуміти fixtures і workers, поки її не проясниш. Playwright — це бібліотека автоматизації браузера, вона просто вміє відкривати сторінки і клікати по елементах, і нічого не знає про тести. Playwright Test — окремий пакет, test runner, побудований поверх цієї бібліотеки: він додає test, expect, fixtures, workers, retries. Коли запускаєш npx playwright test, спочатку піднімається не браузер, а Test Runner — звичайна Node.js програма, яка читає конфіг, знаходить спек-файли, імпортує їх щоб зареєструвати всі test, і тільки потім створює workers для паралельного виконання. І ось найважливіше: сам виклик test з назвою і колбеком нічого не виконує в момент виклику — він просто каже Test Runner "запам'ятай цей тест", а реальне виконання відкладається на потім. Worker теж не має прямого стосунку до браузерного API — це окремий Node.js процес, який створює Test Runner для паралелізму, і вже всередині цього процесу, коли конкретний тест попросить фікстуру browser, тільки тоді запускається сам браузер. Тому і виходить що тест не керує браузером напряму — він лише описує що потрібно зробити, а вся координація лежить на Test Runner.
3 Що таке Node.js і WebSocket і як вони пов'язані з тим як Playwright керує браузером?
ОсновиJunior

Node.js — це середовище де може виконуватись JavaScript поза браузером.

Спочатку JavaScript існував тільки в браузері — міг маніпулювати DOM, реагувати на кліки, відправляти запити. Але не міг читати файли з диску, запускати сервер, чи взаємодіяти з операційною системою. Node.js змінив це у 2009 — взяв JavaScript рушій з Chrome (V8) і запустив його окремо як самостійну програму на комп'ютері.

Тепер JavaScript може робити все що раніше робили тільки серверні мови: читати і писати файли, запускати HTTP сервер, підключатись до бази даних, запускати тести.

Браузер — середовище де є window, document, DOM, localStorage
Node.js — середовище де є файлова система, мережа, процеси ОС, npm пакети
Один і той самий JavaScript — два різних середовища виконання

WebSocket — це постійне двостороннє з'єднання між двома програмами.

Звичайний HTTP запит: клієнт питає → сервер відповідає → з'єднання закривається. Як SMS — відправив, отримав відповідь, все. WebSocket інакший: з'єднання відкривається один раз і лишається відкритим. Обидві сторони можуть надсилати повідомлення одна одній в будь-який момент. Як телефонна розмова — лінія відкрита, говорять обидва.

Як це пов'язано з Playwright: коли ти запускаєш тест — він виконується в Node.js. Playwright відкриває WebSocket з'єднання з браузером один раз на початку і тримає його відкритим весь тест. Через цей канал Node.js надсилає команди браузеру ("клікни тут", "заповни це поле", "виконай цю функцію") і отримує результати назад. Саме тому змінні з Node.js недоступні в браузері — це фізично різні процеси, між ними тільки WebSocket канал.

Схема: Node.js і браузер — два окремих світи
// ══════════════════════════════════════════════════════════════════
// Node.js процес (твій тест)        Браузер (окремий процес)
// ══════════════════════════════════════════════════════════════════
//
// const expected = 48; ──────────── // expected тут не існує!
// const page = ...    ◄──WebSocket──► window.dataLayer = [...]
// await page.click()  ──"клікни"──► document.querySelector(...)  
// await page.evaluate() ──код──────► виконується тут
//                      ◄──результат─ повертається в Node.js
//
// Все що є в Node.js — недоступне в браузері і навпаки
// Єдиний міст — WebSocket, через який Playwright передає команди
🔧 TypeScript / JS патерни в цьому прикладі async function — функція яка може "чекати" (await) інші асинхронні операції; async/await — синтаксичний цукор над Promise, робить асинхронний код схожим на синхронний; { page } — деструктуризація об'єкта (object destructuring): витягуємо конкретне поле з об'єкта; стрілочна функція () => {} — скорочений синтаксис функції.
🗣 Як читати вголос Node.js — це середовище де JavaScript може виконуватись поза браузером: читати файли, запускати сервери, підключатись до баз даних. Саме в Node.js виконується твій Playwright тест — там живуть всі змінні, об'єкт page, функції expect. Браузер — це окремий процес де живе JavaScript сторінки: window, document, React, dataLayer. Це два різних середовища які не бачать одне одного напряму. Playwright з'єднує їх через WebSocket — це постійне двостороннє з'єднання яке відкривається один раз на початку тесту і тримається відкритим до кінця. Через цей канал Node.js надсилає браузеру команди і отримує результати назад. Коли ти передаєш функцію в page.waitForFunction() або page.evaluate() — Playwright бере текст цієї функції, відправляє через WebSocket в браузер, і браузер виконує її у себе. Але змінні з Node.js туди не переїжджають — це як скопіювати текст функції на інший комп'ютер, змінні з першого комп'ютера там не з'являться. Тому closure не працює через цю межу, і змінні треба явно передавати як аргументи — тоді Playwright серіалізує їх в JSON і відправить разом з функцією.
4 Що таке WebSocket і чим він відрізняється від звичайного HTTP запиту?
ОсновиJunior

Щоб зрозуміти WebSocket — спочатку треба зрозуміти як працює звичайний HTTP.

HTTP запит — як SMS:

Ти відправляєш повідомлення → сервер відповідає → з'єднання закривається. Щоразу коли треба щось отримати — треба відправити новий запит. Кожен запит це окреме з'єднання яке відкривається і закривається. Ефективно для сайтів де дані рідко змінюються — завантажив сторінку, все, чекати нічого.

WebSocket — як телефонна розмова:

З'єднання відкривається один раз і лишається відкритим. Обидві сторони можуть надсилати повідомлення одна одній в будь-який момент без нового запиту. Ефективно коли потрібен постійний обмін даними в реальному часі — чати, онлайн-ігри, фінансові тікери, і саме Playwright для керування браузером.

Чому Playwright використовує WebSocket: Playwright повинен постійно надсилати команди браузеру і отримувати результати. Якби використовував HTTP — кожна дія (click, fill, evaluate) потребувала б нового з'єднання. WebSocket відкривається один раз і через нього проходять всі команди протягом всього тесту — швидко і ефективно.

Схема: HTTP vs WebSocket
// ── HTTP — нове з'єднання на кожен запит ─────────────────────────
//
// Клієнт         Сервер
// ───────        ──────
// "GET /page" ──────►  з'єднання відкрилось
// ◄────── HTML         з'єднання закрилось
//
// "GET /api/data" ───► нове з'єднання
// ◄──── JSON           закрилось
//
// Кожен запит = відкрити + закрити з'єднання

// ── WebSocket — одне з'єднання на весь час ───────────────────────
//
// Клієнт         Сервер
// ───────        ──────
// "привіт" ────────►   з'єднання відкрилось — І ЛИШАЄТЬСЯ ВІДКРИТИМ
// ◄──── "привіт"
// "клікни .btn" ───►   (те саме з'єднання)
// ◄──── "ok"
// "заповни #input" ►   (те саме з'єднання)
// ◄──── "ok"
// ...                  з'єднання закривається тільки в кінці
Як WebSocket виглядає в Playwright — під капотом
// Ти пишеш звичайний код тесту:
await page.goto('/products');
await page.locator('.add-to-cart').click();
await page.fill('#promo', 'SAVE10');

// Під капотом Playwright надсилає через WebSocket команди браузеру:
// → { method: "Page.navigate", params: { url: "/products" } }
// ← { result: { frameId: "..." } }
//
// → { method: "DOM.querySelector", params: { selector: ".add-to-cart" } }
// ← { result: { nodeId: 42 } }
//
// → { method: "Input.dispatchMouseEvent", params: { type: "click", ... } }
// ← { result: {} }
//
// Всі ці повідомлення йдуть через ОДНЕ WebSocket з'єднання
// Це і є Chrome DevTools Protocol (CDP) — мова на якій Playwright говорить з браузером
TypeScript — WebSocket в тестах: page.on('websocket')
// Окрема тема: WebSocket на самій сторінці
// Якщо сторінка використовує WebSocket для реалтайм даних (чат, тікери) —
// Playwright дозволяє слухати ці з'єднання через page.on('websocket')

test('WebSocket отримує оновлення ціни', async ({ page }) => {
  const wsMessages: string[] = [];

  // Підписуємось на WebSocket з'єднання сторінки
  page.on('websocket', ws => {
    console.log('WS відкрито:', ws.url());

    // Слухаємо фрейми які приходять від сервера
    ws.on('framereceived', event => {
      wsMessages.push(event.payload as string);
    });

    ws.on('close', () => console.log('WS закрито'));
  });

  await page.goto('/prices');

  // Чекаємо поки прийде повідомлення про ціну
  // ✅ Правильно: wsMessages живе в Node.js — перевіряємо через expect.poll
  await expect.poll(() => wsMessages.length, { timeout: 10_000 })
    .toBeGreaterThan(0);
  // ❌ НЕ ПРАВИЛЬНО: window.wsMessages не існує в браузері!
  // await page.waitForFunction(() => (window as any).wsMessages?.length > 0);
  // wsMessages — це змінна Node.js, вона недоступна в контексті браузера

  // Перевіряємо вміст WebSocket повідомлення
  const priceUpdate = JSON.parse(wsMessages[0]);
  expect(priceUpdate).toHaveProperty('price');
});
💡 Два різних WebSocket в контексті Playwright:
1. WebSocket між Playwright і браузером — внутрішній, завжди є, через нього Playwright надсилає команди. Це Chrome DevTools Protocol (CDP). Ти його не бачиш, він просто існує під капотом.
2. WebSocket на самій сторінці — якщо сайт використовує WebSocket для реалтайм даних (чат, стрімінг цін). Цей можна слухати через page.on('websocket').
🔧 TypeScript / JS патерни в цьому прикладі page.on('event', callback) — патерн підписки на події (Observer/EventEmitter): передаємо callback-функцію яка викличеться коли подія відбудеться; ws => — стрілочна функція як callback; ws.on('framereceived', event => {}) — вкладені підписки на події; new DataTransfer() — створення екземпляру класу через new.
🗣 Як читати вголос WebSocket — це протокол для постійного двостороннього з'єднання між двома програмами. Відрізняється від звичайного HTTP тим що HTTP це запит-відповідь-закрито, кожна взаємодія це окреме з'єднання — як SMS. WebSocket відкривається один раз і лишається відкритим, обидві сторони можуть надсилати повідомлення в будь-який момент — як телефонна розмова. Playwright використовує WebSocket щоб керувати браузером: на початку тесту відкривається одне з'єднання через яке проходять всі команди — navigate, click, fill, evaluate — і всі відповіді від браузера. Це Chrome DevTools Protocol, CDP — мова на якій Playwright говорить з браузером. Це внутрішнє з'єднання яке ти не бачиш але воно є завжди. Окремо від цього, якщо сама сторінка яку ти тестуєш використовує WebSocket для реалтайм даних — наприклад чат або стрімінг цін — ти можеш слухати ці з'єднання через page.on('websocket') і перевіряти вміст повідомлень які приходять від сервера.
5 Що таке CDP (Chrome DevTools Protocol) і як він пов'язаний з Playwright?
ОсновиSenior

Щоб зрозуміти CDP — спочатку треба розділити два рівні які не мають між собою нічого спільного.

Рівень 1 — код аплікейшину (твій React/JS сайт)

Звичайний JavaScript який виконується всередині браузера. Взаємодіє з браузером через вбудовані браузерні API — document, window, fetch. Ніякого CDP тут немає.

Рівень 2 — CDP (Chrome DevTools Protocol)

Канал для зовнішніх програм які хочуть керувати браузером ззовні. Це набір JSON команд які летять через WebSocket між зовнішньою програмою і браузером. CDP — це "розетка" яку Chrome надає зовнішнім програмам. Підключитись може будь-хто хто знає протокол.

Зовнішня програма (Playwright, DevTools)
        ↓ CDP через WebSocket
   Браузер (Chrome engine)
        ↓ виконує JS
   Код аплікейшину (React, ванільний JS)

Хто використовує CDP:

  • DevTools — коли відкриваєш F12. Це не "вбудована частина Chrome" — це окремий веб-додаток який спілкується з браузером через CDP. Вкладки Elements, Network, Console — все через CDP
  • Playwright — керує браузером через CDP: клік, навігація, перехоплення мережі
  • Puppeteer — бібліотека від Google, попередник Playwright, теж CDP
  • Lighthouse — вимірює performance через CDP
  • VS Code debugger — дебагінг Node.js теж через CDP

Аналогія: браузер — це телевізор. Код сайту — відео що грає на екрані. CDP — пульт керування ззовні. DevTools, Playwright, Lighthouse — різні пристрої які підключаються до одного пульта. Самому відео пульт не потрібен — воно просто грає.

TypeScript — як виглядають CDP команди під капотом
// Коли ти пишеш звичайний Playwright код:
await page.goto('https://myshop.com');
await page.locator('.add-to-cart').click();

// Playwright під капотом надсилає CDP команди через WebSocket:
// → { "method": "Page.navigate", "params": { "url": "https://myshop.com" } }
// ← { "result": { "frameId": "abc123" } }
//
// → { "method": "DOM.querySelector", "params": { "selector": ".add-to-cart" } }
// ← { "result": { "nodeId": 42 } }
//
// → { "method": "Input.dispatchMouseEvent", "params": { "type": "click" } }
// ← { "result": {} }
//
// Ти цього не бачиш — Playwright робить це автоматично
TypeScript — прямий доступ до CDP в тесті через newCDPSession
// Зазвичай CDP не треба викликати напряму — Playwright все робить сам
// Але для просунутих речей є прямий доступ:

const client = await page.context().newCDPSession(page);
// ↑ відкриваємо CDP сесію для конкретної вкладки

// Вмикаємо domain Performance і отримуємо метрики
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
console.log(metrics); // JSHeapUsedSize, LayoutCount, TaskDuration...

// Емулюємо мобільну мережу через CDP
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  downloadThroughput: 1.5 * 1024 * 1024 / 8, // 1.5 Mbps
  uploadThroughput: 750 * 1024 / 8,
  latency: 40,
});
🔧 TypeScript / JS патерни в цьому прикладі page.context().newCDPSession(page) — ланцюжок методів, повертає Promise з CDP client; client.send('Domain.method', params) — надсилає CDP команду, перший аргумент — рядок у форматі "Domain.method", другий — об'єкт параметрів; деструктуризація const { metrics } = await client.send(...) — беремо поле metrics з результату.
🗣 Як читати вголос CDP — Chrome DevTools Protocol — це канал для зовнішніх програм які хочуть керувати браузером ззовні. Важливо розрізняти два рівні: код аплікейшину — звичайний JavaScript всередині браузера, він використовує браузерні API типу document і fetch, CDP тут немає. CDP — це окремий рівень для зовнішніх програм: набір JSON команд які летять через WebSocket від зовнішньої програми до браузера. Найбільший користувач CDP — самі DevTools які ти відкриваєш через F12: це не вбудована частина Chrome, а окремий веб-додаток що спілкується з браузером через CDP. Playwright теж використовує CDP — коли ти пишеш page.goto() або locator.click(), Playwright автоматично надсилає відповідні CDP команди. Для просунутих речей — performance метрики, емуляція мережі — можна відкрити CDP сесію напряму через newCDPSession і надсилати команди самостійно через client.send().
6 Що таке Page, BrowserContext і Browser? Як вони пов'язані і чому page: Page усюди в тестах?
ОсновиJunior

Ми щойно розібрались як Playwright спілкується з браузером через WebSocket і CDP. Тепер подивимось на структуру самого браузера — три рівні ієрархії які пояснюють чому тести ізольовані один від одного навіть при паралельному запуску.

Browser — це сам процес браузера (Chromium, Firefox або WebKit). Дорогий у запуску, тому Playwright тримає один на кожен worker (процес виконання). При паралельному запуску з 4 workers — буде 4 браузерних процеси, по одному на кожен worker.

BrowserContext — ізольований "профіль" всередині браузера, схожий на окреме інкогніто вікно. Має свої cookies, localStorage, cache. Playwright створює новий для кожного тесту.

Cookies належать BrowserContext — не Browser і не Page. Кожен context має власний cookie jar: два різні context автоматично мають повністю ізольовані cookies, навіть якщо працюють в одному Browser-процесі. Саме на цьому і побудована ізоляція тестів у Playwright — тест A залогінений під admin ніяк не бачить сесію тесту B. Всі вкладки (Page) всередині одного context — навпаки, cookies спільні.

Page — одна вкладка всередині context. Саме з нею ти взаємодієш через goto, click, fill.

Коли ти пишеш async ({ page }) в тесті — ти не створюєш вкладку вручну. Playwright сам підготував Browser, створив новий BrowserContext і відкрив у ньому Page — і передав тобі готову. Це і є fixture: готовий ресурс який фреймворк підготував за тебе. Детальніше про fixtures — в наступних двох картках.

TypeScript — ієрархія Browser → Context → Page
// Ієрархія, яку будує Playwright під капотом:
// Browser  → один процес браузера (Chromium/Firefox/WebKit)
//   └─ BrowserContext → ізольована "профіль"-сесія (cookies, storage, кеш)
//        └─ Page → одна вкладка всередині цього контексту

test('приклад', async ({ page }: { page: Page }) => {
  // page тут — вже готова вкладка, Playwright сам:
  // 1. запустив Browser (або перевикористав з пулу)
  // 2. створив новий ізольований BrowserContext саме для цього тесту
  // 3. відкрив у ньому одну Page і передав її як fixture

  await page.goto('/login');
  await page.fill('#email', 'test@test.com');
});

// Чому це важливо для ізоляції тестів:
test('тест А логіниться', async ({ page }) => {
  await page.goto('/login'); // свій BrowserContext, свої cookies
});
test('тест Б — гість', async ({ page }) => {
  await page.goto('/'); // НЕ бачить cookies тесту А — інший BrowserContext
});
💡 Простими словами: Browser — це сам додаток-браузер. BrowserContext — окремий "інкогніто-профіль" всередині нього, зі своїми cookies/localStorage/cache. Page — одна вкладка в цьому профілі. Playwright дає кожному тесту власний BrowserContext (а отже й Page), тому тести ніколи не "бачать" стан один одного, навіть якщо запускаються паралельно в одному Browser-процесі.
🔧 TypeScript / JS патерни в цьому прикладі { page }: { page: Page } — деструктуризація з TypeScript анотацією типу; async ({ page }) => {} — стрілочна async функція з деструктуризацією параметра; // коментарі — документують що Playwright робить "за лаштунками"; const — оголошення константи (значення не можна перепризначити).
🗣 Як читати вголос У Playwright є три рівні: Browser — це сам процес браузера, важкий об'єкт, який дорого створювати, тому Playwright переважно перевикористовує один на весь тестовий запуск. Всередині нього BrowserContext — це ізольована сесія, схожа на окреме інкогніто-вікно: у неї свої cookies, localStorage, кеш, дозволи. І вже всередині контексту — Page, конкретна вкладка, з якою ми безпосередньо взаємодіємо через goto, click, fill і так далі. Коли ми пишемо async ({ page }) у тесті, ми не створюємо цю вкладку самі — Playwright робить це автоматично: для кожного тесту піднімає новий BrowserContext, відкриває в ньому одну Page і підставляє її як fixture-аргумент. Анотація типу : Page — це просто TypeScript, який каже компілятору, що це об'єкт класу Page, а не any, щоб автодоповнення й перевірка типів працювали. Саме тому в кожному тесті, кожному Page Object, кожному helper-методі ти бачиш page — це не глобальна змінна, а свіжий ізольований об'єкт, який Playwright інжектить заново щоразу, і завдяки цьому один тест ніколи не впливає на стан іншого, навіть при паралельному запуску.
7 Що таке фікстура (fixture) концептуально? Чому page інжектиться, а не створюється вручну?
ОсновиJunior

Фікстура — це готовий ресурс який Playwright сам підготував і передав у тест. Ти просто називаєш що тобі потрібно — і отримуєш це готовим. Після тесту Playwright сам прибирає.

Аналогія з рестораном: ти не ходиш на кухню щоб взяти тарілку — офіціант приносить її сам. Ти просто замовляєш "мені потрібна тарілка" і отримуєш готову. Fixture — це той офіціант.

У коді це виглядає так: коли пишеш async ({ page }) — ти не створюєш page сам. Playwright читає ключ деструктуризації page, розуміє що тобі потрібна вкладка, і підставляє готову. Важливо: Playwright дивиться на ключ об'єкта, а не на ім'я локальної змінної. Тому async ({ page: myPage }) працює так само — fixture key залишається page, просто локальна змінна перейменована на myPage.

Це схоже на Dependency Injection (DI) — патерн з бекенд-розробки. DI означає: "не створюй залежності сам — отримай їх ззовні". Замість того щоб писати new Database() всередині класу — клас отримує вже готовий об'єкт бази даних через конструктор або параметр. Так само тест отримує готову page через параметр функції — не сам її створює.

TypeScript
// test() — функція з параметрами "за домовленістю", як DI
test('приклад', async ({ page }) => {
  // "page" — ключ в об'єкті fixtures, Playwright розпізнає
  // це ім'я і підставляє відповідний готовий ресурс
  await page.goto('/login');
});

// Тобі НЕ треба робити це вручну:
// const browser = await chromium.launch();
// const context = await browser.newContext();
// const page = await context.newPage();
// ... тест ...
// await page.close();
// await context.close();
// Playwright робить усе це сам, і сам же прибирає після тесту

// Вбудовані фікстури з різним життєвим циклом:
// page    → нова для КОЖНОГО тесту
// context → нова для КОЖНОГО тесту (page живе всередині неї)
// browser → одна на ВЕСЬ файл / прогін (дорого перестворювати)
💡 Простими словами: уяви, що фікстура — це офіціант, який сам приносить тобі чисту тарілку перед їжею і сам забирає брудну після, без твоєї участі. Ти просто кажеш "мені потрібна тарілка" (називаєш page у деструктуризації) — і не думаєш про те, звідки вона взялась і хто її помиє.
🔧 TypeScript / JS патерни в цьому прикладі async ({}, use) => {} — стрілочна async функція з двома параметрами, перший — порожня деструктуризація (нам не потрібні інші fixtures); await use(value)use це callback-функція яку ми викликаємо щоб "передати" ресурс в тест і "призупинити" fixture; все що до use() — setup, після — teardown.
🗣 Як читати вголос Фікстура — це готовий ресурс, який Playwright сам готує і передає в тест перед його запуском, а потім сам прибирає після. Технічно, коли ми пишемо async ({ page }) => {...}, page — це не просто змінна, а ключ в об'єкті fixtures, який Playwright розпізнає по імені і підставляє відповідний ресурс. У Playwright є вбудовані фікстури: page, context, browser, request для API-тестів — кожна зі своїм життєвим циклом, наприклад page створюється заново для кожного тесту, а browser — worker-scoped: один браузерний процес на весь worker. При 4 workers — 4 браузерних процеси. Це той самий принцип, що dependency injection в бекенд-фреймворках: ми кажемо "мені потрібен page" просто назвавши його в деструктуризації — і Playwright за лаштунками вирішує, як саме його зібрати, коли створити і коли знищити. Нам не треба писати new Page() чи закривати її вручну. Сила фікстур ще й у тому, що можна створювати власні — наприклад, кастомна фікстура authenticatedPage бере базову page, логінить юзера через storageState, і вже залогінену сторінку віддає в тест; тест просто пише async ({ authenticatedPage }) => {...} і не думає про логін взагалі.
8 Чому page, browser і context називаються "фікстурами"? Це просто слово чи технічний термін?
ОсновиJunior
"Fixture" — це офіційний термін у Playwright, не метафора. Слово прийшло з будівництва і спорту — це щось "зафіксоване", підготовлене заздалегідь і готове до використання. У тестуванні fixture — це будь-який ресурс, який фреймворк готує для тебе до запуску тесту і прибирає після. Саме тому page, browser, context — це фікстури: ти їх не створюєш і не знищуєш, Playwright робить це сам.
TypeScript — з фікстурами vs без
// ✅ З фікстурами — ти просто називаєш що потрібно
test('мій тест', async ({ page, context, browser }) => {
  // page, context, browser вже готові — ти їх не створювала
  await page.goto('/shop');
});
// Після тесту Playwright сам закриває page і context. Крапка.

// ❌ Без фікстур — ти б мала писати це вручну КОЖНОГО РАЗУ:
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// ... тест ...
await page.close();    // і не забути! а якщо тест впав посередині?
await context.close(); // тоді close не виконається, і браузер лишиться висіти
await browser.close();

// ─────────────────────────────────────────────────────
// Різний час життя фікстур — це теж частина "підготовки і прибирання":
//
// browser  — одна на ВЕСЬ тестовий прогін (або файл)
//            запустити Chrome — дорого (секунди)
//            тому Playwright переюзовує один процес
//
// context  — нова для КОЖНОГО тесту
//            це ізоляція: свої cookies, localStorage, cache
//            дешево створити (мілісекунди)
//
// page     — нова для КОЖНОГО тесту (живе всередині context)
//            одна вкладка = один тест
💡 Як це виглядає в документації Playwright: якщо відкрити офіційний сайт і знайти розділ "Fixtures" — там так і написано: "Built-in fixtures: page, browser, context, request". Це не народна назва — це офіційна архітектурна концепція фреймворку, запозичена з pytest (Python), де fixtures теж є центральною ідеєю.
🔧 TypeScript / JS патерни в цьому прикладі async ({ page }, use, testInfo) => {} — функція отримує три аргументи: інші fixtures, callback use(), і метадані тесту; testInfo.title — доступ до властивості об'єкта через крапку; Date.now() — виклик статичного методу класу; template literal `текст ${змінна}` — рядок з вбудованим виразом.
🗣 Як читати вголос "Fixture" — це офіційний термін у Playwright, не просто зручне слово. Походить від ідеї "зафіксованого, заздалегідь підготовленого ресурсу": фреймворк сам готує його до початку тесту і сам прибирає після — незалежно від того, чи тест пройшов чи впав. Якби page не була фікстурою, тобі б довелось вручну писати chromium.launch(), browser.newContext(), context.newPage() на початку кожного тесту і відповідно page.close(), context.close(), browser.close() наприкінці. І найголовніша проблема — якщо тест впав посередині виконання, твій код прибирання просто не досягнеться, браузерні процеси залишаться висіти в пам'яті. Фікстура вирішує це елегантно: Playwright гарантує teardown fixture навіть якщо тест завершився помилкою. (Виняток: якщо процес примусово завершений — SIGKILL, OOM, killed CI runner — teardown не виконається.) Важливо, що page, context і browser мають різний час життя: браузерний процес дорого запускати, тому browser — worker-scoped: один на весь worker-процес. При паралельному запуску з 4 workers — буде 4 браузерних процеси. А context і page створюються заново для кожного тесту окремо, бо саме через них досягається ізоляція — у кожному тесті свої cookies, свій localStorage, свій чистий стан.
9 Як написати базовий тест на Playwright з TypeScript?
ОсновиJunior

Ми вже знаємо що таке Node.js, WebSocket, ієрархія браузера і fixtures. Час написати перший реальний тест — і ти побачиш як всі ці концепти з'єднуються разом.

Базовий тест складається з трьох частин: імпорт test і expect з Playwright, оголошення тесту через функцію test() з назвою і async функцією, і деструктуризація fixtures { page } — саме тут ми "просимо" Playwright дати нам готову вкладку.

Зверни увагу на await перед кожною дією — Playwright асинхронний, і без await наступний рядок виконається не чекаючи завершення попереднього.

TypeScript
import { test, expect } from '@playwright/test';

test('should display homepage title', async ({ page }) => {
  await page.goto('https://example.com');
  
  await expect(page).toHaveTitle(/Example Domain/);
  
  const heading = page.locator('h1');
  await expect(heading).toBeVisible();
  await expect(heading).toHaveText('Example Domain');
});
🔧 TypeScript / JS патерни в цьому прикладі import { test, expect } from '@playwright/test' — ES modules import з деструктуризацією: беремо конкретні exports з пакету; async ({ page }) => {} — стрілочна async функція; await перед кожною дією — чекаємо завершення асинхронної операції; expect(locator).toBeVisible() — ланцюжок методів (method chaining).
🗣 Як читати вголос Імпортуємо test і expect з пакету Playwright. Викликаємо функцію test, передаємо назву тесту рядком і асинхронну стрілочну функцію — вона деструктурує page з fixtures. Всередині: чекаємо навігацію через page.goto, потім перевіряємо заголовок сторінки через expect(page).toHaveTitle з регулярним виразом. Далі оголошуємо константу heading — це локатор на тег h1 — і перевіряємо що він видимий і має потрібний текст.
2 5
10 Що таке callback і як його читати в коді Playwright?
JS/TSJunior

Callback — це функція яку ти передаєш іншій функції, але сама не викликаєш. Той хто отримав твою функцію — вирішує коли її викликати.

Подивимось на конкретний приклад з Playwright:

await page.route('**/api/**', route => {
  route.fulfill({ status: 200 });
});

Тут є дві функції: page.route() — це метод Playwright, він знає як слідкувати за мережевими запитами. І route => { ... } — це твоя функція. Ти її написала, але сама не викликаєш.

Ти даєш свою функцію Playwright і кажеш: "ось моя функція, ти знаєш коли її викликати — викликай коли буде запит". Playwright тримає твою функцію у себе. Коли браузер робить запит на /api/ — Playwright сам викличе твою функцію і передасть в неї об'єкт route.

Тобто: ти — пишеш функцію і передаєш її. Playwright — вирішує коли її викликати і викликає.

Ще простіший приклад — setTimeout:

setTimeout(() => {
  console.log('привіт');
}, 3000);
// () => { console.log('привіт') } — це callback
// Ти передала її setTimeout
// setTimeout чекає 3 секунди і сам викликає твою функцію
// Ти не викликала її — він викликав

Callback зустрічається скрізь у Playwright:

// page.on() — Playwright викличе твій callback коли відбудеться подія
page.on('dialog', dialog => {
  dialog.accept(); // ← твій callback, Playwright викликає коли з'явиться діалог
});

// forEach — JavaScript викличе твій callback для кожного елемента
[1, 2, 3].forEach(num => {
  console.log(num); // ← твій callback, forEach викликає тричі
});

// test() — Playwright викличе твій callback коли запускає тест
test('назва тесту', async ({ page }) => {
  // ← це теж callback! Playwright викликає його коли запускає тест
});

💡 Як розпізнати callback в коді: бачиш що функція передається як аргумент (всередині дужок іншої функції) і ти її не викликаєш одразу — це callback. Стрілочна функція () => {} або async () => {} як аргумент — майже завжди callback.

Чому без () після callback — важливо:

const greet = () => console.log('привіт');

// З дужками () — викликаємо ЗАРАЗ і передаємо результат
setTimeout(greet(), 1000);
// greet() виконалась одразу при читанні цього рядка
// setTimeout отримав undefined — бо greet нічого не повертає

// Без дужок — передаємо саму функцію
setTimeout(greet, 1000);
// setTimeout тримає greet у себе і викличе через 1000мс

Аналогія: ти даєш другу завдання "зателефонуй мамі через годину". greet — це номер телефону. Друг сам натисне дзвінок через годину. greet() — це ти сама набрала номер і дала другу вже активний дзвінок. Мама вже відповідає — до того як минула година.

Як знайти callback у складному коді — три кроки:

  1. Знайди виклик функції — відкриваючу дужку (
  2. Шукай всередині дужок => або ключове слово function
  3. Все що після => до відповідної закриваючої ) — це callback і його тіло
🔧 TypeScript / JS патерни в цьому прикладі () => {} — стрілочна функція як callback (передається як аргумент, не викликається одразу); async () => {} — async стрілочна функція-callback; setTimeout(fn, ms) — вбудована функція яка приймає callback; forEach(callback) — метод масиву з callback; { page } — деструктуризація параметра callback.
🗣 Як читати вголос Callback — це функція яку ти передаєш іншій функції але сама не викликаєш. Той хто отримав твою функцію — вирішує коли її викликати. У Playwright це зустрічається скрізь: у page.route() другим аргументом іде стрілочна функція — це callback, Playwright викличе її кожного разу коли браузер зробить запит що відповідає паттерну. У page.on('dialog', ...) — Playwright викличе callback коли з'явиться діалог. У test('назва', async ({page}) => {}) — сама функція тесту це теж callback, Playwright викликає її коли запускає тест. Ключова ознака callback: функція передається як аргумент всередині дужок іншої функції і не викликається одразу через круглі дужки після неї. Найпростіший приклад поза Playwright: setTimeout(() => console.log('привіт'), 3000) — ти передала функцію setTimeout, і він сам викличе її через 3 секунди.
11 Що таке серіалізація і навіщо вона потрібна при передачі даних між Node.js і браузером?
JS/TSJunior
  1. Об'єкт в JavaScript живе в оперативній пам'яті конкретного процесу як структура з адресами — { name → адреса 0x7f4b → 'Iuliia' }. Ці адреси мають сенс тільки всередині цього одного процесу.
  2. Мережа і файли передають байти. Якщо відправити адресу 0x7f4b — на іншому комп'ютері вона вкаже на іншу ділянку пам'яті або нікуди. Об'єкт прив'язаний до конкретної пам'яті процесу.
  3. Серіалізація — перетворення об'єкта з "адрес в пам'яті" в текст де значення записані явно. Текст не залежить від конкретної машини.
  4. Десеріалізація — зворотній процес: текст назад в об'єкт.
  5. JSON — найпоширеніший формат серіалізації у вебі. Виглядає майже як JavaScript об'єкт, розуміє будь-яка мова програмування і будь-який сервер.
  6. У Playwright серіалізація відбувається коли передаємо аргументи в evaluate() або waitForFunction() — Playwright перетворює JavaScript об'єкт в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад в об'єкт.

Аналогія: об'єкт — це будинок у твоєму місті. Ти не можеш "відправити будинок" другу в іншому місті. Але можеш зробити план будинку і відправити його — це і є серіалізація. Друг отримує план і будує такий самий будинок — це десеріалізація.

TypeScript — серіалізація і десеріалізація
const user = { name: 'Iuliia', age: 30 }; // об'єкт в пам'яті

// Серіалізація — перетворюємо в рядок (JSON текст)
const text = JSON.stringify(user);
// text = '{"name":"Iuliia","age":30}'
// тепер можна зберегти у файл або відправити через мережу

// Десеріалізація — перетворюємо рядок назад в об'єкт
const obj = JSON.parse(text);
// obj = { name: 'Iuliia', age: 30 }
TypeScript — серіалізація в Playwright: передача аргументів між Node.js і браузером
const expected = 48; // змінна в Node.js

// Playwright серіалізує 48 в JSON і передає через WebSocket в браузер
// Браузер десеріалізує і отримує число 48 як аргумент функції
await page.waitForFunction(
  (count) => document.querySelectorAll('.product-card').length >= count,
  expected // ← серіалізується і передається в браузер
);

// Closure НЕ працює через межу процесів — бо функція переїжджає в браузер
// а змінна expected залишається в Node.js (різні процеси, різна пам'ять)
await page.waitForFunction(
  () => document.querySelectorAll('.product-card').length >= expected // ❌ undefined!
);
🔧 TypeScript / JS патерни в цьому прикладі JSON.stringify(obj) — статичний метод класу JSON, серіалізує об'єкт в рядок; JSON.parse(text) — десеріалізує рядок назад в об'єкт; { name: 'Iuliia', age: 30 } — об'єктний літерал; серіалізація обмежена: функції, undefined, і посилання на DOM елементи серіалізувати не можна.
🗣 Як читати вголос Серіалізація — це перетворення об'єкта з оперативної пам'яті в текст для передачі через мережу або запису у файл. Об'єкт не можна передати напряму бо він існує як структура адрес в пам'яті конкретного процесу — ці адреси не мають сенсу в іншому процесі або на іншому комп'ютері. JSON це найпоширеніший формат серіалізації у вебі — JSON.stringify() перетворює об'єкт в рядок, JSON.parse() — назад. У Playwright це важливо при передачі аргументів в evaluate() або waitForFunction(): Playwright серіалізує значення в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад. Саме тому closure не працює через межу процесів — функція переїжджає в браузер, але змінні з Node.js залишаються в Node.js, і їх треба явно передавати як аргументи через серіалізацію.
12 Що таке spread оператор ... і як він використовується в Playwright?
JS/TSJunior

Spread оператор ... — це "розгорни і вистав вміст". Він бере масив або об'єкт і вставляє всі його елементи або поля в нове місце.

TypeScript — spread з масивами
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

// Без spread — масив всередині масиву
const bad = [arr1, arr2];          // [[1,2,3], [4,5,6]]

// Зі spread — розгортаємо вміст
const good = [...arr1, ...arr2];   // [1, 2, 3, 4, 5, 6]
TypeScript — spread з об'єктами
const defaults = { color: 'black', size: 'M', price: 100 };
const override = { price: 200, sale: true };

// Spread розгортає поля і вставляє їх
const result = { ...defaults, ...override };
// { color: 'black', size: 'M', price: 200, sale: true }
//                              ↑
// price перезаписаний бо override йде після defaults
TypeScript — spread в Playwright: ...devices['iPhone 14']
// devices['iPhone 14'] — це готовий об'єкт:
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
// }

// Spread розгортає всі поля в наш об'єкт use:
use: { ...devices['iPhone 14'], locale: 'uk-UA' }
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
//   locale: 'uk-UA',  ← наше додаткове поле
// }
// Замість копіювати всі поля вручну — розгортаємо готовий профіль
💡 Порядок важливий: якщо одне і те саме поле є в обох об'єктах — перемагає той що стоїть правіше:
{ ...defaults, price: 200 } → price буде 200 (наше значення перезаписує)
{ price: 200, ...defaults } → price буде зі defaults (defaults перезаписує наше)
🔧 TypeScript / JS патерни в цьому прикладі ...arr — spread масиву: вставляє елементи; ...obj — spread об'єкта: вставляє поля; { ...obj1, ...obj2 } — злиття об'єктів (merge); { ...obj, key: value } — копіювання об'єкта з додаванням або перезаписом поля; spread не мутує оригінальний об'єкт — створює новий.
🗣 Як читати вголос Spread оператор — три крапки перед змінною — розгортає масив або об'єкт і вставляє весь його вміст в нове місце. З масивами: [...arr1, ...arr2] — замість масиву всередині масиву отримуємо плоский масив з усіма елементами. З об'єктами: { ...obj1, ...obj2 } — зливаємо два об'єкти в один, якщо є однакові поля — перемагає той що стоїть правіше. В Playwright найчастіше бачимо в конфігурації: { ...devices['iPhone 14'], locale: 'uk-UA' } — розгортаємо готовий профіль девайсу з усіма його полями і додаємо своє. Це набагато коротше ніж копіювати viewport, userAgent і isMobile вручну.
13 Що таке статичний метод і чим він відрізняється від звичайного?
JS/TSJunior

В JavaScript є два типи методів — ті що викликаються на екземплярі класу, і ті що викликаються на самому класі. Другі називаються статичними.

Звичайний метод — спочатку створюєш об'єкт через new, потім викликаєш метод на ньому:

const arr = new Array();  // створили екземпляр
arr.push(1);               // викликаємо метод на екземплярі

const str = new String('hello');
str.toUpperCase();          // метод екземпляру

Статичний метод — викликається прямо на класі, без створення екземпляру:

// Не треба: const j = new JSON(); j.stringify()
JSON.stringify(obj);     // статичний — викликаємо на класі JSON
JSON.parse(text);        // статичний

Math.random();           // статичний — не треба new Math()
Math.max(1, 2, 3);       // статичний

Array.isArray([]);       // статичний — перевіряє чи це масив

Promise.all([p1, p2]);   // статичний — виконує кілька Promise паралельно

// В Playwright:
expect.poll(async () => {}); // poll — статичний метод на expect
expect.extend({});          // extend — теж статичний

Як визначити статичний метод у своєму класі:

class MathHelper {
  // Статичний метод — ключове слово static
  static double(n: number) {
    return n * 2;
  }

  // Звичайний метод
  add(n: number) {
    return n + 1;
  }
}

// Статичний — на класі:
MathHelper.double(5); // ✅ 10

// Звичайний — на екземплярі:
const m = new MathHelper();
m.add(5); // ✅ 6

Простіше запам'ятати: якщо перед методом в коді бачиш назву класу з великої літери — це статичний: JSON.stringify, Math.random, Promise.all, Array.isArray, expect.poll. Якщо перед методом змінна — це екземпляр: arr.push, str.toUpperCase.

🔧 TypeScript / JS патерни в цьому прикладі static methodName() {} — ключове слово static робить метод статичним в TypeScript/JS класі; ClassName.method() — виклик статичного методу на класі; instance.method() — виклик звичайного методу на екземплярі; new ClassName() — створення екземпляру.
🗣 Як читати вголос Статичний метод — це метод який викликається прямо на класі без створення екземпляру. Звичайний метод потребує спочатку створити об'єкт через new, потім викликати метод на ньому. Статичний — одразу на класі. Приклади статичних методів які зустрічаються скрізь: JSON.stringify() і JSON.parse() — ніхто не пише new JSON(); Math.random() і Math.max(); Array.isArray(); Promise.all(). В Playwright: expect.poll() і expect.extend() — статичні методи на об'єкті expect. Простий спосіб визначити: якщо перед методом стоїть назва класу з великої літери — статичний. Якщо змінна — звичайний метод екземпляру.
14 Що таке try/catch і коли його використовувати в тестах?
JS/TSJunior

try/catch — це спосіб обробити помилку не дозволяючи їй зупинити програму. Коли код всередині try кидає помилку — виконання переходить в catch, а не зупиняється повністю.

TypeScript — базовий try/catch
// Без try/catch — помилка зупиняє все
const data = JSON.parse('це не JSON'); // ❌ кидає SyntaxError
console.log('цей рядок не виконається');

// З try/catch — перехоплюємо і продовжуємо
try {
  const data = JSON.parse('це не JSON');
} catch (e) {
  console.log('не вдалось розпарсити:', e.message);
}
console.log('цей рядок виконається'); // ✅
TypeScript — три частини: try / catch / finally
try {
  // код який може кинути помилку
} catch (e) {
  // що робити якщо помилка сталась
  // e — об'єкт помилки: e.message, e.name, e.stack
} finally {
  // виконується ЗАВЖДИ — і при помилці і без
  // типово для закриття з'єднань, файлів тощо
}
TypeScript — try/catch в Playwright тесті
// Playwright кидає TimeoutError якщо елемент не з'явився
try {
  await page.waitForSelector('.success-banner', { timeout: 3000 });
  console.log('операція успішна');
} catch (e) {
  // .success-banner не з'явився за 3 секунди — продовжуємо
  console.log('операція не завершилась вчасно');
}

// ⚠️ Коли НЕ треба try/catch:
// Більшість методів Playwright вже кидають зрозумілі помилки
// і тест падає з чітким повідомленням.
// try/catch потрібен тільки коли хочеш ВІДНОВИТИСЬ після помилки
// і продовжити тест, а не дати йому впасти.

// ── Про .rejects.toThrow() ──────────────────────────────────────
// В e-commerce UI тестах майже ніколи не потрібен.
// Неавторизований юзер на /account → додаток показує UI помилку:
await page.goto('/account');
await expect(page).toHaveURL('/login');           // редирект
await expect(page.getByText('Please sign in')).toBeVisible(); // або повідомлення
// Тестуємо те що бачить юзер — жодного rejects.toThrow()
//
// .rejects.toThrow() — для тестування JS функцій які КИДАЮТЬ виключення:
await expect(getUser('')).rejects.toThrow('ID is required'); // unit тест
🔧 TypeScript / JS патерни в цьому прикладі try { } catch (e) { } — синтаксис обробки помилок; e.message — рядок з описом помилки; e.name — назва класу помилки (наприклад 'TimeoutError'); finally — блок що виконується завжди незалежно від результату; throw e — перекинути помилку далі (деталі в наступній картці).
🗣 Як читати вголос try/catch — механізм обробки помилок: код що може кинути помилку кладемо в блок try, якщо помилка сталась — виконання переходить в catch і програма продовжується. Об'єкт помилки e містить message, name і stack. Блок finally виконується завжди — корисний для очищення ресурсів. В Playwright try/catch використовується рідко бо більшість методів вже падають з зрозумілим повідомленням. Потрібен коли хочеш перехопити конкретну помилку і відновитись — наприклад якщо елемент може і не з'явитись і це нормальна ситуація. Детальніше про класи помилок Playwright — в наступній картці.
3 13
15 Які основні локатори є в Playwright? Що таке семантичні локатори?
ЛокаториJunior

Локатор — це спосіб знайти елемент на сторінці. Це не сам елемент, а інструкція "знайди мені ось це". Playwright шукає елемент заново кожного разу коли ти виконуєш дію — клік, fill, перевірку.

Playwright рекомендує семантичні локатори — ті що шукають елемент за його змістом і роллю, а не за CSS класом або позицією в DOM. Чому? Бо CSS класи часто змінюються при рефакторингу, а роль кнопки "Submit" — ні.

TypeScript — типи локаторів з поясненням кожного
// ✅ Семантичні — шукають за змістом і роллю, стійкі до змін верстки

page.getByRole('button', { name: 'Submit' })
// Шукає елемент з ARIA роллю 'button' і текстом 'Submit'
// ARIA роль — це семантичне значення елемента для screen readers

page.getByLabel('Email address')
// Шукає <input> який пов'язаний з лейблом "Email address"
// В HTML: <label for="email">Email address</label><input id="email">
// Атрибут for у лейблі вказує на id інпута — ось цей зв'язок і шукає getByLabel

page.getByPlaceholder('Enter email')
// Шукає input з атрибутом placeholder="Enter email"

page.getByText('Welcome back')
// Шукає будь-який елемент що містить цей текст

page.getByTestId('submit-btn')
// Шукає елемент з атрибутом data-testid="submit-btn"
// Розробники додають його спеціально для тестів
// За замовчуванням атрибут 'data-testid', але можна змінити в конфізі:
// defineConfig({ use: { testIdAttribute: 'data-qa' } })
// Тоді getByTestId('submit-btn') шукатиме data-qa="submit-btn"

// ⚠️ Допустимі — CSS і XPath, але крихкіші
page.locator('.btn-primary')             // CSS клас може змінитись
page.locator('xpath=//button[@type="submit"]') // XPath складніше читати

// ❌ Уникати — залежать від структури DOM
page.locator('.sc-a8f9b2 > div:nth-child(3)') // впаде при будь-якому рефакторингу
💡 Порада: getByRole відповідає ARIA accessibility tree — це найстійкіший локатор і він одночасно тестує доступність. Якщо не можеш знайти елемент через getByRole — можливо він не має правильної семантики в HTML.
🔧 TypeScript / JS патерни в цьому прикладі Немає складних JS конструкцій — це список API методів. Важливий патерн: page.getByRole('button', { name: 'Submit' }) — передача об'єкта як другого аргументу (options object pattern); { name: 'Submit' } — об'єктний літерал (object literal) як конфігурація.
🗣 Як читати вголос Локатор — це інструкція як знайти елемент на сторінці, а не сам елемент. Playwright рекомендує семантичні локатори бо вони шукають елементи за змістом і роллю а не за CSS класами які часто змінюються. getByRole шукає за ARIA роллю — наприклад 'button' або 'textbox' — і опційним ім'ям. getByLabel шукає input пов'язаний з лейблом — в HTML це зв'язок через атрибут for у лейблі який вказує на id інпута. getByTestId шукає за атрибутом data-testid який розробники додають спеціально для тестів. CSS і XPath допустимі але крихкіші — якщо верстка зміниться локатор впаде.
16 Що таке strict mode в локаторах і чому Playwright падає, якщо знайдено 2+ елементи?
ЛокаториJunior

Коли локатор знаходить більше одного елемента і ти намагаєшся виконати дію — Playwright навмисно падає з помилкою. Це і є strict mode.

У Selenium є метод findElement — він мовчки бере перший елемент зі списку якщо знайшов кілька. Здається зручно — але це джерело прихованих багів: тест "зелений", але клікає не на ту кнопку яку ти мав на увазі.

Playwright вибрав інший підхід: краще явний фейл одразу, ніж тихий баг. Якщо локатор знайшов кілька елементів — це сигнал що локатор недостатньо специфічний. Треба його уточнити.

Де саме діє strict mode: це властивість Locator API. Всі дії (click, fill) і web-first assertions на локаторі вимагають щоб він резолвився рівно в один елемент — інакше помилка strict mode violation. Виняток — методи які працюють зі списком за задумом: count(), all(), toHaveCount(). Старе ElementHandle API (page.$()) під strict mode не підпадає — воно мовчки бере перший елемент, як Selenium. Це ще одна причина не використовувати його.

⚠️ Про nth() і first(): це не універсальне рішення для strict mode violation, а часто спосіб заглушити симптом. Використовуй nth() тільки коли порядок реально є частиною бізнес-логіки ("перший товар у списку рекомендацій", "третій крок чекауту"). Якщо ти пишеш nth(2) просто щоб тест перестав падати — локатор поламається від першої ж зміни верстки. Краще знайти стабільніший спосіб: filter({ hasText }), getByRole з назвою, або getByTestId.

TypeScript — strict mode violation
// ❌ Якщо на сторінці кілька .product-card — впаде з помилкою
await page.locator('.product-card').click();
// Error: strict mode violation: locator resolved to 12 elements

// ✅ Звужуємо до конкретного елементу
await page.locator('.product-card').first().click();
await page.locator('.product-card').nth(2).click();
await page.locator('.product-card').filter({ hasText: 'Trench Coat' }).click();

// ✅ Сучасний спосіб — композиція локаторів через filter()
// filter({ hasText }) — картка що МІСТИТЬ цей текст будь-де всередині
await page.getByRole('listitem')
  .filter({ hasText: 'Trench Coat' })
  .getByRole('button', { name: 'Add to Bag' })
  .click();

// filter({ has: locator }) — картка що МІСТИТЬ інший елемент
// "знайди картку в якій є бейдж Sale, і клікни в ній кнопку"
await page.locator('.product-card')
  .filter({ has: page.locator('.sale-badge') })
  .getByRole('button', { name: 'Add to Bag' })
  .click();

// filter({ hasNot }) / filter({ hasNotText }) — інверсія
const inStock = page.locator('.product-card')
  .filter({ hasNotText: 'Out of stock' });

// ✅ Або працюємо з усім списком свідомо (count/all)
const count = await page.locator('.product-card').count();
💡 Чому це важливо: Selenium мовчки брав перший елемент зі списку — тест "проходив" але клікав не туди. Playwright навмисно ламає тест одразу. Strict mode — це не перешкода, а сигнал: "твій локатор знайшов кілька елементів, уточни який саме тобі потрібен".
🔧 TypeScript / JS патерни в цьому прикладі handle?. — optional chaining: викликаємо метод тільки якщо handle не null/undefined; page.$() повертає ElementHandle | null — union type в TypeScript; const locator = page.locator() — зверни увагу: БЕЗ await, бо locator синхронний; await з'являється тільки на .click().
🗣 Як читати вголос Strict mode — це поведінка за замовчуванням у Playwright: якщо локатор резолвиться у більше ніж один елемент, а ми викликаємо дію типу click чи fill, тест одразу падає з явною помилкою, а не мовчки бере перший елемент, як це робив би Selenium. Це навмисний дизайн-вибір: краще явний фейл на етапі написання тесту, ніж прихований баг, коли тест технічно "зелений", але клікає не на той елемент, який малося на увазі. Щоб обійти strict mode правильно, є кілька варіантів: .first() чи .nth(index), якщо порядок елементів стабільний і важливий; .filter({ hasText: ... }), якщо потрібен конкретний елемент за вмістом, що набагато надійніше за індекс; або .count(), якщо нам взагалі потрібна кількість, а не дія над одним елементом. Головний меседж — strict mode це не перешкода, а сигнал, що локатор недостатньо специфічний для того, що ми хочемо зробити.
17 Що таке ElementHandle, чи є він у Playwright і чому його не треба використовувати?
ЛокаториJunior
ElementHandle у Playwright є. Важливе формулювання: він не deprecated — офіційна документація Playwright називає його discouraged ("не рекомендується"), тобто це legacy API яке підтримується, але Playwright Team рекомендує Locator API майже для всіх сценаріїв. Виник він тому, що Playwright спочатку мав API, схожий на Puppeteer — там page.$() повертав саме ElementHandle. Потім команда Playwright придумала кращу абстракцію — Locator — і стара залишилась для зворотної сумісності.

Є випадки де ElementHandle ще виправданий: boundingBox() — отримати координати елемента, ownerFrame() — визначити в якому frame знаходиться елемент, contentFrame() — отримати Frame для iframe елемента, evaluate(fn) — передати конкретний DOM вузол у браузерний контекст.
TypeScript — в чому фундаментальна різниця
// ── ElementHandle ── це ФОТОГРАФІЯ елемента, знята прямо зараз ──
// page.$() знаходить елемент НЕГАЙНО і повертає посилання на конкретний
// DOM-вузол — той, що існує В ЦЮ МІЛІСЕКУНДУ в пам'яті браузера
const handle = await page.$('.buy-button');

// Уяви: React перерендерив компонент між цим рядком і наступним.
// Старий DOM-вузол знищений, новий створений на його місці.
// Але handle все ще тримає посилання на СТАРИЙ, якого вже немає.
await handle?.click(); // ❌ клік на "привид" — помилка або нічого не відбувається

// ── Locator ── це ІНСТРУКЦІЯ як знайти елемент ──
// page.locator() НЕ шукає елемент зараз. Він просто запам'ятовує
// "рецепт": знайди елемент з класом .buy-button
const locator = page.locator('.buy-button'); // нічого не відбулось, немає await!

// Тільки тут Playwright реально шукає елемент — заново, прямо зараз
// і одразу чекає, поки він стане видимим і клікабельним (auto-waiting)
await locator.click(); // ✅ завжди знаходить актуальний елемент
TypeScript — як розпізнати старий стиль у чужому коді
// ❌ Старий стиль (Puppeteer-era) — legacy API, discouraged:
const btn = await page.$('button.submit');   // $ повертає ElementHandle | null
const btns = await page.$$('li.item');         // $$ повертає ElementHandle[]
await btn?.click();                              // ?. бо може бути null

// ✅ Сучасний стиль — завжди так, без виключень:
const btn = page.locator('button.submit');    // без await — locator синхронний!
const btns = page.locator('li.item');          // теж без await
await btn.click();                               // await тільки тут, на дії

// Важливо: page.locator() — синхронний, без await
// await з'являється тільки коли викликаєш дію: .click(), .fill(), .toBeVisible()
// Якщо хтось пише "await page.locator(...)" — це теж помилка розуміння
💡 Просте правило для code review: бачиш await page.$() або await page.$$() — це legacy API (документація Playwright називає його discouraged, не deprecated), треба рефакторити на page.locator(). Бачиш await page.locator() — людина переплутала: locator() синхронний, await тут зайвий.
🔧 TypeScript / JS патерни в цьому прикладі page.$('selector') — метод що повертає Promise; handle?.click() — optional chaining для безпечного виклику; page.locator() БЕЗ await — синхронна функція яка повертає Locator об'єкт одразу; різниця: методи що повертають Promise потребують await, синхронні — ні.
🗣 Як читати вголос ElementHandle у Playwright є, і тут важливе точне формулювання: він не deprecated — документація називає його discouraged, тобто не рекомендований. Це legacy API яке підтримується, але Playwright Team радить Locator майже для всіх сценаріїв. Він з'явився тому, що Playwright спочатку мав API схожий на Puppeteer: page.$() знаходив елемент прямо зараз і повертав посилання на конкретний DOM-вузол, який існує в цю мілісекунду. Проблема в тому, що сучасні фронтенд-фреймворки — React, Vue — постійно перерендеровують DOM: старі вузли знищуються, нові створюються на їх місці. Якщо між моментом коли ти взяла ElementHandle і моментом коли ти кликаєш відбувся перерендер — handle тримає посилання на вузол, якого вже немає в DOM, і дія або впаде з помилкою, або клікне в нікуди. Locator вирішує це фундаментально: page.locator('.buy-button') взагалі нічого не шукає одразу — і тому не потребує await, він синхронний. Він просто запам'ятовує інструкцію "знайди елемент з таким класом". Тільки коли ти викликаєш дію — click(), fill() — ось тоді Playwright виконує пошук заново, прямо перед дією, і одразу застосовує auto-waiting. Тому в code review await page.$() — це завжди червоний прапорець: або людина прийшла з Puppeteer і ще не перебудувалась, або написала старий код який треба рефакторити.
18 Що таке auto-waiting в Playwright і як він працює?
ЛокаториJunior

Перед кожною взаємодією з елементом Playwright виконує так звані Actionability Checks — набір перевірок що елемент готовий до дії. Для більшості дій це: елемент є в DOM (attached), видимий (visible), не анімується (stable), активний (enabled). Для fill() додатково — доступний для редагування (editable), для кліку — може отримувати події (receives events, тобто не перекритий іншим елементом). Timeout за замовчуванням — 30 секунд. Це означає що більшість часу не треба писати жодних явних очікувань — просто клікаєш і Playwright сам розбереться.

Але є ситуації коли waitFor() потрібен явно:

  • Чекаємо зміну URL — після кліку відбувається навігація на іншу сторінку
  • Чекаємо стан завантаження — коли важливо що всі запити завершились
  • Чекаємо появу або зникнення елемента явно — коли елемент не є ціллю дії, а просто індикатором стану (наприклад toast-повідомлення)
  • Чекаємо довільну JS умову — через waitForFunction() коли немає DOM-елемента для перевірки

⚠️ { force: true } — майже завжди антипатерн. Він вимикає Actionability Checks: Playwright більше не перевіряє чи елемент видимий, чи не перекритий, чи взагалі готовий — просто б'є подією по координатах. Це не "пришвидшення тесту", а вимкнення захисту.

Якщо force: true "полагодив" тест — значить тест тепер проходить попри реальну проблему: елемент перекритий модалкою, кнопка ще disabled, оверлей не зник. Юзер у такій ситуації клікнути б не зміг — а тест каже "все ок".

Виправдані випадки рідкісні: навмисна перевірка кліку по частково перекритому елементу, або обхід відомого бага стороннього віджета. І тоді поруч має бути коментар чому.

TypeScript — auto-waiting і коли waitFor() потрібен
// ✅ Auto-waiting — просто клікаємо, Playwright чекає сам
await page.getByRole('button', { name: 'Save' }).click();
// Playwright перевіряє: кнопка є в DOM? видима? не анімується? активна?
// Тільки коли всі умови виконані — клікає

// ── Коли waitFor() потрібен явно ────────────────────────────────

// 1. Чекаємо навігацію на іншу сторінку
await page.waitForURL('**/dashboard');
// Після кліку на Login — чекаємо поки URL зміниться на /dashboard

// 2. Чекаємо стан завантаження
await page.waitForLoadState('domcontentloaded');
// 'domcontentloaded' — HTML завантажений і розібраний
// 'networkidle' — немає активних мережевих запитів 500мс (обережно, може зависнути)

// 3. Чекаємо елемент-індикатор (не для дії, а для перевірки стану)
await page.locator('.toast-success').waitFor({ state: 'visible' });
// Toast з'явився — значить дія пройшла успішно
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
// Спінер зник — завантаження завершилось

// 4. Чекаємо JS умову в браузері (детальніше в Middle картці waitForFunction)
await page.waitForFunction(() => (window as any).dataLayer?.length > 0);
// Немає DOM-елемента — перевіряємо напряму через JavaScript
🔧 TypeScript / JS патерни в цьому прикладі async ({ page }) => {} — деструктуризація fixtures; { state: 'visible' } — options object pattern; waitForFunction(() => {}) — передача стрілочної функції як аргумент (функція першого класу / first-class function); (window as any) — type assertion в TypeScript: говоримо компілятору "довіряй мені, це any".
🗣 Як читати вголос Перший рядок — просто клікаємо кнопку, Playwright сам чекає поки вона стане видимою і активною, нічого додаткового не треба. Далі waitForURL — явно чекаємо поки URL відповідає патерну, зірочки це wildcard. waitForLoadState('networkidle') — чекаємо поки мережа затихне, тобто немає активних запитів більше 500мс. В конфігу timeout: 60_000 — це глобальний ліміт на весь тест у мілісекундах, expect.timeout — окремий ліміт на кожен expect.
19 Що не так з page.waitForTimeout()? Чому це антипатерн?
ЛокаториJunior

page.waitForTimeout(3000) — це фіксована пауза: тест зупиняється рівно на 3 секунди незалежно від того чи UI вже готовий чи ні. Це антипатерн з трьох причин: якщо UI готовий за 0.5 секунди — ти витрачаєш зайві 2.5 секунди; якщо UI не готовий за 3 секунди — тест впаде; і тести стають повільними і крихкими одночасно.

Playwright має auto-waiting — він сам чекає поки елемент готовий до взаємодії. Тому майже завжди замість waitForTimeout є правильніша альтернатива.

TypeScript — explicit sleep vs auto-waiting
// ❌ Антипатерн: фіксована пауза "на удачу"
await page.click('.add-to-cart');
await page.waitForTimeout(2000); // чекаємо 2с і сподіваємось, що toast встиг зʼявитись
await expect(page.locator('.toast')).toBeVisible();

// ✅ Чекаємо саме той стан, який нам потрібен
await page.click('.add-to-cart');
await expect(page.locator('.toast')).toBeVisible(); // сама ретраїться, без зайвої затримки
💡 Дві проблеми одразу: якщо 2000мс замало — тест все одно flaky; якщо забагато — тест уповільнюється даремно на кожному прогоні. Web-first assertion вирішує обидві: чекає рівно стільки, скільки треба, не більше.
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForTimeout(3000) — фіксована пауза в мілісекундах — АНТИПАТЕРН; await expect(locator).toBeVisible() — правильна альтернатива з auto-waiting; різниця: waitForTimeout завжди чекає 3 секунди, expect чекає рівно стільки скільки потрібно (до таймауту).
🗣 Як читати вголос waitForTimeout — це фіксована пауза, яка не привʼязана до жодного реального стану сторінки, вона просто блокує виконання на задану кількість мілісекунд і сподівається, що за цей час потрібна подія відбудеться. Проблема в тому, що це число завжди вгадане: якщо мережа повільніша звичайного — двох секунд може не вистачити і тест впаде, а якщо забагато — тест буде штучно повільним на кожному прогоні, навіть коли все відбувається миттєво. Web-first assertion типу expect(locator).toBeVisible() вирішує обидві проблеми одночасно, бо чекає не фіксований час, а саме ту умову, яка нам потрібна, і завершується одразу, як тільки вона виконалась, або падає з чітким таймаутом, якщо не виконалась взагалі. Тому в код-рев'ю waitForTimeout — це майже завжди червоний прапорець, окрім дуже рідкісних кейсів типу навмисного очікування анімації, для якої немає detectable DOM-стану.
20 Чому waitForLoadState('networkidle') вважається пасткою, а не надійним рішенням?
ЛокаториJunior

waitForLoadState('networkidle') — стан що означає "немає жодного мережевого запиту 500 мілісекунд". Здається зручним способом дочекатись поки "все завантажилось". Але на практиці це частий антипатерн.

Проблема: сучасні сайти мають постійний фоновий трафік — аналітика надсилає дані, WebSocket тримає з'єднання, polling оновлює дані. Стан "тиші 500мс" може ніколи не настати — і тест зависне до таймауту.

TypeScript — networkidle і SPA з фоновим трафіком
// ❌ Здається надійним, але офіційно НЕ рекомендовано для SPA
await page.goto('/product/123');
await page.waitForLoadState('networkidle');
// Якщо сторінка має polling, analytics beacons, чат-віджет з
// періодичними запитами — "idle" (0 запитів 500мс) може НІКОЛИ не настати

// ✅ Чекаємо конкретний бізнес-сигнал готовності
await page.goto('/product/123');
await expect(page.locator('[data-testid="pdp-price"]')).toBeVisible();

// waitForResponse — чекаємо конкретний запит, але:
// ⚠️ НЕ гарантує що UI вже відрендерив відповідь!
// Потрібно додатково чекати елемент або використовувати Promise.all
// щоб підписатись ДО дії яка тригерить запит:
const [response] = await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/product')),
  page.locator('.load-more').click(), // дія що тригерить запит
]);
💡 Офіційна позиція Playwright: документація прямо застерігає від networkidle для сучасних SPA — рекомендується чекати конкретний елемент чи мережевий запит, а не "тишу в мережі" загалом.
🔧 TypeScript / JS патерни в цьому прикладі waitForLoadState('networkidle') — рядкове enum значення; умова networkidle: жодного мережевого запиту 500мс; проблема: analytics, websocket, polling — постійні запити що не дають "idle" настати; альтернатива: waitForURL() або waitForSelector().
🗣 Як читати вголос networkidle означає "немає мережевих запитів останні 500мс" — звучить як надійний сигнал "сторінка повністю завантажилась", але на практиці сучасні SPA рідко справді затихають: аналітика типу GTM шле beacon-запити періодично, чат-віджети тримають long-polling з'єднання, якісь сторонні скрипти роблять фонові виклики нескінченно. Якщо такий фоновий трафік є, умова "0 активних запитів 500мс" може просто ніколи не виконатись, і тест зависне до таймауту замість швидкого фейлу або проходження. Тому правильний підхід — не чекати абстрактну тишу в мережі, а чекати конкретний сигнал, який реально означає готовність: видимість ключового елемента на сторінці через web-first assertion, або явне очікування конкретної мережевої відповіді через waitForResponse, якщо нас цікавить саме дані з певного API endpoint. Це і швидше, бо не чекає зайвого, і надійніше, бо привʼязано до того, що нам справді потрібно для тесту.
21 Скільки рівнів таймаутів є в Playwright і чому тест падає попри те що ти поставила timeout: 60000?
ЛокаториSenior

Більшість людей думають що в Playwright є один таймаут. Насправді їх три, і вони незалежні один від одного. Класична ситуація: ти поставила timeout: 60000 в конфізі і все одно тест падає через 5 секунд — і не розумієш чому. Відповідь: ти поміняла не той таймаут.

  • Test timeout — скільки часу має весь тест від першого до останнього рядка. Якщо тест не завершився за цей час — Playwright його вбиває. За замовчуванням 30 секунд.
  • Action timeout — скільки Playwright чекає поки окрема дія виконається: click(), fill(), hover(). Це той самий auto-waiting. За замовчуванням немає окремого обмеження (наслідує test timeout), але можна встановити явно через use: { actionTimeout }.
  • Expect timeout — скільки web-first assertion ретраїться перш ніж здатись. expect(locator).toBeVisible() перевіряє умову знову і знову. За замовчуванням 5 секунд.
  • Navigation timeout — окремий таймаут для goto(), waitForURL(), waitForNavigation(). Встановлюється через use: { navigationTimeout }. Часто пропускають — а саме він винен коли goto() падає на повільному стейджі.
  • Global timeout — ліміт на весь прогін цілком, а не на один тест: globalTimeout: 3_600_000 в конфізі. Захищає CI від зависання на години якщо щось пішло зовсім не так.

Ієрархія перевизначення — від загального до конкретного:

globalTimeout (весь прогін)
    ↓
Конфіг: timeout / expect.timeout / use.actionTimeout / use.navigationTimeout
    ↓ перевизначається
test.describe.configure({ timeout: 60_000 })     ← рівень describe
    ↓ перевизначається
test('...', { timeout: 60_000 }, async () => {}) ← рівень тесту
test.setTimeout(60_000)                          ← всередині тесту
    ↓ перевизначається
await locator.click({ timeout: 10_000 })         ← рівень окремої дії
await expect(locator).toBeVisible({ timeout: 10_000 })  ← рівень assertion

// Чим конкретніше — тим вищий пріоритет. Найточніший виграє.

⚠️ Чому важливо розрізняти: якщо тест падає з TimeoutError — перше питання "який саме таймаут спрацював?" Класична пастка: збільшила timeout: 60000 в конфізі, а падає все одно через 5 секунд — бо це був expect timeout, він живе окремо в expect: { timeout }. Різний діагноз → різне рішення.

playwright.config.ts — три таймаути на рівні конфігурації
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // 1. TEST TIMEOUT — весь тест має вкластись у цей час
  timeout: 60_000, // 60 секунд (за замовч. 30с)

  // 2. EXPECT TIMEOUT — скільки web-first assertions ретраяться
  expect: {
    timeout: 10_000, // 10 секунд (за замовч. 5с)
  },

  use: {
    // 3. ACTION TIMEOUT — скільки чекати на кожну окрему дію
    actionTimeout: 15_000, // 15 секунд (за замовч. немає, ділить з test timeout)

    // 4. NAVIGATION TIMEOUT — окремо для goto(), reload(), waitForNavigation()
    navigationTimeout: 30_000, // 30 секунд
  },
});
TypeScript — override таймаутів для конкретного тесту або дії
// Override test timeout для одного повільного тесту
test('повільний e2e сценарій', async ({ page }) => {
  test.setTimeout(120_000); // 2 хвилини тільки для цього тесту
  await page.goto('/checkout');
  // ... довгий сценарій ...
});

// Override action timeout для конкретної повільної дії
await page.locator('.heavy-animation-btn').click({ timeout: 20_000 });
await page.locator('#slow-input').fill('value', { timeout: 10_000 });

// Override expect timeout для конкретного assertion
await expect(page.locator('.lazy-loaded-image'))
  .toBeVisible({ timeout: 15_000 }); // ця картинка грузиться довго — даємо більше часу

// Override для групи тестів через describe
test.describe('повільні інтеграційні тести', () => {
  test.setTimeout(90_000); // всі тести в цьому describe отримають 90с

  test('тест 1', async ({ page }) => { /* ... */ });
  test('тест 2', async ({ page }) => { /* ... */ });
});
TypeScript — як читати повідомлення про помилку і розуміти який таймаут спрацював
// ── Приклад 1: впав expect timeout ──
// TimeoutError: expect(locator).toBeVisible() timeout 5000ms exceeded
// → елемент не зʼявився за 5 секунд
// → або елемент взагалі відсутній, або треба збільшити expect timeout
await expect(page.locator('.modal')).toBeVisible(); // ← тут впав

// ── Приклад 2: впав action timeout ──
// TimeoutError: page.click: timeout 30000ms exceeded
// → елемент знайдений, але не став клікабельним (перекритий, disabled, анімується)
await page.locator('.submit-btn').click(); // ← тут впав

// ── Приклад 3: впав test timeout ──
// Test timeout of 30000ms exceeded
// → весь тест не вклався — можливо завис waitForEvent або нескінченний цикл
// → перевір чи немає missing await, або збільш test timeout
💡 Практичне правило для налаштування:
Не збільшуй test timeout як першу реакцію на падіння — спочатку зрозумій який саме таймаут і чому. Збільшення test timeout маскує проблему замість того щоб вирішити її. Якщо конкретна дія повільна — збільш action timeout тільки для неї. Якщо assertion чекає lazy-loaded контент — збільш expect timeout тільки для нього.
🔧 TypeScript / JS патерни в цьому прикладі timeout: 60_000 — числовий літерал з underscore separator (для читабельності, ідентичний 60000); expect: { timeout: 5_000 } — вкладений об'єкт в конфігурації; { timeout: 10000 } — options object для перевизначення таймауту локально.
🗣 Як читати вголос В Playwright є три незалежних таймаути, і плутанина між ними — одна з найчастіших причин "чому тест все одно падає попри те що я збільшила timeout". Test timeout — це максимальний час на весь тест цілком, налаштовується через timeout в конфізі або test.setTimeout() для конкретного тесту. Action timeout — це скільки auto-waiting чекає поки елемент стане готовим до дії, налаштовується через actionTimeout в use блоці конфігу або прямо в дії через опцію { timeout }. Expect timeout — це скільки web-first assertion ретраїться перевіряючи умову, за замовчуванням 5 секунд, налаштовується через expect.timeout в конфізі або третім аргументом в конкретному assertion. Коли тест падає з TimeoutError, перше що треба зробити — прочитати повне повідомлення помилки: якщо там написано expect(locator).toBeVisible() timeout exceeded — це expect timeout; якщо page.click: timeout exceeded — це action timeout; якщо просто Test timeout of Xms exceeded — це test timeout і треба шукати де тест завис взагалі. Різний діагноз вимагає різного рішення, і збільшення test timeout як першої реакції — це майже завжди неправильно, бо це маскує реальну проблему замість її вирішення.
22 Що таке web-first assertions і чим вони відрізняються від звичайного expect/assert?
ЛокаториJunior

Звичайна перевірка в JavaScript виконується один раз і одразу: отримала значення — порівняла — результат. Але в браузерних тестах UI змінюється асинхронно: кнопка може ще не з'явитись, текст ще не оновитись. Перевірка "зараз" дасть хибний результат.

Web-first assertions — це expect(locator).toBeVisible() і подібні. Вони не перевіряють одразу — вони ретраяться з невеликим інтервалом поки умова не стане true або не вийде таймаут. Тобто вони самі чекають потрібного стану.

TypeScript — web-first vs одноразова перевірка
// ❌ Одноразова перевірка: isVisible() повертає стан ЗАРАЗ, в цю мілісекунду
const visible = await page.locator('.toast-message').isVisible();
expect(visible).toBe(true); // flaky: toast міг ще не встигнути зʼявитись

// ✅ Web-first assertion: сама себе ретраїть до 5 секунд (за замовчуванням)
await expect(page.locator('.toast-message')).toBeVisible();
// Playwright перевіряє стан раз у кілька десятків мс, поки умова
// не стане true АБО не вийде timeout — тому не треба ручних wait
TypeScript — toBeVisible / toBeAttached / toBeHidden / toHaveCount: часто плутають
// ── toBeAttached() — елемент Є В DOM (видимість не важлива) ─────
await expect(locator).toBeAttached();
// ✅ пройде для: <div style="display:none">текст</div>
// Елемент існує в дереві, але юзер його не бачить

// ── toBeVisible() — елемент є В DOM І ВИДИМИЙ ───────────────────
await expect(locator).toBeVisible();
// ❌ впаде для: <div style="display:none">
// Видимий = має непорожній bounding box і не має visibility:hidden

// ── toBeHidden() — НЕ видимий: або прихований, АБО взагалі не в DOM
await expect(locator).toBeHidden();
// ✅ пройде і для display:none, і для повністю відсутнього елемента
// ⚠️ Головна пастка: toBeHidden() != not.toBeVisible()? Ні, це те саме.
// Але toBeHidden() != not.toBeAttached() — різні речі!

// ── Перевірити що елемента НЕМАЄ в DOM взагалі ──────────────────
await expect(locator).not.toBeAttached();  // точна перевірка "видалений з DOM"
await expect(locator).toHaveCount(0);      // те саме іншими словами

// ── toHaveCount() — єдиний що НЕ падає на strict mode ───────────
await expect(page.locator('.product-card')).toHaveCount(12);
// toBeVisible() на 12 елементах → strict mode violation
// toHaveCount() працює зі списком за задумом
💡 Як розпізнати web-first assertion: якщо в expect() передається Locator напряму (expect(locator).toBeVisible()) — це web-first, він сам чекає. Якщо передається вже обчислене значення (expect(await locator.isVisible()).toBe(true)) — це звичайний one-shot assert, ретраїв немає.

expect.configure() — дозволяє змінити поведінку expect для конкретного тесту або блоку: expect.configure({ timeout: 10000 }) встановлює таймаут 10с для всіх assertions після цього рядку. Корисно коли знаєш що певна операція повільна.

Soft assertions (expect.soft()) — перевірка яка не зупиняє тест при падінні: тест продовжується і всі soft-помилки збираються і виводяться разом в кінці. Детальніше в окремій картці.
🔧 TypeScript / JS патерни в цьому прикладі expect(locator).toBeVisible() vs expect(await locator.textContent()) — різниця: перший передає Locator об'єкт (lazy), другий передає результат await (значення вже отримане); await locator.textContent() — спочатку виконується await, потім результат передається в expect.
🗣 Як читати вголос Різниця тут не в синтаксисі, а в поведінці в часі. Коли ми пишемо expect(await locator.isVisible()).toBe(true) — спочатку повністю виконується isVisible(), який повертає булеве значення стану елемента саме в цю мілісекунду, а вже потім expect порівнює це застигле значення з очікуваним. Якщо елемент з'явиться на екрані через 200 мс після цієї перевірки — тест все одно впаде, хоча по суті все працює правильно, просто з невеликою затримкою. Web-first assertion, навпаки, виглядає як await expect(locator).toBeVisible() — тут ми передаємо в expect сам Locator, не обчислене значення, і Playwright бере на себе ретраї: він перевіряє умову toBeVisible повторно, з невеликим інтервалом, протягом дефолтного таймауту, поки умова не стане істинною, і тільки тоді, коли час вийде, а умова так і не виконалась, тест падає. Це усуває цілий клас flaky-тестів, де причина нестабільності — не логічна помилка, а просто різниця в мілісекундах між тим, коли елемент реально зʼявився, і коли ми його перевірили.
23 Що таке soft assertions і коли їх використовувати?
ЛокаториJunior
Soft assertions не зупиняють тест при першому провалі — вони накопичують помилки і репортять всі разом.
TypeScript
test('product page validations', async ({ page }) => {
  await page.goto('/product/123');

  // Зупиняє тест при першій помилці
  await expect(page.locator('h1')).toBeVisible();

  // Soft: продовжує навіть якщо ці впадуть
  await expect.soft(page.locator('.price')).toBeVisible();
  await expect.soft(page.locator('.rating')).toBeVisible();
  await expect.soft(page.locator('.stock-badge')).toHaveText(/In Stock/);

  // Всі помилки виведуться разом
});
🔧 TypeScript / JS патерни в цьому прикладі expect.soft(locator) — виклик методу на об'єкті expect (не функції); expect.soft повертає той самий expect API — це fluent interface; test.info().errors — доступ до вкладеної властивості через ланцюжок крапок.
🗣 Як читати вголос Перший expect — звичайний, якщо h1 не знайдено тест одразу зупиняється. Далі expect.soft — викликаємо статичний метод soft на об'єкті expect, передаємо локатор і чейнимо перевірку. Якщо ця перевірка падає — тест продовжує виконуватись далі. Всі накопичені soft-помилки виводяться разом в кінці тесту в одному звіті.
💡 Коли використовувати: валідація сторінки з кількома незалежними полями, де потрібно побачити всі проблеми одразу.
24 Що таке page.waitForFunction() і коли він потрібен якщо є web-first assertions?
ЛокаториSenior

Web-first assertions типу expect(locator).toBeVisible() самі ретраяться — але тільки для DOM-елементів. Що якщо умова яку треба перевірити — не про елемент на сторінці?

  • Чекаємо поки window.analyticsReady === true — глобальна змінна яку встановлює скрипт після ініціалізації
  • Чекаємо поки в window.dataLayer з'явиться конкретна подія
  • Чекаємо поки кількість елементів у React state досягне певного числа
  • Чекаємо поки анімація завершиться по конкретному CSS property, а не по видимості

Для всього цього немає локатора. page.waitForFunction() передає довільну JS-функцію яка виконується в браузері і повторюється поки не поверне truthy значення або не вийде таймаут.

Різниця від page.evaluate(): evaluate() виконується один раз і повертає результат. waitForFunction() виконується повторно з інтервалом поки умова не стане true — це evaluate з вбудованим polling.

Як читати код з waitForFunction — по кроках:

await page.waitForFunction(() => {
  return Array.isArray((window as any).dataLayer)
    && (window as any).dataLayer.length > 0;
});
  • await — чекаємо поки waitForFunction завершиться. Без нього тест піде далі не чекаючи.
  • () => { ... } — стрілочна функція без аргументів. Думай про неї як про "посилку коду яку ми відправляємо в браузер". Вона виконується не в Node.js, а всередині браузера.
  • window — глобальний об'єкт браузера. Доступний тому що код виконується в браузері.
  • (window as any) — TypeScript cast щоб компілятор не скаржився що dataLayer не задекларований на типі Window.
  • return true/false — якщо повернула truthy → Playwright зупиняється. Якщо falsy → чекає ~100мс і повторює. Якщо за таймаут не стало true → кидає TimeoutError.

Чому closure не працює і навіщо другий аргумент:

const expected = 48; // змінна в Node.js

// ❌ НЕ ПРАЦЮЄ — браузер не бачить Node.js змінні через closure
await page.waitForFunction(() => {
  return document.querySelectorAll('.product-card').length >= expected; // expected → undefined!
});

// ✅ ПРАВИЛЬНО — передаємо як другий аргумент
// Playwright серіалізує 48 в JSON і передає в браузер як параметр функції
await page.waitForFunction(
  (expectedCount) => {                  // параметр функції
    return document.querySelectorAll('.product-card').length >= expectedCount;
  },
  48                                    // ← стане значенням expectedCount
);

Коли потрібен async всередині:

// async потрібен якщо всередині браузера треба зробити fetch
await page.waitForFunction(async () => {
  const response = await fetch('/api/status'); // fetch — браузерний API
  const data = await response.json();
  return data.status === 'ready';
});
// В більшості випадків async не потрібен — window/document/CSS синхронні
TypeScript — Приклад 1: чекаємо ініціалізацію GTM / dataLayer
// GTM або analytics скрипт завантажується асинхронно
// Якщо перевірити window.dataLayer одразу — він може ще не існувати
test('GTM ініціалізувався', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // ❌ evaluate() — виконується ОДИН РАЗ прямо зараз
  // Якщо скрипт ще не завантажився — поверне undefined
  const ready = await page.evaluate(() => (window as any).dataLayer);

  // ✅ waitForFunction() — повторює кожні ~100мс поки не true
  await page.waitForFunction(() => {
    return Array.isArray((window as any).dataLayer)
      && (window as any).dataLayer.length > 0;
  });

  // Тепер точно є — дістаємо для перевірки
  const events = await page.evaluate(() => (window as any).dataLayer);
  console.log(events); // [{ event: 'page_view', ... }]
});
TypeScript — Приклад 2: чекаємо конкретну подію в dataLayer
test('подія add_to_cart зафіксована', async ({ page }) => {
  await page.goto('/product/trench-coat');
  await page.locator('button.add-to-cart').click();

  // Чекаємо саме цю подію — не просто що dataLayer існує
  await page.waitForFunction(() => {
    const dl = (window as any).dataLayer || [];
    return dl.some((e: any) => e.event === 'add_to_cart');
  });

  // Дістаємо подію і перевіряємо деталі
  const event = await page.evaluate(() =>
    (window as any).dataLayer.find((e: any) => e.event === 'add_to_cart')
  );
  expect(event.ecommerce.items[0].item_id).toBe('SKU-123');
});
TypeScript — Приклад 3: чекаємо завершення CSS анімації
test('модальне вікно повністю відкрилось', async ({ page }) => {
  await page.goto('/products');
  await page.locator('.quick-view').click();

  // toBeVisible() → true одразу коли opacity > 0
  // але ми хочемо дочекатись коли анімація ЗАВЕРШИЛАСЬ (opacity = 1)
  await page.waitForFunction(() => {
    const modal = document.querySelector('.modal');
    return getComputedStyle(modal!).opacity === '1';
  });

  // Анімація завершена — скріншот без змазаного переходу
  await expect(page.locator('.modal')).toHaveScreenshot('modal-open.png');
});
TypeScript — Приклад 4: передаємо аргумент з Node.js і налаштовуємо polling
test('список товарів повністю завантажений', async ({ page }) => {
  await page.goto('/women/dresses');

  // Передаємо 48 як аргумент з Node.js в браузер
  // Чекаємо конкретну кількість — не просто появу першого елемента
  await page.waitForFunction(
    (expectedCount) => {
      const items = document.querySelectorAll('.product-card');
      return items.length >= expectedCount;
    },
    48,                              // ← аргумент з Node.js
    { timeout: 10000, polling: 500 } // перевіряємо кожні 500мс
  );

  const count = await page.locator('.product-card').count();
  expect(count).toBe(48);
});

// polling: 'raf' — перевіряти на кожен requestAnimationFrame
// Ідеально для анімацій — синхронізовано з браузерним render loop
await page.waitForFunction(
  () => getComputedStyle(document.querySelector('.hero')!).opacity === '1',
  undefined,
  { polling: 'raf' }
);
TypeScript — передаємо аргументи з Node.js і налаштовуємо таймаут
// Передаємо аргумент з Node.js в браузер — той самий патерн що й у evaluate()
const expectedCount = 48;

await page.waitForFunction(
  (count) => {
    // count прийшов з Node.js — доступний тут в браузері
    const items = document.querySelectorAll('.product-card');
    return items.length >= count;
  },
  expectedCount, // ← передаємо як другий аргумент
  { timeout: 10000, polling: 500 } // таймаут 10с, перевіряємо кожні 500мс
);

// polling: 'raf' — перевіряти на кожен requestAnimationFrame (для анімацій)
await page.waitForFunction(
  () => {
    const el = document.querySelector('.animated-element');
    return getComputedStyle(el!).opacity === '1';
  },
  undefined, // аргумент не передаємо
  { polling: 'raf' } // синхронізовано з браузерним render loop
);
TypeScript — expect.poll() як альтернатива для простих випадків
// Для простих умов є зручніша альтернатива — expect.poll()
// Вона ретраїть довільну функцію як web-first assertion

// ✅ Коротше і читабельніше ніж waitForFunction для простих перевірок
await expect.poll(async () => {
  return await page.evaluate(() => (window as any).analyticsReady);
}, { timeout: 5000, intervals: [100, 250, 500] }).toBe(true);

// Різниця між waitForFunction і expect.poll():
// waitForFunction — функція виконується В БРАУЗЕРІ, має доступ до window/DOM
// expect.poll()  — функція виконується В NODE.JS, може робити будь-що: API запити, читати файли
await expect.poll(async () => {
  const response = await request.get('/api/status');
  return (await response.json()).status;
}).toBe('ready'); // ретраїть API запит поки не отримає 'ready'
💡 Коли що використовувати:
Умова про DOM-елемент → expect(locator).toBeVisible() або інший web-first assertion
Умова про стан браузера (window, dataLayer, CSS property) → page.waitForFunction()
Проста умова або умова в Node.js (API, файл) → expect.poll()
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForFunction(() => {}, argValue, { timeout, polling }) — три аргументи: функція, аргумент для передачі в браузер, options; (window as any).dataLayer — type assertion; Array.isArray() — статичний метод; dl.some((e: any) => condition) — Array.some() з предикатом.
🗣 Як читати вголос page.waitForFunction() потрібен коли умова яку треба перевірити не виражається через DOM-елемент і локатор. Web-first assertions типу expect(locator).toBeVisible() ретраяться — але тільки для елементів на сторінці. Якщо треба почекати поки window.analyticsReady стане true, або поки в dataLayer з'явиться конкретна подія, або поки CSS property анімованого елемента досягне певного значення — для цього немає локатора. waitForFunction() приймає довільну JS-функцію, виконує її в браузері де є доступ до window і document, і повторює з невеликим інтервалом поки вона не поверне truthy значення або не вийде таймаут. Це принципово відрізняється від page.evaluate() який виконується рівно один раз — waitForFunction це evaluate з вбудованим polling. Аргумент з Node.js передається другим параметром — той самий патерн що й у evaluate. Для простіших випадків є expect.poll() де функція виконується в Node.js і може робити будь-що — наприклад ретраїти API запит поки не прийде потрібний статус.
25 Що таке expect.poll() і коли його використовувати?
ЛокаториSenior

expect.poll() — асинхронний polling: ретраїть довільну async функцію в Node.js кожні N мс до таймауту. Ідеально для зовнішніх станів (БД, email, API) де немає DOM-елемента для перевірки.

Це не окремий механізм. expect.poll() використовує той самий retry-loop що й звичайні web-first assertions — просто замість того щоб повторно резолвити Locator, він повторно викликає твою функцію і передає результат у матчер. Тому і таймаути, і поведінка ретраїв тут ідентичні expect(locator).toBeVisible().

Три схожі механізми які часто плутають:

  • expect.poll(async () => value).toBe(x) — ретраїть функцію в Node.js, передає її результат у матчер. Для перевірки значення що змінюється з часом: статус в API, кількість листів, запис у БД.
  • await expect(async () => {...}).toPass() — ретраїть функцію яка сама містить assertions. Не повертає значення — просто повторює блок поки всі assertions всередині не пройдуть. Для ретраю групи перевірок разом.
  • page.waitForFunction(() => ...) — це інша категорія: виконує код всередині браузера, а не в Node.js. Має доступ до window, document. Не є заміною expect.poll() — вони працюють у різних середовищах.

Просте правило: перевіряєш значення з Node.js (API, БД) → expect.poll(). Ретраїш блок assertions → toPass(). Потрібен доступ до window/documentwaitForFunction(). Перевіряєш DOM-елемент → звичайний web-first assertion, нічого з цього не треба.

TypeScript
// Чекаємо поки API поверне правильний статус
await expect.poll(
  async () => {
    const res = await page.request.get('/api/jobs/abc123');
    return (await res.json()).status;
  },
  {
    message: 'Job should complete within 30s',
    timeout: 30_000,
    intervals: [500, 1000, 2000, 5000], // exponential backoff
  }
).toBe('completed');

// Чекаємо поки email прийде (через mailhog API)
await expect.poll(
  async () => {
    const res = await page.request.get('http://mailhog:8025/api/v2/messages');
    return (await res.json()).total;
  },
  { timeout: 15_000 }
).toBeGreaterThan(0);
🔧 TypeScript / JS патерни в цьому прикладі await expect.poll(async () => { return await ... }, { timeout, intervals }) — expect.poll приймає async функцію і options; intervals: [100, 250, 500] — масив чисел; async () => { const response = await request.get() — async arrow function з await всередині.
🗣 Як читати вголос Викликаємо статичний метод expect.poll — перший аргумент асинхронна функція-геттер що повертає значення для перевірки, другий — об'єкт опцій. message — текст помилки якщо перевірка не пройде за відведений час. timeout — максимальний час в мілісекундах. intervals — масив затримок між спробами в мілісекундах, це реалізація exponential backoff: спочатку швидко, потім рідше. Після poll(...) чейнимо звичайний матчер як .toBe('completed') або .toBeGreaterThan(0).
26 Як реалізувати Custom Expect Matchers?
ЛокаториSenior

Вбудовані matchers Playwright — toBeVisible(), toHaveText() — покривають загальні випадки. Custom matchers потрібні коли одна і та сама складна перевірка повторюється в багатьох тестах — але навіть тоді спочатку варто розглянути простішу альтернативу.

Простіша альтернатива — helper функція:

// helpers/assertions.ts — просто функція
function expectValidPrice(price: string) {
  expect(price).toMatch(/^\£\d+\.\d{2}$/);
  expect(price).not.toBe('');
  expect(parseFloat(price.replace('£', ''))).toBeGreaterThan(0);
}

// В тесті — те саме що custom matcher, але простіше:
expectValidPrice(price);

Єдина реальна перевага custom matcher над helper — повідомлення при падінні:

// Helper падає так:
// Expected: match /^\£\d+\.\d{2}$/
// Received: "abc"

// Custom matcher падає так:
// Expected price to be valid product price format
// Received: "abc"

Просте правило: helper функція — простіше, зрозуміліше, достатньо для 99% випадків. Custom matcher — тільки якщо важливо мати читабельне повідомлення при падінні яке точно описує що саме перевіряється. Якщо перевірка зустрічається 1-2 рази — просто напиши inline.

matchers/index.ts
import { expect } from '@playwright/test';

expect.extend({
  async toHaveProductPrice(locator: Locator, currency: string) {
    const text = await locator.textContent();
    const priceRegex = new RegExp(`\\${currency}\\d+\\.\\d{2}`);
    const pass = priceRegex.test(text ?? '');

    return {
      pass,
      message: () => pass
        ? `Expected price NOT to match ${currency} format but got: ${text}`
        : `Expected price to match ${currency}XX.XX but got: ${text}`,
    };
  },
});

// TypeScript типи для матчера
declare module '@playwright/test' {
  interface Matchers<R> {
    toHaveProductPrice(currency: string): Promise<R>;
  }
}

// Використання
await expect(page.locator('.price')).toHaveProductPrice('£');
await expect(page.locator('.price')).toHaveProductPrice('€');
🔧 TypeScript / JS патерни в цьому прикладі expect.extend({ toBeValidProduct(received: any) {} }) — метод extend приймає об'єкт де ключ це назва matcher, значення — функція; return { pass, message: () => string } — функція повертає об'єкт з методом message; () => string — функція що повертає рядок (lazy evaluation).
🗣 Як читати вголос Викликаємо expect.extend, передаємо об'єкт де ключ — назва матчера, значення — асинхронна функція. Перший аргумент функції — те що передається в expect(), тут це locator. Другий — наш кастомний аргумент currency. Всередині отримуємо текст елементу, перевіряємо регуляркою, повертаємо об'єкт з pass — булевий результат, і message — функція що повертає текст помилки. declare module — TypeScript module augmentation, розширюємо інтерфейс Matchers щоб IDE підказувала наш матчер з автодоповненням.
27 Чи треба щось особливе робити, щоб локатори бачили елементи в Shadow DOM?
ЛокаториSenior

Shadow DOM — механізм браузера для інкапсуляції компонентів. Елементи всередині shadow root ізольовані від основного документу. Використовується в Web Components і деяких UI бібліотеках.

Добра новина: Playwright автоматично "пронизує" open shadow root при пошуку локаторами. В більшості випадків нічого особливого робити не треба.

TypeScript — Shadow DOM "з коробки"
// Web Component з shadow root, наприклад кастомний <product-rating>
// <product-rating>         ← звичайний DOM, поза shadow root
//   #shadow-root           ← межа Shadow DOM
//     <span class="stars">★★★★☆</span>  ← всередині shadow root
// </product-rating>

// ✅ Playwright локатор проникає в shadow DOM автоматично
// Спочатку знаходимо host елемент, потім шукаємо всередині shadow root
await expect(
  page.locator('product-rating').locator('.stars')
).toHaveText('★★★★☆');
// або через getByRole що теж проникає через shadow DOM:
await expect(
  page.locator('product-rating').getByRole('img', { name: '4 stars' })
).toBeVisible();

// ❌ В Selenium довелось би явно заходити в shadow root:
// const shadowHost = driver.findElement(By.css('product-rating'));
// const shadowRoot = shadowHost.getShadowRoot();
// const stars = shadowRoot.findElement(By.css('.stars'));
💡 Виняток: closed shadow root ({ mode: 'closed' }) Playwright теж не бачить, як і будь-який інший інструмент — це навмисне обмеження браузера, а не Playwright.
🔧 TypeScript / JS патерни в цьому прикладі page.locator('.shadow-host .inner-element') — CSS selector через Shadow DOM; Playwright автоматично пронизує open shadow root; shadowRoot.querySelector() — браузерний API для явного доступу; { force: true } — options object для примусової дії.
🗣 Як читати вголос На відміну від Selenium, де для доступу до елементів усередині Shadow DOM потрібно явно знайти shadow host, дістати його shadow root і вже в ньому шукати елемент окремим викликом, у Playwright локатор автоматично "пронизує" відкритий (open) shadow DOM без жодного додаткового коду — ми просто пишемо звичайний CSS-селектор типу .stars, і Playwright сам розуміє, що шукати треба не тільки в основному документі, а й усередині будь-яких shadow roots на сторінці. Це особливо актуально для сучасних e-commerce платформ, які часто будуються на Web Components чи дизайн-системах із shadow DOM для інкапсуляції стилів. Єдиний виняток — closed shadow root, коли компонент явно створений з { mode: 'closed' }: тоді JavaScript у принципі не має доступу до цього shadow root, і це обмеження самого браузера, а не якесь упущення Playwright — жоден інструмент автоматизації не зможе туди зазирнути.
4 13
28 Що таке fixtures в Playwright? Як використовувати вбудовані?
FixturesJunior

Ми вже розібрали що таке fixtures концептуально і чому page/context/browser так називаються. Тепер подивимось на всі вбудовані fixtures разом — і коли який використовувати.

Fixtures — це механізм dependency injection. Вбудовані: page, browser, context, request.

Scope вбудованих fixtures: page, context, requesttest-scoped (створюються заново для кожного тесту). browserworker-scoped (один на весь worker-процес, не на весь прогін). При паралельному запуску з 4 workers — буде 4 окремих браузерних процеси.

TypeScript
test('login flow', async ({ page, context, request }) => {
  // page — новий Tab в ізольованому контексті
  await page.goto('/');

  // context — BrowserContext для cookies/storage
  await context.addCookies([{ name: 'session', value: 'abc', url: '/' }]);

  // request — API calls без браузера
  const res = await request.get('/api/users/me');
  await expect(res).toBeOK();
});
🔧 TypeScript / JS патерни в цьому прикладі async ({ page, context, request }) => {} — деструктуризація кількох fixtures одночасно; [{ name: 'session', value: 'abc', url: '/' }] — масив об'єктів (array of objects); await expect(res).toBeOK() — ланцюжок: спочатку отримуємо Response, потім перевіряємо.
🗣 Як читати вголос Функція test отримує об'єкт fixtures через деструктуризацію у фігурних дужках. page — це новий tab у ізольованому контексті, він живе тільки на час цього тесту. context — це BrowserContext, через нього можна керувати cookies і storage. context.addCookies — додаємо масив об'єктів-кукі: ім'я, значення, URL. request — окремий об'єкт для HTTP-запитів без браузера, request.get робить GET-запит і toBeOK перевіряє що статус 2xx.
29 Навіщо кастомні fixtures якщо є beforeEach? Як вибрати між ними?
FixturesMiddle

Проблема: коли в кожному тесті треба робити одне і те саме — наприклад, відкрити сторінку і залогінитись — ці рядки дублюються скрізь. Кастомна fixture прибирає це дублювання: ти описуєш підготовку один раз і просто просиш її в тесті за іменем.

Fixture — три частини:

myFixture: async ({ page }, use) => {
  // 1. SETUP — виконується ДО тесту
  await page.goto('/cart');

  await use(page); // 2. use() — Playwright зупиняється тут і запускає тест
                   //    коли тест завершився — Playwright повертається сюди

  // 3. TEARDOWN — виконується ПІСЛЯ тесту (навіть якщо тест впав)
  await page.goto('/');
}

use() — розділювач між setup і teardown. Без нього Playwright не знав би коли запускати тест. В use() передаємо будь-що що підготували — page, об'єкт, рядок або undefined. В тесті пишемо ім'я fixture в деструктуризації — воно має збігатись з назвою в extend().

Fixture vs beforeEach:

  • beforeEach достатньо — коли треба просто відкрити сторінку і нічого більше
  • Fixture краща — коли є teardown (в одному місці), коли треба передати значення в тест (beforeEach не може), або коли той самий setup потрібен у кількох файлах

Fixture vs глобальна змінна (let db: any):

Глобальна змінна ламається при паралельному виконанні — кілька тестів одночасно пишуть в одну змінну. Fixture дає кожному тесту свій власний екземпляр ресурсу. Плюс якщо setup впав — fixture кидає зрозумілу помилку, а глобальна змінна лишається undefined.

🔧 TypeScript / JS патерни в цьому прикладі async ({ page }, use) => {} — стрілочна async функція з деструктуризацією і use як callback; await use(value) — Playwright викличе тест з нашим значенням; ім'я в extend() = ім'я в деструктуризації тесту.
🗣 Як читати вголос Кастомна fixture вирішує проблему дублювання setup. Fixture має три частини: setup до use(), тест всередині, і teardown після. use() — розділювач: Playwright зупиняється на ньому, запускає тест, повертається і виконує teardown навіть якщо тест впав. В use() передаємо що підготували — page, об'єкт, рядок. В тесті пишемо ім'я fixture в деструктуризації — воно збігається з назвою в extend(). Fixture краща за beforeEach коли є teardown або коли треба передати значення в тест. Краща за глобальну змінну бо при паралельному виконанні кожен тест отримує свій власний екземпляр ресурсу.
30 Що таке кастомні fixtures і як їх створювати?
FixturesMiddle

Кастомні fixtures розширюють вбудовані — додають авторизацію, page objects або тестові дані.

Навіщо {} першим аргументом: перший параметр fixture — це об'єкт де ти запитуєш інші fixtures. Якщо потрібен page — пишеш { page }. Якщо нічого не потрібно — все одно треба поставити щось на місці першого параметра бо функція приймає два аргументи: (fixtures, use). Тому пишемо {} — порожня деструктуризація, означає "перший аргумент є але мені нічого з нього не треба".

Ключовий момент: Playwright не запускає всі fixtures проєкту перед кожним тестом. Він дивиться лише на те, що тест реально попросив у своїй сигнатурі — async ({ page }) проти async ({ authenticatedPage }) — і будує тільки той підграф залежностей, який потрібен саме для цього набору. Якщо authenticatedPage внутрішньо запитує { page }, Playwright спочатку створить page, а решту fixtures проєкту навіть не торкне. Fixture по суті не каже "виконай це" — вона каже "ось що мені потрібно, щоб працювати", а Playwright сам вирішує що і в якому порядку створити виходячи з цих запитів.

// Потрібен page — беремо його
loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }

// Нічого не потрібно — порожня деструктуризація
dbConnection: async ({}, use) => { const db = await connectDB(); await use(db); }
fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductPage } from '../pages/ProductPage';

type MyFixtures = {
  loginPage: LoginPage;
  productPage: ProductPage;
  authenticatedPage: Page;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  authenticatedPage: async ({ page }, use) => {
    // Setup: логін через API (швидше ніж через UI)
    await page.request.post('/api/login', {
      data: { email: 'test@test.com', password: 'pass' }
    });
    await use(page);  // <-- тест виконується тут
    // Teardown: після use()
    await page.request.post('/api/logout');
  },
});

export { expect };
Використання в тесті
import { test, expect } from '../fixtures';

test('already authenticated', async ({ authenticatedPage, loginPage }) => {
  await authenticatedPage.goto('/profile');
  // вже залогінений, fixture відпрацьовує setup автоматично
});
🔧 TypeScript / JS патерни в цьому прикладі base.extend({ fixtureName: async ({}, use) => {} }) — метод extend приймає об'єкт де ключі це назви fixtures, значення — async функції; await use(resource) — use як callback-функція яку Playwright викличе з нашим значенням.

Порядок створення fixtures при залежностях: якщо fixture A залежить від fixture B — Playwright гарантує порядок: B setup → A setup → тест → A teardown → B teardown. Зворотній порядок teardown — як стек. Це важливо на Senior інтерв'ю.

box: true — опція для fixtures (Playwright 1.46+): { scope: 'test', box: true }. Box fixture не показується в Trace Viewer як окремий крок — всі дії всередині неї відображаються в контексті тесту. Корисно для "прозорих" utility fixtures.

Generator / yield: function* — зірочка робить функцію генератором. У fixtures yield db робить те саме що await use(db). В більшості команд пишуть через use() — він простіший. yield можна зустріти в чужому коді.
🗣 Як читати вголос Імпортуємо test as base — перейменовуємо щоб не конфліктувати з нашим новим test. Оголошуємо TypeScript тип MyFixtures з іменами наших fixture-ів. Потім base.extend<MyFixtures>(...) — передаємо generic з нашим типом і об'єкт де кожен ключ — назва fixture. Кожна fixture — асинхронна функція з двома аргументами: деструктурованими вбудованими fixtures і функцією use. Все що до await use(...) — це setup, саме в use передаємо готове значення для тесту, все після — teardown. Експортуємо наш test і тепер тести імпортують його замість стандартного.
31 Що таке fixtures з yield? Чим це відрізняється від use() і коли краще використовувати?
FixturesSenior

Ми вже знаємо як писати fixtures через use(): setup до виклику, teardown після. yield — це альтернативний синтаксис для того самого, але через JavaScript генератор. Результат абсолютно однаковий — різниця тільки в синтаксисі.

// З use() — функція приймає use як аргумент
myFixture: async ({}, use) => {
  // setup
  await use(value); // ← тест виконується тут
  // teardown
}

// З yield — функція-генератор "зупиняється" на yield поки тест виконується
myFixture: async function* ({}) {
  // setup
  yield value; // ← тест виконується тут
  // teardown
}

yield читабельніший у двох випадках: коли потрібен try/finally для гарантованого teardown навіть при помилці в тесті, і коли є кілька ресурсів кожен з яких треба прибирати окремо.

На практиці: більшість команд використовують use() — він простіший і не потребує знання генераторів. yield — опціональний стиль, але його можна зустріти в чужому коді тому треба розуміти.

TypeScript — use() vs yield: той самий результат, різний синтаксис
// ── З use() ──────────────────────────────────────────────────────
const test = base.extend({
  dbConnection: async ({}, use) => {
    const db = await connectToDatabase(); // setup
    await use(db);                          // тест виконується тут
    await db.disconnect();                  // teardown
  },
});

// ── З yield — той самий результат ────────────────────────────────
const test = base.extend({
  dbConnection: async function* () {
    const db = await connectToDatabase(); // setup
    yield db;                               // тест виконується тут
    await db.disconnect();                  // teardown
  },
});

// В тесті використовується однаково:
test('тест з БД', async ({ dbConnection }) => {
  const users = await dbConnection.query('SELECT * FROM users');
  expect(users.length).toBeGreaterThan(0);
});
TypeScript — де yield реально кращий: гарантований teardown через try/finally
// ❌ З use() — try/finally виглядає незграбно
dbConnection: async ({}, use) => {
  const db = await connectToDatabase();
  try {
    await use(db); // якщо тест впаде з помилкою...
  } finally {
    await db.disconnect(); // ...disconnect виконається в будь-якому разі
  }
},

// ✅ З yield — try/finally читається природньо
dbConnection: async function* () {
  const db = await connectToDatabase();
  try {
    yield db; // тест виконується тут
  } finally {
    await db.disconnect(); // завжди виконається — навіть якщо тест впав
  }
},

// Примітка: Playwright і без try/finally гарантує виконання teardown
// (код після use() або yield завжди виконується)
// try/finally потрібен тільки якщо setup сам може впасти і треба прибрати часткові ресурси
TypeScript — кілька ресурсів з окремим cleanup для кожного
// З yield кілька ресурсів виглядають чисто
testEnvironment: async function* () {
  const db = await connectToDatabase();
  try {
    const server = await startMockServer();
    try {
      const cache = await initCache();
      try {
        yield { db, server, cache }; // тест отримує всі три ресурси
      } finally {
        await cache.flush();        // прибираємо cache першим
      }
    } finally {
      await server.stop();         // потім зупиняємо сервер
    }
  } finally {
    await db.disconnect();         // останнім закриваємо БД
  }
},

// В тесті:
test('інтеграційний тест', async ({ testEnvironment }) => {
  const { db, server, cache } = testEnvironment;
  // ...
});
💡 Коротко:
use() — стандартний підхід, простіший, використовується найчастіше
yield — альтернатива через генератор, той самий результат, зручніший коли є try/finally
Обидва підходи можна змішувати в одному test.extend() — це нормально
🔧 TypeScript / JS патерни в цьому прикладі async function* () {} — generator function: зірочка після function робить її генератором; yield value — зупиняє генератор і "повертає" значення; після resume (продовження) — виконується код після yield; try/finally в генераторі — teardown завжди виконується навіть при помилці.
🗣 Як читати вголос Fixture з yield — це альтернативний синтаксис для того ж самого що робить use(): код до yield це setup, після yield це teardown, і сам yield це точка де виконується тест. Відрізняється тільки синтаксис: замість звичайної async функції яка приймає use як аргумент — функція-генератор з зірочкою async function*. На практиці більшість команд пишуть через use() бо це простіше і не потребує розуміння генераторів. yield стає зручнішим в одному конкретному випадку: коли потрібен try/finally щоб гарантувати cleanup навіть якщо сам тест або частина setup впала з помилкою — з yield цей патерн читається природньо як звичайний try/finally блок, а з use() виглядає трохи незграбно бо треба огортати сам виклик use() в try блок.
32 Що таке worker-scoped fixtures і в чому відмінність від test-scoped?
FixturesMiddle

Worker — це окремий Node.js процес який Playwright запускає для виконання тестів. Якщо у тебе workers: 4 в конфізі — Playwright запускає 4 окремих процеси, і кожен виконує свою частину тестів.

Test-scoped (за замовчуванням) — fixture створюється заново для кожного тесту і знищується після нього. Кожен тест отримує чисту ізольовану копію:

Worker 1:
  Тест 1 → створити fixture → тест → знищити fixture
  Тест 2 → створити fixture → тест → знищити fixture
  Тест 3 → створити fixture → тест → знищити fixture

Worker-scoped — fixture створюється один раз коли worker стартує і знищується коли worker завершує роботу. Всі тести в цьому worker використовують одну і ту саму копію:

Worker 1:
  → створити fixture (один раз)
  Тест 1 → використовує fixture
  Тест 2 → використовує ту саму fixture
  Тест 3 → використовує ту саму fixture
  → знищити fixture (один раз)

Навіщо worker-scoped? Коли fixture дорога у створенні — наприклад підключення до бази даних займає 2 секунди. Якщо test-scoped — підключення відбувається для кожного тесту. Якщо worker-scoped — підключення один раз і всі тести в цьому worker використовують його.

Важливо: worker-scoped fixtures не шаряться між різними workers. Worker 1 і Worker 2 мають свої окремі екземпляри — ізоляція між workers зберігається.

TypeScript
type WorkerFixtures = { dbConnection: Database };

export const test = base.extend<{}, WorkerFixtures>({
  // Другий параметр extend<TestFixtures, WorkerFixtures>
  dbConnection: [async ({}, use) => {
    const db = await Database.connect(process.env.DB_URL!);
    await use(db);            // ← одне з'єднання на Worker
    await db.disconnect();   // teardown після всіх тестів воркера
  }, { scope: 'worker' }],   // ← ключовий параметр
});

// Тепер всі тести в файлі використовують одне DB з'єднання
test('query users', async ({ dbConnection }) => {
  const users = await dbConnection.query('SELECT * FROM users');
  expect(users).toHaveLength(10);
});
🔧 TypeScript / JS патерни в цьому прикладі scope: 'worker' — рядкове значення enum; fixture з scope — один екземпляр на весь worker-процес замість кожного тесту; async ({}, use) => {} — порожня деструктуризація коли інші fixtures не потрібні; await db.close() — cleanup після yield/use.
🗣 Як читати вголос base.extend приймає два generic параметри у кутових дужках: перший — test-scoped fixtures, другий — worker-scoped. Для worker-scoped fixture значення — не просто функція, а масив з двох елементів: функція і об'єкт з { scope: 'worker' }. Це ключова відмінність синтаксису. Все інше так само: use(db) передає з'єднання в тест, після use — teardown. Але тепер db.connect викликається один раз на весь worker і всі тести в ньому отримують той самий екземпляр.
33 Що таке auto: true у fixtures і коли це краще ніж beforeEach?
FixturesSenior

Звичайна фікстура виконується тільки якщо ти явно вказала її в параметрах тесту:

test('мій тест', async ({ page, myFixture }) => { ... })
//                                  ↑ треба явно попросити

auto: true змінює це — фікстура виконується для кожного тесту автоматично, навіть якщо тест про неї нічого не знає і не вказував її в параметрах.

Навіщо це потрібно? Є речі які треба робити перед або після кожного тесту, але не хочеться засмічувати параметри кожного тесту:

  • Логувати початок і кінець кожного тесту в зовнішню систему
  • Перевіряти що немає JS-помилок на сторінці після кожного тесту
  • Встановлювати глобальний мок для аналітики щоб вона не заважала тестам
  • Робити скріншот після кожного тесту незалежно від результату

Різниця від beforeEach: auto: true fixture визначається один раз у спільному файлі і автоматично застосовується до всіх тестів які використовують цей test.extend(). beforeEach можна винести в базовий test або shared helper, але auto fixtures масштабуються значно краще — setup і teardown в одному місці, не потрібен окремий afterEach, і типізація через TypeScript працює автоматично.

Як читати auto fixture по кроках
export const test = base.extend({
// ↑ беремо стандартний Playwright test і розширюємо новою фікстурою

  autoLogger: [async ({ page }, use, testInfo) => {
// ↑ назва фікстури. Ніколи не пишемо { autoLogger } в тесті — вона виконується сама
// { page } — інші фікстури які нам потрібні всередині
// use    — функція яку треба викликати щоб "дати місце" тесту
// testInfo — метадані: назва тесту, статус, шлях до файлу

    console.log(`▶ Починаємо: ${testInfo.title}`);
    // ↑ все ДО use() виконується ДО тесту (setup)

    await use(undefined);
    // ↑ НАЙВАЖЛИВІШИЙ рядок. Тут виконується сам тест.
    // undefined бо ми нічого не передаємо в тест
    // Все ДО цього — setup. Все ПІСЛЯ — teardown.

    console.log(`✓ Завершено: ${testInfo.title}`);
    // ↑ все ПІСЛЯ use() виконується ПІСЛЯ тесту (teardown)

  }, { auto: true }],
// ↑ { auto: true } — другий елемент масиву
// Каже Playwright: "запускай це завжди, без оголошення в тесті"
});
TypeScript — базовий auto fixture: логування кожного тесту
// fixtures/base.ts — визначаємо auto fixture один раз
import { test as base } from '@playwright/test';

export const test = base.extend({
  // auto: true — виконується для КОЖНОГО тесту автоматично
  // { auto: true } замість просто значення
  autoLogger: [async ({ page }, use, testInfo) => {
    // Setup — виконується ДО тесту
    console.log(`▶ Починаємо: ${testInfo.title}`);
    const startTime = Date.now();

    // use() — тут виконується сам тест
    await use(undefined); // undefined бо нічого не передаємо в тест

    // Teardown — виконується ПІСЛЯ тесту
    const duration = Date.now() - startTime;
    console.log(`✓ Завершено: ${testInfo.title} (${duration}мс)`);
  }, { auto: true }], // ← ось тут auto: true
});
Як читати — приклад 2: перевірка JS-помилок по кроках
checkNoJsErrors: [async ({ page }, use) => {
  const errors: Error[] = [];

  page.on('pageerror', error => errors.push(error));
  // ↑ підписуємось ДО use() — тому слухач активний з першої секунди тесту
  // якби підписались ПІСЛЯ use() — вже було б пізно, тест закінчився

  await use(undefined);
  // ↑ тест виконується. errors[] накопичує помилки якщо є

  expect(errors, `JS помилки:
${errors.map(e => e.message).join('
')}`).toHaveLength(0);
  // ↑ ПІСЛЯ тесту — перевіряємо що errors[] порожній
  // другий аргумент expect() — кастомне повідомлення при фейлі
  // якщо помилок не було — рядок проходить, тест завершується нормально
}, { auto: true }],

// Тест НІЧОГО не знає про checkNoJsErrors:
test('відкриваємо PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');
  await expect(page.locator('h1')).toBeVisible();
  // Якщо на сторінці є JS-помилка — тест впаде ПІСЛЯ цього рядка
  // з повідомленням "JS помилки: TypeError: cannot read..."
});
TypeScript — auto fixture для перевірки JS-помилок після кожного тесту
// fixtures/base.ts
export const test = base.extend({
  // Перевіряємо JS-помилки для КОЖНОГО тесту — тест не знає про це
  checkNoJsErrors: [async ({ page }, use) => {
    const errors: Error[] = [];

    // Підписуємось до тесту
    page.on('pageerror', error => errors.push(error));

    // Тест виконується
    await use(undefined);

    // Після тесту — перевіряємо що не було JS-помилок
    expect(
      errors,
      `JS помилки на сторінці:\n${errors.map(e => e.message).join('\n')}`
    ).toHaveLength(0);
  }, { auto: true }],
});

// tests/product.spec.ts — тест не знає про checkNoJsErrors
// але він все одно виконується після кожного тесту
test('відкриваємо PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');
  // Якщо на сторінці є JS-помилки — тест впаде з зрозумілим повідомленням
  // навіть якщо сам тест не перевіряє помилки
});
Як читати — scope: 'worker' по кроках
expensiveSetup: [async ({}, use) => {
  const db = await connectToTestDatabase();
  // ↑ підключились до БД ОДИН РАЗ — до всіх тестів в цьому worker

  await use(undefined);
  // ↑ всі тести в цьому worker виконуються (може бути 20-30 тестів)

  await db.disconnect();
  // ↑ відключились ОДИН РАЗ — після всіх тестів в цьому worker

}, { auto: true, scope: 'worker' }],
// ↑ scope: 'worker' — фікстура живе весь час поки живе worker-процес
// Без scope: 'worker' (тобто scope: 'test' за замовчуванням):
// підключення до БД відкривалось і закривалось би для КОЖНОГО тесту окремо
// З scope: 'worker': одне підключення на всі 20-30 тестів — набагато швидше
TypeScript — auto fixture для блокування аналітики в усіх тестах
export const test = base.extend({
  // Блокуємо GTM і аналітику для ВСІХ тестів автоматично
  // Тести швидші і не залежать від зовнішніх сервісів
  blockAnalytics: [async ({ page }, use) => {
    await page.route(
      '**/{gtm,analytics,hotjar,intercom}*',
      route => route.abort()
    );
    await use(undefined); // тест виконується без аналітики
    // teardown не потрібен — route.unroute() Playwright зробить сам
  }, { auto: true }],

  // Scope: 'test' за замовчуванням — нова для кожного тесту
  // Scope: 'worker' — одна на весь worker-процес (для дорогих ресурсів)
  expensiveSetup: [async ({}, use) => {
    const db = await connectToTestDatabase();
    await use(undefined);
    await db.disconnect();
  }, { auto: true, scope: 'worker' }], // підключення до БД один раз на worker
});
TypeScript — як використовувати кастомний test у файлах тестів
// ❌ Якщо імпортувати з @playwright/test — auto fixtures не спрацюють
// import { test, expect } from '@playwright/test';

// ✅ Імпортуємо кастомний test де визначені наші auto fixtures
import { test, expect } from '../fixtures/base';

// Тепер всі тести в цьому файлі автоматично отримують:
// - логування через autoLogger
// - перевірку JS-помилок через checkNoJsErrors
// - заблоковану аналітику через blockAnalytics
// Тест про це нічого не знає і нічого не оголошує
test('простий тест', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('h1')).toBeVisible();
});
💡 auto fixture vs beforeEach — коли що:
beforeEach → простий setup в межах одного файлу, не потрібен teardown
auto: true fixture → setup + teardown в одному місці, застосовується до всіх тестів які використовують цей test.extend(), не треба повторювати в кожному файлі
🔧 TypeScript / JS патерни в цьому прикладі base.extend({ autoLogger: [async ({ page }, use, testInfo) => {}, { auto: true }] }) — масив де перший елемент — async функція, другий — конфігураційний об'єкт; { auto: true } — об'єктний літерал як конфігурація; page.on('load', async () => {}) — async callback для події.
🗣 Як читати вголос Звичайна фікстура виконується тільки якщо явно вказати її в параметрах тесту. auto: true прибирає цю вимогу — фікстура виконується для кожного тесту автоматично, тест про неї нічого не знає і нічого не оголошує. Це зручно для cross-cutting concerns — речей які треба робити для всіх тестів: логувати час виконання, перевіряти відсутність JS-помилок після кожного тесту, блокувати аналітику щоб тести не залежали від зовнішніх сервісів. Технічно auto fixture пишеться як звичайна кастомна fixture через test.extend(), але замість просто значення передається масив де другий елемент це об'єкт з { auto: true }. Всередині fixture є три частини: код до use() — це setup, await use(undefined) — тут виконується сам тест, код після use() — це teardown. Саме це і відрізняє auto fixture від beforeEach: setup і teardown в одному місці без окремого afterEach, і визначається один раз для всіх тестів які імпортують кастомний test, а не в кожному файлі окремо. Важливий нюанс: щоб auto fixtures спрацювали, тести мають імпортувати test з файлу де вони визначені, а не з @playwright/test напряму.
34 Що таке test.beforeEach / afterEach / beforeAll / afterAll?
FixturesJunior

Ми вже знаємо як виконати одну дію в тесті. Але коли тестів багато — кожен з них потребує схожого підготовчого кроку: відкрити певну сторінку, залогінитись, підготувати тестові дані. Писати це в кожному тесті окремо — дублювання. Саме для цього існують хуки.

Хуки — це функції що Playwright викликає автоматично у визначений момент: до тесту, після тесту, до всіх тестів в блоці, після всіх. Вони живуть всередині test.describe() і діють тільки на тести цього блоку.

Важливо про scope: beforeAll/afterAll виконуються один раз на worker, а не на весь прогін. При паралельному запуску з кількома workers — beforeAll виконається в кожному worker окремо. Якщо потрібно щоб код виконався рівно один раз для всього прогону — використовуй globalSetup або project dependencies.

TypeScript
import { test, expect } from '@playwright/test';

test.describe('Shopping Cart', () => {
  test.beforeAll(async ({ request }) => {
    // Виконується ОДИН РАЗ перед усіма тестами блоку
    // Scope: worker (shared між тестами одного воркера)
    await request.post('/api/seed-products');
  });

  test.beforeEach(async ({ page }) => {
    // Перед КОЖНИМ тестом
    await page.goto('/cart');
  });

  test.afterEach(async ({ page }, testInfo) => {
    // Після кожного — корисно для cleanup
    if (testInfo.status !== testInfo.expectedStatus) {
      await page.screenshot({ path: `failure-${testInfo.title}.png` });
    }
  });

  test.afterAll(async ({ request }) => {
    // Після всіх тестів блоку
    await request.delete('/api/test-data');
  });
});
🔧 TypeScript / JS патерни в цьому прикладі test.beforeEach(async ({ page }) => {}) — hook: функція що виконується перед кожним тестом; test.beforeAll(async () => {}) — hook без деструктуризації (нам не потрібна page); хуки — це callbacks які Playwright викликає автоматично у визначений момент.
🗣 Як читати вголос test.describe — це блок групування, першим аргументом назва групи, другим стрілочна функція з тестами. Всередині: test.beforeAll отримує fixtures і асинхронний колбек — він запускається один раз перед усіма тестами блоку. test.beforeEach — перед кожним тестом окремо, тут переходимо на потрібну сторінку. test.afterEach отримує другим аргументом testInfo — об'єкт з мета-інформацією про тест, порівнюємо status з expectedStatus і якщо не збігаються — робимо скриншот. test.afterAll — прибираємо тестові дані після всього блоку.
35 Який порядок виконання beforeAll, beforeEach, afterEach, afterAll при вкладених describe?
FixturesSenior

Коли є один describe — все очевидно: beforeAllbeforeEach → тест → afterEachafterAll. Але коли describe вкладені — порядок стає менш очевидним і це часто плутають.

Базове правило: хуки виконуються від зовнішнього до внутрішнього при вході і від внутрішнього до зовнішнього при виході. Уяви це як шари цибулі — спочатку знімаєш зовнішній шар, потім внутрішній. При виході — навпаки.

зовнішній beforeAll        ← виконується ОДИН РАЗ перед усіма тестами
  внутрішній beforeAll     ← виконується ОДИН РАЗ перед тестами вкладеного describe
    зовнішній beforeEach   ← для КОЖНОГО тесту
      внутрішній beforeEach ← для КОЖНОГО тесту у вкладеному describe
        → ТЕСТ
      внутрішній afterEach  ← для КОЖНОГО тесту
    зовнішній afterEach    ← для КОЖНОГО тесту
  внутрішній afterAll      ← ОДИН РАЗ після всіх тестів вкладеного describe
зовнішній afterAll         ← ОДИН РАЗ після ВСІХ тестів включно з вкладеними
  • Проблема 1: beforeAll зовнішнього describe виконується для всіх тестів включно з вкладеними — навіть якщо setup потрібен тільки для зовнішніх тестів
  • Проблема 2: якщо зовнішній beforeEach встановлює стан, а внутрішній перевизначає — зовнішній все одно виконається першим
  • Проблема 3: afterAll зовнішнього describe чекає поки завершаться ВСІ вкладені describe включно з їх afterAll
TypeScript — повний приклад з виводом порядку
test.describe('Зовнішній', () => {
  test.beforeAll(() => console.log('1. зовнішній beforeAll'));
  test.afterAll(() =>  console.log('8. зовнішній afterAll'));
  test.beforeEach(() => console.log('3. зовнішній beforeEach'));
  test.afterEach(() =>  console.log('6. зовнішній afterEach'));

  test.describe('Внутрішній', () => {
    test.beforeAll(() => console.log('2. внутрішній beforeAll'));
    test.afterAll(() =>  console.log('7. внутрішній afterAll'));
    test.beforeEach(() => console.log('4. внутрішній beforeEach'));
    test.afterEach(() =>  console.log('5. внутрішній afterEach'));

    test('тест', () => console.log('→ ТЕСТ'));
  });
});

// Вивід в консоль:
// 1. зовнішній beforeAll
// 2. внутрішній beforeAll
// 3. зовнішній beforeEach   ← зовнішній ПЕРШИЙ
// 4. внутрішній beforeEach
// → ТЕСТ
// 5. внутрішній afterEach   ← внутрішній ПЕРШИЙ при виході
// 6. зовнішній afterEach
// 7. внутрішній afterAll
// 8. зовнішній afterAll     ← зовнішній ОСТАННІЙ
TypeScript — реальний кейс: різні ролі у вкладених describe
test.describe('Checkout', () => {
  // Зовнішній beforeEach: загальний setup для всіх checkout тестів
  test.beforeEach(async ({ page }) => {
    await page.goto('/cart');
    await page.locator('.add-to-cart').click();
    // Виконується для ВСІХ тестів — і зовнішніх і вкладених
  });

  test('кошик не порожній', async ({ page }) => {
    await expect(page.locator('.cart-count')).toHaveText('1');
  });

  test.describe('залогінений юзер', () => {
    // Внутрішній beforeEach: виконується ПІСЛЯ зовнішнього
    // Тобто порядок: goto('/cart') → click('.add-to-cart') → loginAsUser()
    test.beforeEach(async ({ page }) => {
      await loginAsUser(page); // логін після того як товар вже в кошику
    });

    test('зберігає кошик після логіну', async ({ page }) => {
      await expect(page.locator('.cart-count')).toHaveText('1');
    });
  });

  test.describe('гість', () => {
    // Цей beforeEach теж виконується ПІСЛЯ зовнішнього
    test.beforeEach(async ({ page }) => {
      await page.locator('.guest-checkout').click();
    });

    test('гість бачить форму оплати', async ({ page }) => {
      await expect(page.locator('.payment-form')).toBeVisible();
    });
  });
});
TypeScript — пастка: beforeAll зовнішнього виконується для всіх вкладених
// ❌ Типова помилка: думаємо що зовнішній beforeAll тільки для зовнішніх тестів
test.describe('Products', () => {
  test.beforeAll(async () => {
    await seedDatabase(); // ← виконається і перед тестами вкладеного describe!
  });

  test('показує список товарів', async ({ page }) => { /* ... */ });

  test.describe('Filters', () => {
    // seedDatabase() теж виконається перед цими тестами
    // навіть якщо фільтри не потребують бази даних
    test('фільтр по ціні', async ({ page }) => { /* ... */ });
  });
});

// ✅ Правильно: якщо setup потрібен тільки для конкретних тестів — перенести всередину
test.describe('Products', () => {
  test('показує список товарів', async ({ page }) => { /* ... */ });

  test.describe('з базою даних', () => {
    test.beforeAll(async () => {
      await seedDatabase(); // тепер тільки для цього вкладеного describe
    });
    test('фільтр по ціні', async ({ page }) => { /* ... */ });
  });
});
💡 Мнемоніка для запам'ятовування:
Вхід: зовнішній → внутрішній (від шкірки цибулі до центру)
Вихід: внутрішній → зовнішній (від центру до шкірки)
beforeAll і afterAll — один раз на весь describe
beforeEach і afterEach — для кожного тесту окремо, але той самий принцип зовнішній→внутрішній
🔧 TypeScript / JS патерни в цьому прикладі test.beforeAll(() => console.log('1. outer beforeAll')) — стрілочна функція без async (якщо не потрібен await); console.log() — виклик методу глобального об'єкту console; числа в рядках для демонстрації порядку виконання.
🗣 Як читати вголос При вкладених describe блоках хуки виконуються за принципом "від зовнішнього до внутрішнього при вході, від внутрішнього до зовнішнього при виході" — як шари цибулі. Для beforeEach це означає що зовнішній виконується першим, потім внутрішній, потім тест, потім внутрішній afterEach, і тільки потім зовнішній afterEach. Для beforeAll важлива пастка: він виконується один раз але для ВСІХ тестів в describe включно з вкладеними — тобто якщо ти зробила дорогий setup в зовнішньому beforeAll, він спрацює і перед тестами вкладеного describe, навіть якщо вони цього setup не потребують. Рішення: якщо setup потрібен тільки для певної групи тестів — перенести його у beforeAll того конкретного вкладеного describe, а не тримати у зовнішньому. afterAll зовнішнього describe чекає поки завершаться абсолютно всі вкладені describe включно з їх власними afterAll, тому teardown в зовнішньому afterAll виконується дійсно в самому кінці.
36 Чому не можна шарити стан між тестами через звичайну глобальну змінну?
FixturesMiddle

Природний рефлекс: зберегти спільне значення в глобальну змінну. В Playwright це працює по-різному залежно від налаштувань — і розуміння цього допомагає уникнути неочевидних багів.

При `fullyParallel: false` (за замовчуванням) — весь файл виконується в одному worker. Тести в одному файлі йдуть послідовно, тому глобальна змінна на рівні файлу працює для тестів цього файлу:

// product.spec.ts — всі тести в одному worker
let sharedData: string;

test.beforeAll(async () => { sharedData = 'готово'; });
test('тест 1', async () => { console.log(sharedData); }); // ✅ бачить
test('тест 2', async () => { console.log(sharedData); }); // ✅ бачить

При `fullyParallel: true` або між різними файлами — тести розходяться по різних workers (окремих Node.js процесах). Кожен process має свою копію пам'яті — глобальна змінна в одному процесі невидима іншому:

// ❌ Якщо тести потрапили в різні workers
let testCounter = 0;
test('тест 1', async () => { testCounter++; console.log(testCounter); }); // 1
test('тест 2', async () => { testCounter++; console.log(testCounter); }); // теж 1!
// Кожен worker має свою копію testCounter — вони не знають один про одного

Висновок: глобальна змінна в межах одного файлу при fullyParallel: false — технічно працює. Але це ненадійний патерн: поведінка зміниться якщо увімкнути fullyParallel або перенести тести в інший файл. Краще використовувати test.beforeAll з локальною змінною в describe блоці, або worker-scoped fixture — вона явно каже "це спільне для worker".

TypeScript — чому глобальна змінна не працює як очікується
// ❌ Очікування: лічильник рахує всі тести по черзі
let testCounter = 0;

test('тест 1', async () => {
  testCounter++;
  console.log(testCounter); // може вивести 1
});

test('тест 2', async () => {
  testCounter++;
  console.log(testCounter); // теж може вивести 1, а не 2!
});
// Якщо тести 1 і 2 потрапили в РІЗНІ workers — це окремі Node.js процеси,
// кожен зі своєю копією модуля і своєю пам'яттю → testCounter НЕ спільний

// ✅ Для дійсно спільного стану — зовнішнє сховище (файл, БД, API)
await writeFile('./counter.json', JSON.stringify({ count: 1 }));
💡 Модель паралелізму Playwright:
fullyParallel: false (за замовчуванням) — один файл = один worker, тести всередині файлу послідовно, різні файли паралельно в різних workers.
fullyParallel: true — кожен тест може отримати свій worker, навіть тести з одного файлу.
Worker — це окремий Node.js процес (не потік!), тому змінні між workers не шаряться.
🔧 TypeScript / JS патерни в цьому прикладі let sharedState = 0 — змінна в module scope; sharedState++ — increment оператор; кожен тест — окремий Node.js worker process: змінна існує тільки в межах свого процесу; workerInfo.workerIndex — доступ до індексу worker через об'єкт testInfo.
🗣 Як читати вголос Playwright розпаралелює тести не через потоки в одному процесі, а через окремі Node.js процеси-воркери — за замовчуванням кожен test-файл отримує свій worker. Це означає, що звичайна змінна на рівні модуля, типу let testCounter = 0, в кожному воркері існує як власна, ізольована копія в пам'яті того процесу: зміна цієї змінної в одному файлі ніяк не видна іншому файлу, навіть якщо обидва імпортують той самий модуль. Це не баг, а навмисний дизайн — ізоляція процесів означає, що один тест не може випадково зламати стан іншого, навіть якщо в коді є помилка з мутацією спільних об'єктів. Якщо реально потрібен спільний стан між тестами — наприклад, лічильник чи прапорець "цей ресурс вже створено" — треба використовувати щось зовнішнє відносно Node.js процесу: файл на диску, запис у базі даних, чи API-виклик до сервісу, тому що тільки зовнішнє сховище буде дійсно одним і тим самим для всіх воркерів одночасно.
37 Коли реально потрібні вкладені describe? Чому не завжди достатньо одного?
FixturesSenior

Для більшості випадків плоска структура з одним describe і beforeEach — найкраще рішення. Вона простіша і читабельніша:

test.describe('Сторінка /account', () => {
  test.beforeEach(async ({ page }) => { await page.goto('/account'); });

  test('адмін бачить кнопку видалити', async ({ page }) => {
    await loginAs(page, 'admin');
    await expect(page.locator('.delete-btn')).toBeVisible();
  });

  test('юзер НЕ бачить кнопку видалити', async ({ page }) => {
    await loginAs(page, 'user');
    await expect(page.locator('.delete-btn')).not.toBeVisible();
  });
});

Вкладені describe виправдані тільки в одному реальному випадку — коли для цілої групи тестів потрібен спільний beforeEach з кількох кроків який боляче дублювати в кожному тесті.

Правило просте: якщо можна написати плоско — пиши плоско. Вкладені describe тільки тоді коли є реальне дублювання setup яке болить.

TypeScript — коли вкладені describe НЕ потрібні: плоска структура
// ✅ Один describe, beforeEach відкриває сторінку, кожен тест сам логіниться
// Просто, читабельно, без зайвої вкладеності
test.describe('Сторінка /account', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/account');
  });

  test('адмін бачить кнопку видалити', async ({ page }) => {
    await loginAs(page, 'admin');
    await expect(page.locator('.delete-btn')).toBeVisible();
  });

  test('юзер НЕ бачить кнопку видалити', async ({ page }) => {
    await loginAs(page, 'user');
    await expect(page.locator('.delete-btn')).not.toBeVisible();
  });

  test('гість редиректить на /login', async ({ page }) => {
    await expect(page).toHaveURL('/login');
  });
});
TypeScript — коли вкладені describe ПОТРІБНІ: багато спільних кроків і кілька тестів у групі
// Спільний setup: 3 кроки для всіх + 1 специфічний для групи + 3 тести в кожній групі
// Без вкладених: 4 кроки × 6 тестів = 24 рядки дублювання
// З вкладеними: 3 спільних + 1×2 специфічних = 5 рядків setup
test.describe('Checkout', () => {

  test.beforeEach(async ({ page }) => {
    // 3 спільних кроки для ВСІХ тестів
    await page.goto('/cart');
    await page.locator('.add-to-cart').first().click();
    await page.locator('.proceed-to-checkout').click();
  });

  test.describe('залогінений юзер', () => {
    test.beforeEach(async ({ page }) => {
      await loginAs(page, 'user'); // +1 крок тільки для цієї групи
    });

    // Без вкладених describe — довелось би повторювати 4 кроки в кожному з 3 тестів
    test('бачить збережені адреси', async ({ page }) => { /* ... */ });
    test('бачить збережені картки', async ({ page }) => { /* ... */ });
    test('може оформити в один клік', async ({ page }) => { /* ... */ });
  });

  test.describe('гість', () => {
    test.beforeEach(async ({ page }) => {
      await page.locator('.guest-checkout').click(); // +1 крок для гостя
    });

    test('бачить форму введення адреси', async ({ page }) => { /* ... */ });
    test('бачить форму введення картки', async ({ page }) => { /* ... */ });
    test('не бачить "один клік"', async ({ page }) => { /* ... */ });
  });
});
TypeScript — порівняння: без вкладеного describe (дублювання) і з ним
// ❌ Без вкладеного describe — 4 кроки повторюються в кожному тесті
test('юзер бачить збережені адреси', async ({ page }) => {
  await page.goto('/cart');                              // крок 1
  await page.locator('.add-to-cart').first().click();   // крок 2
  await page.locator('.proceed-to-checkout').click();   // крок 3
  await loginAs(page, 'user');                           // крок 4
  // ... сам тест
});

test('юзер бачить збережені картки', async ({ page }) => {
  await page.goto('/cart');                              // крок 1 — знову!
  await page.locator('.add-to-cart').first().click();   // крок 2 — знову!
  await page.locator('.proceed-to-checkout').click();   // крок 3 — знову!
  await loginAs(page, 'user');                           // крок 4 — знову!
  // ... сам тест
});
// І так ще 4 рази для решти тестів... 24 рядки дублювання

// ✅ З вкладеним describe — setup написаний один раз, тест — тільки тест
test.describe('залогінений юзер', () => {
  test.beforeEach(...); // 4 кроки написані ОДИН РАЗ

  test('бачить збережені адреси', async ({ page }) => { /* тільки тест */ });
  test('бачить збережені картки', async ({ page }) => { /* тільки тест */ });
});
💡 Коли вкладені describe виправдані — простий чеклист:
Є 2+ тести які потребують однакового setup з кількох кроків → вкладений describe
Setup відрізняється для різних груп тестів → окремий вкладений describe для кожної групи
Тільки один тест у групі → не варто, просто напиши все в тесті
Setup з одного кроку → теж зазвичай не варто, напиши прямо в тесті
🔧 TypeScript / JS патерни в цьому прикладі test.describe('outer', () => { test.describe('inner', () => {}) }) — вкладені виклики функцій; test.beforeEach(async ({ page }) => {}) — hook з async callback; scope вкладеності: внутрішній beforeEach "бачить" зовнішній через closure.
🗣 Як читати вголос Плоска структура з одним describe і beforeEach — це завжди перший вибір, вона простіша і читабельніша. Якщо кожен тест потребує свого специфічного логіну або дії — просто пишемо це прямо в тест, без вкладення. Вкладені describe виправдані тільки коли є реальне дублювання яке болить: конкретно — коли є кілька кроків setup які однакові для цілої групи тестів, і ця група достатньо велика щоб дублювання відчувалось. Наприклад, три кроки щоб дістатись до сторінки checkout плюс логін — це чотири рядки які треба повторювати в кожному з шести тестів, виходить двадцять чотири рядки дублювання. З вкладеним describe ці чотири кроки пишуться один раз у beforeEach, і кожен тест містить тільки саму перевірку. Але якщо у вкладеному describe тільки один тест — вкладення не виправдане, простіше написати setup прямо в тесті.
38 Що таке test.describe.serial() і чому це навмисний виняток з ізоляції тестів?
FixturesSenior

Playwright ізолює кожен тест — новий BrowserContext для кожного. test.describe.serial() — навмисне порушення цього правила. Є E2E сценарії де тести мають виконуватись послідовно і кожен наступний залежить від результату попереднього: реєстрація → логін → замовлення. Якщо перший крок впав — немає сенсу виконувати решту.

TypeScript — serial mode проти стандартної ізоляції
// За замовчуванням: тести незалежні, порядок не гарантований,
// фейл одного НЕ впливає на інші

// ✅ serial: навмисно ламаємо ізоляцію, коли тести логічно залежні
test.describe.serial('checkout flow покроково', () => {
  let orderId: string;

  test('1. додати товар в кошик', async ({ page }) => {
    await page.goto('/cart');
    // ...
  });

  test('2. оформити замовлення', async ({ page }) => {
    // виконується ТІЛЬКИ якщо тест 1 пройшов; orderId доступний далі
    orderId = 'ORD-123';
  });

  test('3. перевірити email підтвердження', async () => {
    // якщо тест 2 впав — цей тест буде SKIPPED, не запуститься взагалі
  });
});
💡 Коли використовувати: рідко, тільки коли кроки реально не мають сенсу окремо (наприклад, багатокроковий checkout flow). Зловживання serial — ознака поганого дизайну тестів, бо втрачається паралелізм і ізоляція, заради якої ми все це робили.
🔧 TypeScript / JS патерни в цьому прикладі test.describe.serial('назва', () => {}) — виклик методу на вкладеному об'єкті describe; .serial — property access на test.describe; serial mode: тести виконуються послідовно і якщо один впав — наступні автоматично skip; виняток з правила ізоляції [Якір 1].
🗣 Як читати вголос Усе, що ми обговорювали про ізоляцію — окремий BrowserContext, окремий worker-процес — будується на припущенні, що тести незалежні: порядок виконання не гарантований, і фейл одного не повинен впливати на інші. test.describe.serial() — це навмисний, явний виняток із цього правила: всередині такого блоку тести виконуються строго по порядку, в одному й тому самому worker, і якщо один тест впаде — всі наступні в цьому ж serial-блоці автоматично позначаються як skipped, а не запускаються взагалі. Це корисно, коли кроки логічно є частинами одного бізнес-флоу, наприклад покроковий checkout, де немає сенсу перевіряти "підтвердження замовлення", якщо саме замовлення не було створено — простіше явно показати залежність через serial, ніж штучно ділити це на один величезний тест. Але це варто використовувати рідко й усвідомлено, бо кожен serial-блок втрачає головні переваги звичайних тестів — паралелізм і незалежність, тому якщо serial зустрічається часто в кодовій базі, це зазвичай сигнал, що тести варто перепроєктувати на менш зв'язані одиниці.
39 Що таке fullyParallel і чим він відрізняється від звичайного паралелізму між файлами?
FixturesSenior

За замовчуванням Playwright паралелізує на рівні файлів: кожен .spec.ts файл отримує свій worker-процес і виконується паралельно з іншими файлами. Але всередині одного файлу тести виконуються послідовно — один за одним.

Уяви: у тебе є файл з 20 тестами. Один worker запустив цей файл і виконує всі 20 тестів по черзі — поки перший не закінчився, другий не починається. Всі інші workers в цей час чекають або виконують інші файли.

fullyParallel: true змінює цю поведінку: тепер кожен окремий test() — незалежно від того в якому файлі він знаходиться — може отримати свій worker. 20 тестів з одного файлу можуть виконуватись на 6 workers одночасно.

  • Корисно коли: є великі файли з багатьма незалежними тестами — без fullyParallel весь файл виконується на одному worker, інші workers простоюють
  • Небезпечно коли: тести в одному файлі мають спільний стан або залежать один від одного — fullyParallel може зламати порядок. Але якщо всі тести правильно ізольовані (Якір 1) — проблем немає

💡 fullyParallel (конфіг) vs mode: 'parallel' (describe):
fullyParallel: true в конфізі — глобальне налаштування для всього проєкту.
test.describe.configure({ mode: 'parallel' }) — локальне увімкнення паралелізму тільки для конкретного describe блоку. Корисно коли глобально fullyParallel: false але є один блок де паралелізм бажаний.
test.describe.configure({ mode: 'serial' }) — локальне вимкнення паралелізму (коли глобально fullyParallel: true, але цей блок має залежності).

TypeScript — без fullyParallel vs з fullyParallel
// Без fullyParallel (за замовчуванням):
// checkout.spec.ts має 10 тестів
// → Worker 1 виконує всі 10 тестів послідовно: test1 → test2 → ... → test10
// → Workers 2-6 в цей час виконують інші файли або чекають
// → Якщо checkout.spec.ts найбільший файл — він стає "вузьким місцем"

// З fullyParallel: true:
// checkout.spec.ts має 10 тестів
// → Workers 1-6 беруть тести з checkout.spec.ts по одному
// → test1(W1), test2(W2), test3(W3), test4(W4), test5(W5), test6(W6) — паралельно
// → Потім ще test7(W1), test8(W2), test9(W3), test10(W4) — другий раунд
// → 10 тестів замість ~10x часу займають ~2x часу (2 раунди по 6)
playwright.config.ts — увімкнення fullyParallel
export default defineConfig({
  // Увімкнути паралелізм на рівні окремих тестів (не тільки файлів)
  fullyParallel: true,

  // Кількість workers не змінюється — тільки як вони використовуються
  workers: process.env.CI ? 4 : undefined, // undefined = автоматично (50% CPU cores)

  use: {
    baseURL: 'https://staging.myshop.com',
  },
});

// Можна також вимкнути fullyParallel для конкретного файлу або describe:
TypeScript — локальне вимкнення fullyParallel для залежних тестів
// Якщо fullyParallel: true глобально, але є файл з залежними тестами —
// можна вимкнути паралелізм локально для цього файлу

// Варіант 1: весь файл послідовно
test.describe.configure({ mode: 'serial' }); // на верхньому рівні файлу

test('крок 1: логін', async ({ page }) => { /* ... */ });
test('крок 2: додати товар', async ({ page }) => { /* ... */ });
test('крок 3: оформити замовлення', async ({ page }) => { /* ... */ });
// Всі три виконуються послідовно навіть при fullyParallel: true

// Варіант 2: тільки один describe блок послідовно, решта файлу — паралельно
test.describe('залежні кроки', () => {
  test.describe.configure({ mode: 'serial' });
  test('крок 1', async ({ page }) => { /* ... */ });
  test('крок 2', async ({ page }) => { /* ... */ });
});

// Незалежні тести поза describe — виконуються паралельно
test('незалежний тест A', async ({ page }) => { /* ... */ });
test('незалежний тест B', async ({ page }) => { /* ... */ });
TypeScript — реальний вплив на час прогону
// Приклад: ~4000 тестів, 6 workers
//
// БЕЗ fullyParallel:
// Якщо є 10 файлів по 400 тестів — кожен файл на 1 worker
// Паралельно виконується max 6 файлів, кожен послідовно
// "Вузьке місце" = найдовший файл × кількість файлів / 6 workers
//
// З fullyParallel: true:
// Всі 4000 тестів розподіляються між 6 workers незалежно від файлів
// Workers ніколи не чекають — завжди беруть наступний тест зі спільної черги
// Теоретичний мінімум: загальний час / 6 (при ідеальному балансуванні)
// На практиці: регресія <3 год → може скоротитись до ~1.5 год
💡 Коли вмикати fullyParallel:
Вмикай якщо: тести добре ізольовані (окремі fixtures, немає спільного стану), файли великі і нерівномірні за розміром, хочеш максимально завантажити всі workers.
Не вмикай якщо: є багато test.describe.serial() блоків, або тести залежать від порядку виконання — fullyParallel не зламає їх але й не пришвидшить.
🔧 TypeScript / JS патерни в цьому прикладі fullyParallel: true — булева конфігурація; workers: process.env.CI ? 4 : undefined — тернарний оператор; undefined — означає "використай значення за замовчуванням"; test.describe.configure({ mode: 'serial' }) — виклик на вкладеному об'єкті.
🗣 Як читати вголос За замовчуванням Playwright паралелізує на рівні файлів: кожен spec-файл отримує свій worker і виконується паралельно з іншими файлами, але всередині файлу тести ідуть послідовно. Це означає що якщо у тебе є файл з 50 тестами — один worker виконує їх всі по черзі, а інші workers в цей час або виконують менші файли або чекають. fullyParallel: true прибирає це обмеження: тепер кожен окремий тест незалежно від файлу може піти на вільний worker, як задачі в черзі — workers беруть наступну задачу як тільки звільняються. Кількість workers залишається тою самою — змінюється тільки гранулярність розподілу. Якщо тести правильно ізольовані, як і має бути за Якорем 1, fullyParallel дає відчутне прискорення особливо коли файли нерівномірні за розміром. Але якщо є залежні тести в файлі — для них треба явно вказати test.describe.configure({ mode: 'serial' }), тоді цей конкретний блок буде захищений від паралельного виконання навіть при глобальному fullyParallel.
40 Що таке test.use() на рівні describe і навіщо він потрібен якщо є глобальний use в конфізі?
FixturesSenior

В playwright.config.ts є блок use — там встановлюються глобальні налаштування для всіх тестів: viewport, locale, baseURL, storageState і так далі. Але що якщо треба змінити ці налаштування тільки для групи тестів, а не для всього проєкту?

  • Один describe блок тестує мобільну версію — потрібен інший viewport
  • Один describe блок тестує залогіненого адміна — потрібен інший storageState
  • Один describe блок тестує французьку локаль — потрібен locale: 'fr-FR'

Без test.use() довелось би або дублювати налаштування в кожному тесті вручну, або створювати окремий проєкт у конфізі для кожного варіанту. test.use() всередині describe блоку перевизначає конфігурацію локально — тільки для тестів в цьому блоці. Решта тестів у файлі продовжують використовувати глобальні налаштування.

⚠️ Важливо: test.use() має бути викликаний на верхньому рівні describe блоку — не всередині test(). Він не накопичується рекурсивно — він повністю замінює конкретне поле яке ти вказала, решта полів наслідується з глобального конфігу.

TypeScript — мобільний viewport для групи тестів
import { test, expect, devices } from '@playwright/test';

// Глобально: Desktop Chrome (з конфігу)

test.describe('мобільна версія PLP', () => {
  // Локально для цього describe: емулюємо iPhone 14
  test.use({ ...devices['iPhone 14'] });
  // devices['iPhone 14'] містить: viewport, userAgent, isMobile, hasTouch

  test('фільтри відкриваються як bottom sheet', async ({ page }) => {
    await page.goto('/women/dresses');
    await page.locator('button.filters-toggle').click();
    // На мобільному фільтри відкриваються знизу, а не сайдбар
    await expect(page.locator('.bottom-sheet-filters')).toBeVisible();
  });

  test('swipe по галереї зображень', async ({ page }) => {
    await page.goto('/product/trench-coat');
    // Viewport вже мобільний — isMobile: true, hasTouch: true
    await expect(page.locator('.swipe-gallery')).toBeVisible();
  });
});

// Цей тест — знову Desktop Chrome з глобального конфігу
test('desktop PLP має сайдбар фільтрів', async ({ page }) => {
  await page.goto('/women/dresses');
  await expect(page.locator('.sidebar-filters')).toBeVisible();
});
TypeScript — різні ролі через storageState в одному файлі
// В одному файлі тестуємо і адміна і звичайного юзера
// Без test.use() довелось би або два окремих файли або логін в кожному тесті

test.describe('адмін панель', () => {
  // Для всіх тестів в цьому блоці — сесія адміна
  test.use({ storageState: '.auth/admin.json' });

  test('адмін бачить кнопку "Управління товарами"', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.locator('button.manage-products')).toBeVisible();
  });

  test('адмін може видалити відгук', async ({ page }) => {
    await page.goto('/reviews');
    await expect(page.locator('button.delete-review')).toBeVisible();
  });
});

test.describe('звичайний юзер', () => {
  // Для цього блоку — сесія звичайного юзера
  test.use({ storageState: '.auth/user.json' });

  test('юзер НЕ бачить кнопку управління', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.locator('button.manage-products')).not.toBeVisible();
  });
});
TypeScript — locale і timezone для групи тестів
test.describe('французька локаль', () => {
  test.use({
    locale: 'fr-FR',
    timezoneId: 'Europe/Paris',
  });

  test('ціни показуються у форматі EUR', async ({ page }) => {
    await page.goto('/fr/products');
    await expect(page.locator('.price')).toContainText('€');
  });

  test('дати форматуються по-французьки', async ({ page }) => {
    await page.goto('/fr/orders');
    // Французький формат: 15/01/2024, а не 01/15/2024
    await expect(page.locator('.order-date')).toContainText('15/01/2024');
  });
});

test.describe('японська локаль', () => {
  test.use({
    locale: 'ja-JP',
    timezoneId: 'Asia/Tokyo',
  });

  test('ціни показуються в єнах', async ({ page }) => {
    await page.goto('/jp/products');
    await expect(page.locator('.price')).toContainText('¥');
  });
});
TypeScript — що саме замінюється, а що наслідується з конфігу
// playwright.config.ts — глобальний конфіг
export default defineConfig({
  use: {
    baseURL: 'https://staging.myshop.com',
    locale: 'en-GB',
    viewport: { width: 1280, height: 720 },
  },
});

// tests/french.spec.ts
test.describe('французька локаль', () => {
  test.use({
    locale: 'fr-FR', // замінюємо ТІЛЬКИ це поле
    // baseURL і viewport НЕ вказані — залишаються з конфігу
  });

  test('ціни в євро', async ({ page }) => {
    // baseURL → https://staging.myshop.com (з конфігу, не змінили)
    // viewport → 1280x720 (з конфігу, не змінили)
    // locale  → fr-FR (замінили через test.use)
    await page.goto('/fr/products');
    await expect(page.locator('.price')).toContainText('€');
  });
});

// Аналогія: test.use() працює як Object.assign
// finalConfig = Object.assign({}, globalConfig, localTestUse)
// тобто ти передаєш тільки те що хочеш поміняти — решта залишається

// ❌ НЕ працює — test.use() не можна викликати всередині test()
test('якийсь тест', async ({ page }) => {
  test.use({ locale: 'fr-FR' }); // помилка!
  // test.use() працює тільки на верхньому рівні describe, до всіх тестів
});
💡 test.use() vs окремий project у конфізі — коли що:
test.use() всередині describe → варіація в межах одного файлу, невелика різниця в налаштуваннях
Окремий project у конфізі → системна різниця для великої групи файлів (наприклад, всі мобільні тести окремо від десктопних), або різні браузери для всього проєкту
🔧 TypeScript / JS патерни в цьому прикладі test.use({ locale: 'fr-FR', timezoneId: 'Europe/Paris' }) — виклик методу з options object всередині describe; { ...devices['iPhone 14'] } — spread operator для розгортання профілю; test.use() на верхньому рівні describe — важливо: НЕ всередині test().
🗣 Як читати вголос test.use() всередині describe — це спосіб перевизначити конфігурацію з блоку use в конфізі локально, тільки для конкретної групи тестів, без створення окремого проєкту. Він викликається на верхньому рівні describe блоку, до всіх test() — і всі тести всередині цього блоку отримують ці налаштування, а тести поза блоком продовжують використовувати глобальні. Найпоширеніші кейси: різні viewport через devices для мобільних і десктопних тестів в одному файлі, різні storageState для тестування різних ролей — адмін, звичайний юзер, гість — в одному файлі без повторного логіну в кожному тесті, і різні locale або timezoneId для i18n сценаріїв. Важлива деталь: test.use() не накопичується — він повністю замінює конкретне поле яке ти передала, а решта полів наслідується з глобального конфігу. Тому якщо глобально є baseURL і ти в test.use() змінюєш тільки localebaseURL залишиться глобальним.
5 11
41 Що таке BrowserContext і навіщо він потрібен?
БраузерMiddle

BrowserContext — це ізольована "сесія браузера": окремі cookies, localStorage, кеш. Аналог "incognito window".

Навіщо це потрібно?

Уяви що два тести виконуються паралельно і обидва логіняться на сайт — але під різними користувачами. Якщо вони використовують один браузер без ізоляції — cookies одного тесту перезаписують cookies іншого. Тест 1 залогінився як адмін, тест 2 залогінився як юзер — і раптом тест 1 бачить юзерський акаунт бо cookies перемішались.

BrowserContext вирішує це: кожен тест отримує свій власний ізольований контекст зі своїми cookies і localStorage. Тести не впливають один на одного навіть при паралельному виконанні.

Ієрархія:

  • Browser — один процес браузера, живе весь прогін
  • BrowserContext — ізольована сесія, Playwright створює нову для кожного тесту
  • Page — вкладка всередині контексту

Важливий нюанс: найменша одиниця ізоляції — це BrowserContext, а не Page. Дві Page всередині одного контексту навмисно діляться станом — це просто дві вкладки одного користувача. Ізоляція починається лише на межі контексту:

Browser
├── BrowserContext (Alice)
│   ├── Page
│   └── Page        ← ті самі cookies що й сусідня Page
└── BrowserContext (Bob)
    └── Page         ← окремі cookies, Alice їх не бачить

Звідси й page.close() не розлогінює: закривається лише вкладка, а сесія живе в BrowserContext, який продовжує існувати. Розлогінює саме context.close() — він забирає з собою всі Page, які йому належали.

Один браузер може мати багато контекстів одночасно — це набагато швидше ніж запускати окремий браузер для кожного тесту. Навіть browser.newPage() — метод що ніби створює сторінку прямо з браузера — насправді тихцем створює новий BrowserContext під капотом і кладе Page туди. Сторінка фізично не може існувати поза контекстом.

TypeScript — multi-user тест
test('buyer and seller see same product', async ({ browser }) => {
  // Два незалежних контексти = дві різні "сесії"
  const sellerCtx = await browser.newContext({
    storageState: '.auth/seller.json'
  });
  const buyerCtx = await browser.newContext({
    storageState: '.auth/buyer.json'
  });

  const sellerPage = await sellerCtx.newPage();
  const buyerPage = await buyerCtx.newPage();

  // Паралельні дії
  await Promise.all([
    sellerPage.goto('/seller/product/123'),
    buyerPage.goto('/product/123'),
  ]);

  await sellerCtx.close();
  await buyerCtx.close();
});
🔧 TypeScript / JS патерни в цьому прикладі browser.newContext({ storageState, viewport, locale }) — factory method: метод що створює новий об'єкт; options object з кількома полями; await context.close() — явне закриття ресурсу (важливо для уникнення memory leak).
🗣 Як читати вголос Тест деструктурує browser — це сам екземпляр браузера. Викликаємо browser.newContext двічі — кожен раз з різним storageState, тобто різними сесіями. З кожного контексту отримуємо newPage(). Потім Promise.all запускає обидва goto паралельно — це рядок що одночасно навігує обидві вкладки. В кінці обов'язково закриваємо контексти через .close(), інакше вони залишаться в пам'яті.
42 Як реалізувати авторизацію один раз для всіх тестів (storageState)?
БраузерMiddle

Якщо кожен тест починає з логіну — це повільно і крихко. Логін займає 2-3 секунди, і якщо форма логіну зміниться — впадуть всі тести.

Коли ти логінишся на сайті — браузер зберігає cookies і localStorage. Саме завдяки їм при наступному відкритті сайту ти вже залогінена — браузер показує ці дані серверу і сервер каже "ок, я тебе знаю".

storageState — це знімок цього стану. Playwright зберігає cookies і localStorage у JSON файл після першого логіну. Перед наступним тестом Playwright завантажує цей файл у BrowserContext — і браузер думає що ти вже залогінена. Сайт не знає різниці — бачить ті самі cookies що й при реальному логіні.

Важливий нюанс: storageState зберігає cookies і localStorage, але НЕ sessionStorage. SessionStorage прив'язана до конкретної вкладки і не може бути серіалізована між сесіями. Якщо сайт зберігає авторизацію в sessionStorage — storageState не допоможе.

storageState завантажується в BrowserContext, а не в Browser. Тобто кожен тест отримує новий BrowserContext з попередньо завантаженим storageState — це і є механізм ізоляції при спільній авторизації.

Тобто: один раз логінишся через UI і зберігаєш стан у файл → всі інші тести починають вже із залогіненого стану → форму логіну не треба заповнювати знову.

Файл виглядає приблизно так:

{
  "cookies": [
    { "name": "session_id", "value": "abc123", "domain": "myshop.com" }
  ],
  "origins": [
    { "origin": "https://myshop.com",
      "localStorage": [{ "name": "user_id", "value": "42" }] }
  ]
}

Про path.join(__dirname, '../.auth/user.json') — крок за кроком:

Мій проєкт/
├── tests/
│   └── auth.setup.ts   ← файл з кодом
└── .auth/
    └── user.json       ← сюди хочемо зберегти
  • __dirname — автоматична змінна Node.js: "папка де лежить цей файл з кодом". Якщо код у tests/auth.setup.ts — то __dirname = Мій проєкт/tests. Не треба писати вручну, Node.js знає сам.
  • .. — означає "вийти з папки на рівень вгору". З Мій проєкт/tests виходимо в Мій проєкт.
  • path.join() — склеює частини шляху разом: Мій проєкт/tests + .. + .auth/user.json = Мій проєкт/.auth/user.json

Тобто ти кажеш: "починай з папки де я (auth.setup.ts), вийди на рівень вгору, зайди в .auth, ось там user.json". Якщо написати просто рядок '../../.auth/user.json' — він залежить від того звідки запускаєш команду. __dirname завжди відносно самого файлу, не залежно звідки запускають.

auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_PASS!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('**/dashboard');

  // Зберігаємо cookies + localStorage
  await page.context().storageState({ path: authFile });
});
playwright.config.ts
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      storageState: '.auth/user.json', // ← всі тести стартують авторизованими
    },
    dependencies: ['setup'],
  },
]
🔧 TypeScript / JS патерни в цьому прикладі await context.storageState({ path: '.auth/user.json' }) — зберігаємо стан в файл; use: { storageState: '.auth/user.json' } — конфігурація через рядковий шлях; test.use({ storageState }) — виклик методу на об'єкті test з об'єктом конфігурації.
🗣 Як читати вголос У файлі auth.setup.ts: звичайний логін через UI, але в кінці викликаємо page.context().storageState({ path: authFile }) — це серіалізує всі cookies і localStorage в JSON-файл. У конфігу додаємо окремий project 'setup' з testMatch на цей файл. Основний project chromium отримує storageState — шлях до збереженого JSON — і dependencies: ['setup'], тобто спочатку виконається setup, потім вже основні тести зі вже встановленою сесією.
43 Як працювати з localStorage в тестах? Що таке page.localStorage (Playwright 1.61+)?
БраузерMiddle

localStorage — це сховище в браузері де сайт зберігає дані між сесіями: токени, налаштування, кошик. У тестах часто треба встановити або перевірити localStorage — наприклад підготувати стан до тесту або перевірити що дані збереглись.

Раніше єдиний спосіб — через page.evaluate(). Playwright 1.61 додав прямий API page.localStorage.

TypeScript — старий спосіб через evaluate() (всі версії)
// Встановити значення
await page.evaluate(() => {
  localStorage.setItem('user_token', 'test-token-123');
  localStorage.setItem('locale', 'en-GB');
});

// Прочитати значення
const token = await page.evaluate(() =>
  localStorage.getItem('user_token')
);

// Очистити
await page.evaluate(() => localStorage.clear());
TypeScript — новий API page.localStorage (Playwright 1.61+)
// Встановити значення — без evaluate()
await page.localStorage.setItem('user_token', 'test-token-123');
await page.localStorage.setItem('locale', 'en-GB');

// Прочитати значення
const token = await page.localStorage.getItem('user_token');

// Видалити одне поле
await page.localStorage.removeItem('user_token');

// Очистити все
await page.localStorage.clear();

// Отримати всі ключі
const keys = await page.localStorage.keys();
🔧 TypeScript / JS патерни в цьому прикладі page.localStorage — об'єкт з методами setItem/getItem/removeItem/clear/keys (Playwright 1.61+); page.evaluate(() => localStorage.setItem(...)) — старий спосіб через виконання коду в браузері; обидва підходи асинхронні — потребують await.
🗣 Як читати вголос localStorage — браузерне сховище де сайт зберігає дані між сесіями. Раніше в Playwright єдиний спосіб взаємодіяти з ним — через page.evaluate() де ми запускаємо код всередині браузера. Playwright 1.61 додав прямий API page.localStorage з тими самими методами що й нативний localStorage: setItem, getItem, removeItem, clear. Тепер не треба писати evaluate — просто await page.localStorage.setItem('key', 'value'). Обидва підходи асинхронні.
44 Як тестувати нові вкладки і popup-вікна?
БраузерMiddle

target="_blank" — це HTML атрибут який каже браузеру відкрити посилання в новій вкладці замість поточної. target — куди відкрити, _blank — "нова порожня вкладка".

<!-- Звичайне посилання — відкривається в тій самій вкладці -->
<a href="/products">Товари</a>

<!-- З target="_blank" — відкривається в НОВІЙ вкладці -->
<a href="/products" target="_blank">Товари</a>

Коли юзер клікає на посилання з target="_blank" або JavaScript відкриває нове вікно через window.open() — браузер створює нову вкладку. Playwright не переключається на неї автоматично. Треба явно "зловити" нову вкладку і отримати об'єкт Page для неї.

Важливо: popup відкривається в тому самому BrowserContext — зі спільними cookies і localStorage. Це дозволяє новій вкладці мати той самий авторизований стан що й батьківська сторінка. Саме тому слухаємо подію на context, а не на browser.

Ключовий патерн: спочатку підписуємось на подію появи нової вкладки, і тільки потім клікаємо — інакше можна пропустити подію.

Є два способи зловити нову вкладку — коли який:

page.on('popup') — "підпишись і забудь". Ти кажеш "коли з'явиться нова вкладка — зроби ось це" і йдеш далі. Код не зупиняється. Підходить коли треба зробити одну просту перевірку і більше нічого.

context.waitForEvent('page') — "зупинись і дочекайся". Код зупиняється поки нова вкладка не з'явиться, потім продовжується і ти маєш нову вкладку як змінну. Підходить коли треба далі взаємодіяти з нею — клікати, заповнювати форму, перевіряти кілька речей.

TypeScript — page.on('popup'): одна перевірка і все
// Підпишись і забудь — код не зупиняється, нова вкладка в callback
page.on('popup', async (popup) => {
  await expect(popup).toHaveURL(/privacy/); // перевірили і все
});

await page.click('.privacy-link');
// тест продовжується далі не чекаючи нової вкладки
TypeScript — context.waitForEvent('page'): кілька дій у новій вкладці
// Promise.all() повертає МАСИВ результатів:
// results = [newPage, undefined]
//            ↑           ↑
//         нова вкладка   click нічого не повертає
//
// const [newPage] — деструктуризація масиву: беремо тільки перший елемент
// Те саме що: const results = await Promise.all([...]); const newPage = results[0];
const [newPage] = await Promise.all([
  context.waitForEvent('page'),  // слухаємо ДО кліку — не пропустимо подію
  page.click('.privacy-link'),    // клікаємо
]);

// newPage — повноцінна змінна, можемо взаємодіяти скільки завгодно
await newPage.waitForLoadState();
await newPage.locator('.accept-cookies').click();
await newPage.locator('.subscribe-form').fill('test@test.com');
await expect(newPage.locator('.confirmation')).toBeVisible();
💡 Просте правило:
Одна перевірка → page.on('popup')
Кілька дій у новій вкладці → context.waitForEvent('page')

⚠️ Важливо: Promise.all() гарантує що waitForEvent встановлено ДО кліку — інакше подія може прийти раніше і ти її пропустиш.
🔧 TypeScript / JS патерни в цьому прикладі const [newPage] = await Promise.all([context.waitForEvent('page'), page.click(...)])Promise.all: виконуємо два Promise паралельно і чекаємо обидва; деструктуризація масиву [newPage] — беремо перший елемент результату.
🗣 Як читати вголос Оголошуємо константу через деструктуризацію масиву const [newPage]. Правa сторона — Promise.all з масивом двох промісів: перший context.waitForEvent('page') — встановлює слухача на появу нової вкладки і повертає проміс що зрезолвиться з об'єктом нової сторінки; другий — клік по лінку. Promise.all гарантує що слухач вже активний в момент кліку. Після цього newPage — це повноцінний об'єкт Page з усіма методами.
45 Як працювати з iframe в Playwright?
БраузерJunior

iframe — це HTML елемент який вбудовує одну сторінку всередині іншої. Типовий приклад: платіжна форма від Stripe або PayPal — вона відображається на вашому сайті, але фактично це окрема сторінка від платіжного провайдера всередині iframe.

Проблема: локатори за замовчуванням шукають елементи тільки в основному документі. Елементи всередині iframe — в окремому документі. Тому потрібен спеціальний підхід.

TypeScript
// Через frameLocator (рекомендований спосіб)
const frame = page.frameLocator('iframe[name="payment"]');
await frame.getByLabel('Card number').fill('4111111111111111');

// Через frame() якщо потрібен доступ до page-level методів
const iframe = page.frame({ name: 'payment' });
const content = await iframe?.content();

// Вкладені iframe
const nested = page
  .frameLocator('#outer')
  .frameLocator('#inner');
await nested.getByRole('button').click();
🔧 TypeScript / JS патерни в цьому прикладі frameLocator vs page.frame — різниця: page.frameLocator('iframe') повертає FrameLocator — рекомендований сучасний API з auto-waiting і chaining, як звичайний Locator. page.frame({ name: 'payment' }) — старіший API, повертає Frame | null (потребує null-check), не має auto-waiting, але дає прямий доступ до Frame для низькорівневих операцій. У більшості тестів використовуй frameLocator().
🗣 Як читати вголос page.frameLocator — шукаємо iframe на сторінці за CSS-селектором або атрибутом, повертає об'єкт FrameLocator. На ньому вже можна викликати всі знайомі методи: getByLabel, getByRole тощо — вони шукатимуть елементи всередині цього iframe. Альтернатива — page.frame({ name: '...' }), але це старіший API, він повертає Frame або null, тому використовуємо optional chaining ?.. Для вкладених iframe — чейнимо два frameLocator один за одним.
46 Як обробляти діалоги (alert, confirm, prompt)?
БраузерJunior

Після того як ми розібрали хуки lifecycle — beforeEach і afterEach — перейдемо до іншого типу подій: нативні діалоги браузера. Alert, confirm і prompt — це спливаючі вікна які браузер показує через JavaScript. Без обробника Playwright заблокує їх автоматично і тест може зависнути.

Ключовий момент: обробник треба зареєструвати до дії яка викличе діалог — бо діалог може з'явитись миттєво під час кліку. Тому пишемо page.on('dialog', ...) першим рядком, і тільки потім клікаємо кнопку.

TypeScript
// Обробник ТРЕБА додати ДО дії що викликає діалог
page.on('dialog', async (dialog) => {
  console.log(dialog.message());       // текст діалогу
  console.log(dialog.type());          // 'alert' | 'confirm' | 'prompt'

  if (dialog.type() === 'confirm') {
    await dialog.accept();              // OK
  } else if (dialog.type() === 'prompt') {
    await dialog.accept('my input');   // з відповіддю
  } else {
    await dialog.dismiss();            // Cancel
  }
});

await page.getByRole('button', { name: 'Delete' }).click();
🔧 TypeScript / JS патерни в цьому прикладі page.on('dialog', async (dialog) => {}) — підписка на подію з async callback; важливо: підписка ДО goto(), бо подія може спрацювати під час завантаження; dialog.accept() / dialog.dismiss() — методи об'єкта.
🗣 Як читати вголос Викликаємо page.on — підписуємось на подію 'dialog' першим рядком до будь-якого кліку. Колбек отримує об'єкт dialog: dialog.message() повертає текст, dialog.type() — тип. Перевіряємо тип через if/else if/else: для confirm викликаємо dialog.accept() — це натискання OK; для prompt — accept з рядком-відповіддю; інакше dismiss — це Cancel. Тільки після того як обробник встановлено — клікаємо кнопку що викличе діалог.
47 Як заповнити форму і завантажити файл?
БраузерJunior

Форми — один з найчастіших сценаріїв в e-commerce тестуванні: реєстрація, логін, checkout, пошук. Playwright має окремі методи для кожного типу поля: текстові inputs, dropdown/select, чекбокси, радіо-кнопки і файлові inputs.

Головне правило: для кожного типу поля є найправильніший спосіб взаємодії. Наприклад, для <select> є selectOption() — не треба клікати і вибирати вручну.

TypeScript
// Форма
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');

// Select/dropdown
await page.selectOption('#country', { label: 'Ukraine' });

// Checkbox
await page.getByRole('checkbox', { name: 'Accept terms' }).check();

// Upload файлу
await page.getByLabel('Upload avatar').setInputFiles('avatar.jpg');

// Upload кількох файлів
await page.locator('input[type="file"]').setInputFiles([
  'file1.pdf', 'file2.pdf'
]);

// Drag & drop
await page.dragAndDrop('.source', '.target');
🔧 TypeScript / JS патерни в цьому прикладі page.locator('#promo').fill('CODE') — method chaining; page.setInputFiles('[type=file]', 'path/file.pdf') — рядковий селектор + шлях як рядок; ['file1.pdf', 'file2.pdf'] — масив рядків для множинного завантаження.
🗣 Як читати вголос getByLabel('Email').fill(...) — знаходимо поле за його лейблом і заповнюємо значенням. selectOption на селекторі — передаємо об'єкт з label або value, Playwright знайде потрібний option. getByRole('checkbox').check() — ставимо галочку. setInputFiles — передаємо шлях до файлу або масив шляхів, Playwright вставить їх у файловий input. dragAndDrop — приймає два CSS-селектори: звідки перетягнути і куди.
48 Що таке page.waitForEvent() і як правильно тестувати download, popup, dialog і filechooser?
БраузерSenior

До цього ми вивчали як чекати стан елемента на сторінці — toBeVisible(), toBeEnabled(). Але є клас подій які відбуваються не всередині DOM, а на рівні самого браузера. І для них звичайні assertions не працюють взагалі:

  • download — юзер клікнув "Завантажити PDF". Це не елемент на сторінці, це окремий браузерний процес.
  • popup — клік відкрив нову вкладку або вікно. Це нова Page яка ще не існувала, локатором її не знайдеш.
  • dialog — зʼявився нативний alert(), confirm() або prompt(). Це системний діалог браузера, поза DOM.
  • filechooser — клік відкрив системний діалог вибору файлу. Теж поза DOM.

Всі ці події — асинхронні і одноразові. Вони відбуваються один раз у відповідь на якусь дію, і якщо ти їх пропустив — назад не повернеш.

⚠️ Головна пастка — порядок операцій (race condition):
Інстинктивно хочеться написати: спочатку клікнути, потім чекати подію. Але клік може спрацювати так швидко що download вже почався і завершився ще до того як ти підписався на waitForEvent — і Playwright її просто пропустить.

Правильний патерн — завжди навпаки: спочатку підписуємось на очікування події (Promise), потім виконуємо дію яка її тригерить. Playwright починає "слухати" ще до кліку і нічого не пропустить.
TypeScript — download: правильний порядок підписка → дія → await
test('завантаження PDF звіту', async ({ page }) => {
  await page.goto('/reports');

  // ✅ ПРАВИЛЬНО: спочатку підписуємось — Promise створено, але ще не await
  const downloadPromise = page.waitForEvent('download');

  // Тепер тригеримо подію — Playwright вже "слухає" і не пропустить
  await page.locator('button.download-report').click();

  // Тепер чекаємо результат — download об'єкт доступний
  const download = await downloadPromise;

  // Перевіряємо ім'я файлу
  expect(download.suggestedFilename()).toBe('report-2024.pdf');

  // Зберігаємо файл і перевіряємо що він не порожній
  const path = await download.path();
  expect(path).toBeTruthy();
});

// ❌ НЕПРАВИЛЬНО — race condition: download може завершитись до waitForEvent
// await page.locator('button.download-report').click();
// const download = await page.waitForEvent('download'); // може пропустити!
TypeScript — popup: перехоплюємо нову вкладку яку відкриває клік
test('посилання відкривається в новій вкладці', async ({ page, context }) => {
  await page.goto('/products');

  // Підписуємось на context — нова вкладка це подія контексту, не сторінки
  const popupPromise = context.waitForEvent('page');

  // Клікаємо посилання з target="_blank"
  await page.locator('a[target="_blank"]').click();

  // Отримуємо нову Page — це повноцінний об'єкт, можна з ним взаємодіяти
  const newPage = await popupPromise;
  await newPage.waitForLoadState('domcontentloaded');

  // Перевіряємо що нова вкладка відкрила потрібний URL
  expect(newPage.url()).toContain('/product/123');
  await expect(newPage.locator('h1')).toBeVisible();
});
TypeScript — dialog: перехоплюємо alert, confirm, prompt
test('підтвердження видалення через confirm()', async ({ page }) => {
  await page.goto('/settings');

  // ⚠️ ВАЖЛИВО: якщо dialog з'явиться і обробника немає — Playwright
  // автоматично відхилить його (dismiss), а виконання тесту заблокується!
  // Тому ЗАВЖДИ реєструємо обробник ДО дії яка викликає діалог
  page.on('dialog', async (dialog) => {
    // Перевіряємо текст діалогу
    expect(dialog.message()).toBe('Ви впевнені що хочете видалити акаунт?');

    // Натискаємо "Ок" (accept) або "Скасувати" (dismiss)
    await dialog.accept(); // або dialog.dismiss() для скасування
  });

  await page.locator('button.delete-account').click();

  // Після підтвердження — перевіряємо результат
  await expect(page.locator('.success-message')).toBeVisible();
});
TypeScript — filechooser: завантажуємо файл через системний діалог
test('завантаження аватару через file picker', async ({ page }) => {
  await page.goto('/profile');

  // Той самий патерн: спочатку підписуємось, потім тригеримо
  const fileChooserPromise = page.waitForEvent('filechooser');

  // Клікаємо кнопку яка відкриває системний file picker
  await page.locator('button.upload-avatar').click();

  const fileChooser = await fileChooserPromise;
  await fileChooser.setFiles('./test-data/avatar.png');

  // Альтернатива — якщо input[type="file"] доступний напряму:
  // await page.locator('input[type="file"]').setInputFiles('./test-data/avatar.png')
  // ⚠️ setInputFiles() тільки для input[type="file"], НЕ для drag-and-drop зон

  await expect(page.locator('.avatar-preview')).toBeVisible();
});
💡 Резюме: коли що використовувати:
waitForEvent('download') → файл починає скачуватись
context.waitForEvent('page') → відкривається нова вкладка (target="_blank")
page.on('dialog', handler) → alert/confirm/prompt (обробляємо синхронно через on)
waitForEvent('filechooser') → відкривається системний file picker

І завжди: підписка → дія → await результату, а не навпаки.
🔧 TypeScript / JS патерни в цьому прикладі const [download] = await Promise.all([page.waitForEvent('download'), page.click(...)]) — деструктуризація масиву результату Promise.all; Promise.all() — виконує масив Promise паралельно; race condition патерн: спочатку підписка, потім дія.
🗣 Як читати вголос page.waitForEvent() існує для класу подій які відбуваються не всередині DOM, а на рівні самого браузера — download, popup, dialog, filechooser. Для них звичайні локатори і assertions не підходять взагалі, бо там немає DOM-елемента який можна знайти. Ключова концептуальна річ — правильний порядок операцій. Інстинктивно хочеться спочатку клікнути, а потім чекати подію — але це race condition: клік може спрацювати так швидко що подія вже відбулась і завершилась до того як Playwright почав слухати. Правильний патерн — завжди навпаки: спочатку створюємо Promise через waitForEvent без await (просто підписуємось), потім виконуємо дію яка тригерить подію, і тільки потім awaiting Promise. Так Playwright вже слухає в момент кліку і нічого не пропустить. Для download перевіряємо suggestedFilename() і path(). Для popup отримуємо повноцінний об'єкт нової Page і можемо з нею взаємодіяти як зі звичайною сторінкою. Для dialog — виняток: підписуємось через page.on('dialog', handler) а не через waitForEvent, бо dialog треба обробити синхронно в момент появи, інакше браузер зависне чекаючи відповіді. Для filechooser — той самий патерн підписка-дія-await, і потім setFiles() замість реального вибору файлу в системному діалозі.
49 Що таке page.on('console'), page.on('pageerror') і page.on('request')? Чим вони відрізняються від waitForEvent?
БраузерSenior

У попередньому питанні ми говорили про waitForEvent — це очікування одноразової події. Але є інший клас речей які відбуваються не один раз, а постійно поки сторінка жива: кожен console.log в браузері, кожна JS-помилка, кожен мережевий запит. Для цього є page.on() — підписка на потік подій.

  • waitForEvent — чекаємо одну конкретну подію і рухаємось далі. Одноразово.
  • page.on() — вішаємо постійний слухач який спрацьовує на кожну подію цього типу поки сторінка жива.
  • page.on('console') — перехоплює кожен console.log, console.warn, console.error з JavaScript коду сторінки. Корисно щоб перевірити що сторінка не кидає приховані помилки в консоль, або зловити конкретне повідомлення при певній дії.
  • page.on('pageerror') — перехоплює некеровані JS-помилки: TypeError, ReferenceError, будь-який throw який не спіймав try/catch. Золото для smoke-тестів — можна перевірити що при завантаженні сторінки немає JS-крешів, навіть якщо візуально все виглядає нормально.
  • page.on('request') і page.on('response') — перехоплює кожен мережевий запит і відповідь. Відрізняється від page.route() тим що ми тільки спостерігаємо, а не змінюємо трафік.

⚠️ Важлива деталь: підписку через page.on() треба вішати до page.goto(). Якщо повісити після — пропустиш всі події які відбулись під час завантаження сторінки: початкові console.log, помилки при ініціалізації, перші мережеві запити.

TypeScript — page.on('console'): перехоплюємо консольні повідомлення
test('сторінка не пише помилки в консоль при завантаженні', async ({ page }) => {
  const consoleErrors: string[] = [];

  // ✅ Підписуємось ДО goto — щоб не пропустити події завантаження
  page.on('console', (msg) => {
    // msg.type() → 'log' | 'warn' | 'error' | 'info' | 'debug'
    // msg.text() → сам текст повідомлення
    if (msg.type() === 'error') {
      consoleErrors.push(msg.text());
    }
  });

  await page.goto('/product/123');
  await page.locator('.product-title').waitFor();

  // Після завантаження перевіряємо що помилок не було
  expect(consoleErrors).toHaveLength(0);
});

test('перехоплюємо конкретне повідомлення', async ({ page }) => {
  const messages: string[] = [];

  page.on('console', (msg) => messages.push(msg.text()));

  await page.goto('/checkout');
  await page.locator('button.place-order').click();

  // Перевіряємо що після кліку код написав очікуване лог-повідомлення
  expect(messages.some(m => m.includes('Order submitted'))).toBe(true);
});
TypeScript — page.on('pageerror'): ловимо некеровані JS-помилки
test('на сторінці немає JS-крешів', async ({ page }) => {
  const jsErrors: Error[] = [];

  // pageerror спрацьовує на будь-який uncaught exception в браузері
  // Це TypeError, ReferenceError, або будь-який throw поза try/catch
  page.on('pageerror', (error) => {
    jsErrors.push(error); // error — це справжній Error об'єкт з message і stack
  });

  await page.goto('/dashboard');

  // Взаємодіємо зі сторінкою — перевіряємо що жоден сценарій не крешить
  await page.locator('nav a').first().click();
  await page.locator('.filter-btn').click();

  // Якщо масив не порожній — виводимо stack trace для дебагу
  expect(jsErrors, jsErrors.map(e => e.message).join('\n')).toHaveLength(0);
});

// Патерн для smoke-тесту: перевірка кількох сторінок одразу
const criticalPages = ['/', '/products', '/cart', '/account'];

for (const path of criticalPages) {
  test(`немає JS-помилок на ${path}`, async ({ page }) => {
    const errors: Error[] = [];
    page.on('pageerror', e => errors.push(e));
    await page.goto(path);
    expect(errors).toHaveLength(0);
  });
}
TypeScript — page.on('request') і ('response'): спостерігаємо за мережею
test('перевіряємо що аналітика викликається при додаванні в кошик', async ({ page }) => {
  const analyticsRequests: string[] = [];

  // Спостерігаємо за запитами — НЕ змінюємо їх (це відрізняє від page.route)
  page.on('request', (request) => {
    if (request.url().includes('analytics.myshop.com')) {
      analyticsRequests.push(request.url());
    }
  });

  await page.goto('/product/trench-coat');
  await page.locator('button.add-to-cart').click();

  // Перевіряємо що аналітичний запит відбувся
  expect(analyticsRequests.length).toBeGreaterThan(0);
});

test('API повертає 200 для критичних ендпоінтів', async ({ page }) => {
  const failedRequests: {url: string, status: number}[] = [];

  // page.on('response') — спрацьовує коли прийшла відповідь
  page.on('response', (response) => {
    if (response.url().includes('/api/') && response.status() >= 400) {
      failedRequests.push({ url: response.url(), status: response.status() });
    }
  });

  await page.goto('/checkout');

  // Якщо є помилки — виводимо деталі для зручного дебагу
  expect(
    failedRequests,
    `Failed API calls: ${failedRequests.map(r => `${r.status} ${r.url}`).join(', ')}`
  ).toHaveLength(0);
});
💡 page.on('request') vs page.route() — в чому різниця:
page.on('request') — пасивне спостереження. Бачимо запит, але не можемо його змінити чи заблокувати.
page.route() — активне втручання. Можемо перехопити запит і повернути мок-відповідь, змінити headers, або заблокувати запит взагалі.

Правило: якщо треба перевірити що запит відбувся → page.on. Якщо треба замінити відповідь → page.route.
🔧 TypeScript / JS патерни в цьому прикладі page.on('console', (msg) => {}) — підписка до goto(); msg.type() — виклик методу на об'єкті події; const errors: Error[] = [] — TypeScript масив з типом; errors.push(error) — додавання до масиву всередині closure.
🗣 Як читати вголос page.on() — це підписка на постійний потік подій, на відміну від waitForEvent який чекає одну конкретну подію і рухається далі. page.on('console') спрацьовує на кожен виклик console.log, warn, error з JavaScript коду сторінки — ми отримуємо об'єкт з методами type() і text(), збираємо помилки в масив і в кінці тесту перевіряємо що масив порожній. Це простий але потужний спосіб виявити приховані проблеми — сторінка може виглядати нормально візуально, але при цьому кидати помилки в консоль. page.on('pageerror') — ще серйозніший рівень: це некеровані JS-виключення, TypeError, ReferenceError, все що не спіймав try/catch, і вони приходять як справжній Error об'єкт зі стек-трейсом. Саме тому в expect я передаю масив помилок другим аргументом — це кастомне повідомлення при фейлі яке одразу покаже що саме впало. page.on('request') і page.on('response') — пасивне спостереження за мережею, ми тільки дивимось але не втручаємось, на відміну від page.route() де ми активно підміняємо відповіді. Головне правило для всіх трьох — підписка до goto(), бо події що відбулись до підписки вже не повернути.
50 Яка різниця між page.on(), page.waitForEvent() і page.evaluate() для "слухання" подій?
БраузерSenior

У Playwright є три різних способи "чекати" або "слухати" щось. Вони вирішують різні задачі і працюють на різних рівнях.

1. page.on() — підписка на вбудовану Playwright подію поки не буде викликаний page.off() або поки page не закриється. Callback викликається кожного разу коли подія відбувається. Код не зупиняється.

2. page.waitForEvent() — чекає один раз на вбудовану Playwright подію і повертає значення. Код зупиняється поки подія не прийшла. Це обидва способи слухати одне і те саме — вбудовані Playwright події.

3. page.evaluate() + ручна підписка — коли треба слухати щось всередині JavaScript сторінки що Playwright взагалі не знає. dataLayer.push(), кастомні події, виклики методів — Playwright їх не бачить через CDP протокол, тому треба "зайти всередину" браузера через evaluate() і підключитись вручну.

page.on()
page.waitForEvent()
evaluate()
Підписка назавжди
Чекає один раз
Всередині JS сторінки
Код не зупиняється
Код зупиняється
Promise вручну
dialog, console, request...
dialog, popup, page...
dataLayer, кастомні події

dataLayer.push() — це не Playwright подія і не браузерна подія. Це просто виклик методу на масиві всередині JavaScript. Playwright не знає що він відбувся — бачить тільки те що видно зовні через CDP. Тому page.on() і page.waitForEvent() тут не допоможуть.

TypeScript — page.on() vs page.waitForEvent(): обидва для вбудованих подій
// page.on() — підписка назавжди, callback для кожної події
page.on('dialog', dialog => dialog.accept());    // кожен dialog буде прийнятий
page.on('console', msg => console.log(msg.text())); // логуємо всі console.log

// page.waitForEvent() — чекає один раз і повертає значення
const dialog = await page.waitForEvent('dialog');  // чекаємо і отримуємо об'єкт
await dialog.accept();

// Вбудовані події Playwright: dialog, popup, page, request,
// response, console, pageerror, worker, websocket, download, filechooser
TypeScript — page.evaluate(): слухаємо те що Playwright не бачить
// dataLayer.push() — це виклик методу всередині JS сторінки
// Playwright не знає про нього — треба підключитись вручну через evaluate()

// Підміняємо dataLayer.push своєю функцією яка "повідомляє" нас через Promise
const dataLayerPromise = page.evaluate(() =>
  new Promise(resolve => {
    (window as any).dataLayer = (window as any).dataLayer || [];
    const orig = (window as any).dataLayer.push.bind((window as any).dataLayer);

    (window as any).dataLayer.push = (...args: any[]) => {
      if (args[0]?.event === 'add_to_cart') resolve(args[0]); // знайшли — повідомляємо
      return orig(...args); // завжди викликаємо оригінальний push
    };
  })
);

// Тепер чекаємо поки подія прийде
const event = await dataLayerPromise;
expect(event).toMatchObject({ event: 'add_to_cart' });
💡 Аналогія: page.on() і page.waitForEvent() — це як слухати вуличні динаміки зовні будівлі. Вони чують тільки те що виходить назовні. dataLayer.push() — це внутрішній телефон. Щоб його почути — треба зайти всередину через evaluate() і підключити власний апарат прямо до лінії.
🔧 TypeScript / JS патерни в цьому прикладі new Promise(resolve => {}) — створення Promise вручну: resolve це функція яку викликаємо коли хочемо "зрезолвити" Promise; підміна методу: obj.method = newFn — замінюємо оригінальну функцію своєю; orig(...args) — виклик оригінальної функції з усіма аргументами через spread; args[0]?.event — optional chaining на елементі масиву.
🗣 Як читати вголос У Playwright є три рівні "слухання". page.on() і page.waitForEvent() — обидва для вбудованих Playwright подій: dialog, popup, request, response, console і так далі. Різниця між ними: page.on() підписується назавжди і callback викликається щоразу, код не зупиняється. page.waitForEvent() чекає один раз і повертає значення, код зупиняється. dataLayer.push() — це не Playwright подія, це виклик методу на масиві всередині JavaScript сторінки. Playwright не знає про нього через CDP протокол. Тому треба третій спосіб: зайти всередину браузера через evaluate(), підмінити dataLayer.push своєю функцією яка перехоплює виклики і через Promise повідомляє тест коли прийшла потрібна подія.
51 Довідник основних класів Playwright — що кожен представляє і ключові методи
БраузерSenior

Всі класи Playwright можна розділити на п'ять груп: ієрархія браузера, взаємодія зі сторінкою, мережа і запити, низькорівневий ввід, браузерні події. Більшість класів не створюються вручну — вони з'являються або як fixtures, або як результати методів і подій.

Група 1 — Ієрархія браузера: Browser → BrowserContext → Page
// ── Browser ───────────────────────────────────────────────────────
// Що: один процес браузера (Chromium / Firefox / WebKit)
// Дорогий — один на весь прогін. Звідки: fixture 'browser'
browser.newContext()   // створити ізольований BrowserContext
browser.contexts()     // всі відкриті контексти
browser.version()      // версія браузера рядком
browser.close()        // закрити браузер (рідко вручну)

// ── BrowserContext ────────────────────────────────────────────────
// Що: ізольований "профіль" — свої cookies, localStorage, дозволи.
// Схожий на окреме вікно інкогніто. Новий для КОЖНОГО тесту.
// Звідки: fixture 'context', або browser.newContext({ ... })
context.newPage()                         // відкрити вкладку
context.cookies()                         // прочитати cookies
context.addCookies([...])                 // встановити cookies
context.storageState({ path: 'auth.json' }) // зберегти сесію у файл
context.setGeolocation({ latitude, longitude }) // геолокація
context.grantPermissions(['camera'])      // видати дозволи
context.setOffline(true)                  // симулювати офлайн
context.routeFromHAR('file.har')          // відтворювати HAR
context.waitForEvent('page')              // чекати нову вкладку

// ── Page ─────────────────────────────────────────────────────────
// Що: одна вкладка браузера. Основний об'єкт з яким ти працюєш.
// Звідки: fixture 'page' (найчастіше), або context.newPage()
page.goto(url)                    // перейти на URL
page.locator(selector)            // Locator — рецепт пошуку елемента
page.getByRole / getByText / getByLabel // семантичні локатори
page.route(pattern, handler)      // перехопити мережевий запит
page.waitForResponse(url)         // чекати конкретну відповідь
page.waitForEvent('download')     // чекати browser-подію
page.evaluate(fn)                 // виконати код в браузері → Node.js
page.exposeFunction(name, fn)     // браузер → Node.js функція
page.addInitScript(fn)            // виконати до завантаження сторінки
page.screenshot()                 // скріншот → Buffer
page.on('console', handler)       // слухати консоль браузера
page.on('pageerror', handler)     // слухати JS помилки
page.on('worker', handler)        // слухати Service Worker
page.url() / page.title()         // поточний URL / заголовок
page.clock.install({ time })      // мокати системний час
page.clock.fastForward('30:00')   // перемотати час вперед
page.keyboard / page.mouse        // низькорівневий ввід
Група 2 — Взаємодія зі сторінкою: Locator, Frame, Accessibility
// ── Locator ───────────────────────────────────────────────────────
// Що: рецепт пошуку елемента. Шукає заново при кожній дії.
// Підтримує auto-waiting. Звідки: page.locator(), page.getBy*()
locator.click()                    // клік (з auto-waiting)
locator.fill('text')               // заповнити поле повністю
locator.type('text')               // друкувати посимвольно
locator.hover()                    // навести курсор
locator.selectOption('value')      // вибрати опцію у <select>
locator.check() / uncheck()        // чекбокс
locator.count()                    // кількість знайдених елементів
locator.first() / last() / nth(n)  // звузити до конкретного
locator.filter({ hasText: '...' }) // фільтрувати за вмістом
locator.locator('.child')          // знайти дочірній елемент
locator.waitFor()                  // явно чекати появи елемента
locator.isVisible() / isEnabled()  // стан (одноразово, без auto-waiting!)

// ── Frame ─────────────────────────────────────────────────────────
// Що: <iframe> всередині сторінки.
// Відрізняється від Page: не має URL history, живе всередині page.

// Варіант 1: frameLocator (рекомендований — повертає Locator)
const frame = page.frameLocator('iframe[name="payment"]' );
await frame.locator('#card-number').fill('4111111111111111');

// Варіант 2: page.frame() — повертає Frame об'єкт (старіший API)
const paymentFrame = page.frame({ name: 'payment' });
await paymentFrame?.locator('#card-cvv').fill('123');

// ── Accessibility ─────────────────────────────────────────────────
// Що: знімок дерева доступності (як screen reader бачить сторінку)
const snapshot = await page.accessibility.snapshot();
// { role: 'WebArea', name: 'Products', children: [...] }
// Але для a11y тестів краще @axe-core/playwright — автоматичний аудит
Група 3 — Мережа і запити: Request, Response, APIRequestContext, Download
// ── Request ───────────────────────────────────────────────────────
// Що: один HTTP запит. Звідки: page.on('request') або page.route()
request.url()          // URL запиту
request.method()       // GET | POST | PUT | DELETE
request.headers()      // заголовки запиту
request.postData()     // тіло POST запиту як рядок
request.resourceType() // 'xhr' | 'fetch' | 'document' | 'image' | ...

// ── Response ──────────────────────────────────────────────────────
// Що: HTTP відповідь. Звідки: page.on('response') або waitForResponse()
response.url()              // URL відповіді
response.status()           // 200, 404, 500...
response.headers()          // заголовки відповіді
await response.json()        // тіло як JSON
await response.text()        // тіло як текст
await response.body()        // тіло як Buffer

// ── APIRequestContext ──────────────────────────────────────────────
// Що: HTTP клієнт БЕЗ браузера. Швидше ніж через page.
// Звідки: fixture 'request'
const res = await request.get('/api/products');
const res = await request.post('/api/cart', { data: { sku: '123' } });
const res = await request.put('/api/user/1', { data: { name: 'Test' } });
const res = await request.delete('/api/cart/item/1');

// ── Download ──────────────────────────────────────────────────────
// Що: файл що скачується. Звідки: waitForEvent('download')
download.suggestedFilename()        // ім'я файлу яке пропонує браузер
await download.path()               // шлях до тимчасового файлу на диску
await download.saveAs('./my.pdf')   // зберегти у вказане місце
Група 4 — Низькорівневий ввід: Keyboard, Mouse, Touchscreen
// ── Keyboard ──────────────────────────────────────────────────────
// Що: низькорівневий ввід з клавіатури. Доступний через page.keyboard
// Коли: звичайний locator.fill() не підходить (hot keys, shortcuts)
await page.keyboard.press('Enter')        // натиснути клавішу
await page.keyboard.press('Control+A')    // комбінація клавіш
await page.keyboard.press('Shift+Tab')    // tab назад
await page.keyboard.type('Hello')         // друкувати посимвольно
await page.keyboard.down('Shift')        // затиснути клавішу
await page.keyboard.up('Shift')          // відпустити клавішу

// ── Mouse ─────────────────────────────────────────────────────────
// Що: низькорівневі дії миші. Доступний через page.mouse
// Коли: drag-and-drop, canvas, точні координати
await page.mouse.move(100, 200)          // перемістити курсор (x, y)
await page.mouse.click(100, 200)         // клік за координатами
await page.mouse.down()                   // затиснути кнопку миші
await page.mouse.up()                     // відпустити кнопку миші
await page.mouse.wheel(0, 500)           // прокрутити (deltaX, deltaY)

// Drag-and-drop через Mouse:
await page.mouse.move(100, 100);  // навести на елемент
await page.mouse.down();            // затиснути
await page.mouse.move(300, 300);  // тягнути
await page.mouse.up();              // відпустити

// ── Touchscreen ───────────────────────────────────────────────────
// Що: тач-події для мобільної емуляції. page.touchscreen
await page.touchscreen.tap(150, 300)       // тап за координатами
Група 5 — Браузерні події: Dialog, ConsoleMessage, Worker
// ── Dialog ────────────────────────────────────────────────────────
// Що: нативний alert/confirm/prompt. Звідки: page.on('dialog')
dialog.type()     // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
dialog.message() // текст діалогу
await dialog.accept('input') // OK (для prompt можна передати текст)
await dialog.dismiss()      // Скасувати

// ── ConsoleMessage ────────────────────────────────────────────────
// Що: одне повідомлення з консолі браузера. Звідки: page.on('console')
msg.type()  // 'log' | 'warn' | 'error' | 'info' | 'debug'
msg.text()  // текст повідомлення
msg.args()  // JSHandle[] — аргументи якщо console.log(obj1, obj2)

// ── Worker ────────────────────────────────────────────────────────
// Що: Web Worker або Service Worker. Звідки: page.on('worker')
worker.url()          // URL скрипта воркера
worker.evaluate(fn)  // виконати код всередині воркера
page.workers()       // всі активні воркери на сторінці зараз
💡 Звідки що береться — швидка підказка:
З fixtures автоматично: page, context, browser, request
З page: Locator (locator/getBy*), Frame (frameLocator), Keyboard (page.keyboard), Mouse (page.mouse), Clock (page.clock)
З подій: Request (on/route), Response (on/waitForResponse), Dialog (on), ConsoleMessage (on), Download (waitForEvent), Worker (on)
🔧 TypeScript / JS патерни в цьому прикладі Картка-довідник. Ключові TypeScript концепти: класи (Browser, Page) — шаблони для об'єктів; методи — функції на класі; async method(): Promise — метод що повертає Promise; string[] — масив рядків; boolean — булевий тип.
🗣 Як читати вголос Класи Playwright можна розділити на п'ять груп. Перша — ієрархія браузера: Browser це сам процес, дорогий і один на прогін; BrowserContext це ізольований профіль зі своїми cookies і дозволами, новий для кожного тесту; Page це вкладка, основний об'єкт з яким ти працюєш. Друга — взаємодія: Locator це рецепт пошуку з auto-waiting, а не посилання на DOM-вузол; Frame це iframe всередині сторінки, доступний через frameLocator(). Третя — мережа: Request і Response зʼявляються через page.on() і page.route(); APIRequestContext це HTTP клієнт без браузера, доступний через fixture request; Download зʼявляється через waitForEvent('download'). Четверта — низькорівневий ввід: Keyboard і Mouse через page.keyboard і page.mouse — коли звичайний click() і fill() недостатньо, наприклад для drag-and-drop або keyboard shortcuts. П'ята — браузерні події: Dialog, ConsoleMessage і Worker — всі зʼявляються як аргументи в page.on(). Головне що треба памʼятати: більшість класів не створюються через new вручну — вони приходять або як fixtures, або як результати методів і подій.
6 9
52 Чим відрізняються page.evaluate(), page.exposeFunction() і page.addInitScript()? Це три різні "мости" між Node.js і браузером
EvaluateSenior

Коли Playwright запускає тест — існують два абсолютно окремих середовища виконання коду, і вони не бачать одне одного напряму.

Node.js процес — там живе твій тест. Там є page, expect, test, всі твої змінні, весь TypeScript код. Це сервер-сторона автоматизації.

Браузер — там живе JavaScript сторінки. React, Vue, window, document, localStorage, dataLayer. Це клієнт-сторона.

Між ними є мережеве з'єднання через WebSocket (саме через нього Playwright керує браузером). Але змінні між ними не шаряться — якщо ти оголосила let myVar = 123 в тесті, браузер про це не знає. І навпаки — якщо на сторінці є window.cartItems, твій тест не може прочитати це напряму.

Playwright дає три інструменти щоб перекинути дані або код через цей міст — і кожен з різним напрямком і тимінгом:

  • page.evaluate() — ти відправляєш функцію в браузер, вона виконується там, і результат повертається назад в Node.js. Напрямок: Node.js → Браузер → Node.js. Одноразово, в конкретний момент.
  • page.exposeFunction() — ти реєструєш функцію з Node.js так, щоб браузер міг її викликати сам у будь-який момент. Напрямок: Браузер → Node.js. Браузер ініціює виклик, Node.js отримує дані.
  • page.addInitScript() — ти відправляєш код який виконується в браузері ще до того як завантажиться будь-який інший скрипт сторінки. Напрямок: Node.js → Браузер, але дуже рано — до <script> тегів самої сторінки.

Аналогія: уяви що Node.js — це ти за комп'ютером, а браузер — це телевізор у сусідній кімнаті. evaluate() — ти заходиш, щось робиш і виходиш. exposeFunction() — ти даєш телевізору кнопку яка дзвонить тобі коли натиснути. addInitScript() — ти приходиш і переналаштовуєш телевізор ще до того як його увімкнули.

TypeScript — page.evaluate(): Node.js відправляє функцію в браузер, отримує результат назад
// Напрямок: Node.js → Браузер → Node.js
// Коли: одноразово, прямо зараз, в конкретний момент тесту
// Навіщо: прочитати щось з браузера що недоступне через локатор

// Приклад 1: читаємо значення з window об'єкта
const cartCount = await page.evaluate(() => {
  // Цей код виконується В БРАУЗЕРІ — тут є window, document, localStorage
  return window.cartItems.length; // повертаємо значення назад в Node.js
});
expect(cartCount).toBe(3); // тут вже в Node.js — звичайний assert

// Приклад 2: передаємо аргументи З Node.js В браузер через другий параметр
const productId = 'SKU-123'; // змінна в Node.js
const price = await page.evaluate((id) => {
  // id прийшов з Node.js — тепер він доступний тут, в браузері
  return window.productCatalog[id].price;
}, productId); // ← передаємо productId як аргумент

// ⚠️ ВАЖЛИВА ПАСТКА: closure не працює з evaluate!
const myVar = 'hello';
await page.evaluate(() => {
  // ❌ myVar тут undefined! Браузер не бачить змінні з Node.js через closure
  // Це окремий процес — closure не перетинає межу Node.js ↔ Браузер
  console.log(myVar); // undefined
});
// ✅ Правильно — передавати через другий аргумент:
await page.evaluate((v) => console.log(v), myVar);
TypeScript — page.exposeFunction(): браузер може викликати твою Node.js функцію
// Напрямок: Браузер → Node.js (браузер ініціює!
// Коли: у будь-який момент поки сторінка живе — браузер сам вирішує коли викликати
// Навіщо: перехоплювати події з браузера в реальному часі (GTM dataLayer, аналітика)

const capturedEvents: any[] = [];

// Реєструємо функцію: після цього в браузері з'явиться window.captureEvent()
// яку може викликати будь-який JS код сторінки
await page.exposeFunction('captureEvent', (event: any) => {
  // Цей колбек виконується В NODE.JS коли браузер викликає window.captureEvent()
  capturedEvents.push(event);
});

// Тепер підміняємо dataLayer.push щоб він також викликав наш колбек
await page.addInitScript(() => {
  window.dataLayer = window.dataLayer || [];
  const original = window.dataLayer.push.bind(window.dataLayer);
  window.dataLayer.push = function(...args: any[]) {
    (window as any).captureEvent(args[0]); // → летить в Node.js!
    return original(...args);
  };
});

await page.goto('/product/123');
await page.locator('.add-to-cart').click();

// Перевіряємо зібрані події — вони вже в Node.js масиві capturedEvents
const addToCartEvent = capturedEvents.find(e => e.event === 'add_to_cart');
expect(addToCartEvent?.items[0].item_id).toBe('SKU-123');
TypeScript — page.addInitScript(): код що виконується ДО будь-якого скрипта сторінки
// Напрямок: Node.js → Браузер, але ДУЖЕ РАНО
// Коли: до завантаження будь-якого <script> тегу сторінки
// Навіщо: замінити/мокнути щось до того як сторінка це використає

// Приклад 1: мокаємо Math.random щоб тести були детерміновані
await page.addInitScript(() => {
  // Сторінка ще не завантажилась, але наш код вже в браузері
  // Підміняємо Math.random ПЕРЕД тим як React/Vue його використає
  Math.random = () => 0.5; // завжди повертає 0.5 — жодної випадковості
});
await page.goto('/product'); // сторінка завантажується вже з нашим Math.random

// Приклад 2: встановлюємо feature flag до завантаження
await page.addInitScript(() => {
  window.featureFlags = { newCheckout: true, darkMode: false };
});
await page.goto('/checkout'); // сторінка читає featureFlags і бачить наші значення

// Приклад 3: можна передавати аргументи так само як в evaluate
const mockUserId = 'test-user-42';
await page.addInitScript(({ userId }) => {
  window.currentUser = { id: userId, role: 'admin' };
}, { userId: mockUserId }); // ← передаємо з Node.js в браузер
💡 Коротке резюме трьох мостів:
evaluate() — ти заходиш в браузер, щось робиш або читаєш, виходиш. Одноразово. Node.js чекає результату.
exposeFunction() — ти даєш браузеру кнопку яка телефонує тобі. Браузер сам вирішує коли натиснути. Node.js слухає.
addInitScript() — ти приходиш і переналаштовуєш браузер ще до того як сторінка увімкнулась. Одноразово, але дуже рано.
🔧 TypeScript / JS патерни в цьому прикладі await page.evaluate((arg) => { return window.fn(arg) }, nodeValue) — функція з аргументом що серіалізується через межу процесів; await page.exposeFunction('name', async (arg) => {}) — exposeFunction приймає рядок (ім'я) і async функцію; closure не працює через межу процесів.
🗣 Як читати вголос Playwright працює в двох абсолютно ізольованих середовищах: Node.js процес де живе тест, і браузер де живе JavaScript сторінки. Між ними є WebSocket але змінні через нього не шаряться — якщо в тесті є змінна myVar, браузер про неї нічого не знає, і навпаки. page.evaluate() — це спосіб відправити функцію в браузер і отримати результат назад. Важлива пастка: closure не працює через цей міст — якщо треба передати змінну з Node.js в браузер, вона має йти через другий аргумент evaluate, а не через замикання. page.exposeFunction() працює в протилежному напрямку: ми реєструємо функцію з Node.js, і після цього в браузері з'являється window.назва() яку може викликати будь-який клієнтський код — саме на цьому побудоване перехоплення GTM dataLayer, де ми підміняємо dataLayer.push щоб він також дзвонив нашому колбеку в Node.js. page.addInitScript() — це найраніший момент: код виконується в браузері до того як завантажиться будь-який скрипт самої сторінки, тому він ідеальний для мокання глобальних об'єктів типу Math.random, feature flags чи window.dataLayer — сторінка завантажується вже з нашими підміненими значеннями і ніколи не бачить "оригінальних".
53 Що таке page.addStyleTag() і page.addScriptTag()? Коли їх використовувати замість addInitScript?
EvaluateSenior

page.addStyleTag() — додає <style> тег з твоїм CSS або підключає зовнішній CSS файл. Браузер одразу застосовує стилі до поточної сторінки.

page.addScriptTag() — додає <script> тег з твоїм JS або підключає зовнішній файл. Скрипт виконується в браузері після завантаження сторінки.

  • addInitScript() — виконується ДО завантаження сторінки. Ідеально для підміни глобальних об'єктів (Math.random, dataLayer)
  • addScriptTag() — виконується ПІСЛЯ завантаження. Для динамічного додавання скриптів під час тесту
  • addStyleTag() — додає CSS в будь-який момент. Браузер одразу перераховує стилі

Найчастіший реальний кейс: addStyleTag для стабілізації скріншотів — приховати курсор, вимкнути CSS анімації і transitions, заховати елементи що мигають або динамічно змінюються.

TypeScript — addStyleTag: стабілізуємо сторінку для скріншотів
test('візуальна регресія PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // Вимикаємо всі анімації і transitions — скріншот завжди в фінальному стані
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
        transition-delay: 0s !important;
      }
      /* Ховаємо курсор — він може потрапити в скріншот */
      * { cursor: none !important; }
      /* Ховаємо елементи що мигають або оновлюються */
      .live-chat-widget,
      .cookie-banner,
      .countdown-timer { display: none !important; }
    `
  });

  await expect(page).toHaveScreenshot('pdp-stable.png');
});

// Або підключаємо зовнішній CSS файл
await page.addStyleTag({ url: 'https://example.com/test-overrides.css' });
// Або файл з диску
await page.addStyleTag({ path: './test-helpers/disable-animations.css' });
TypeScript — addStyleTag в auto fixture: вимикаємо анімації для всіх тестів
// Замість повторювати addStyleTag в кожному тесті — винести в auto fixture
const test = base.extend({
  disableAnimations: [async ({ page }, use) => {
    // Додаємо CSS після кожного goto() — слухаємо навігацію
    page.on('load', async () => {
      await page.addStyleTag({
        content: `* { animation: none !important; transition: none !important; }`
      });
    });
    await use(undefined);
  }, { auto: true }],
});
TypeScript — addScriptTag: коли реально потрібен — стан недоступний через DOM
// ⚠️ Важливо: для більшості завдань addScriptTag не потрібен
// Перевірити кількість елементів → locator.count()
// Прочитати текст → locator.textContent()
// Клікнути кнопку → locator.click()
// addScriptTag — тільки коли потрібне щось що локатор не може дістати

// Реальний кейс 1: внутрішній стан React компонента недоступний у DOM
test('React стан форми містить валідні дані', async ({ page }) => {
  await page.goto('/checkout');
  await page.locator('#email').fill('test@test.com');

  // Додаємо helper який читає внутрішній стан React через __reactFiber
  // Це недоступно через локатори — це JS об'єкт в пам'яті, не DOM атрибут
  await page.addScriptTag({
    content: `
      window.getReactState = function(selector) {
        const el = document.querySelector(selector);
        const key = Object.keys(el).find(k => k.startsWith('__reactFiber'));
        return el[key]?.memoizedState?.queue?.lastRenderedState;
      }
    `
  });

  const formState = await page.evaluate(() =>
    (window as any).getReactState('#checkout-form')
  );
  expect(formState?.isValid).toBe(true);
});

// Реальний кейс 2: симуляція події яку Playwright не підтримує нативно
test('drag-and-drop через кастомний скрипт', async ({ page }) => {
  await page.goto('/kanban');

  // Деякі drag-and-drop бібліотеки не реагують на стандартний mouse.down/move/up
  // Тому ін'єктуємо скрипт який симулює podію через dataTransfer API
  await page.addScriptTag({
    content: `
      window.simulateDrop = function(sourceSelector, targetSelector) {
        const src = document.querySelector(sourceSelector);
        const tgt = document.querySelector(targetSelector);
        const dt = new DataTransfer();
        src.dispatchEvent(new DragEvent('dragstart', { dataTransfer: dt, bubbles: true }));
        tgt.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true }));
      }
    `
  });

  await page.evaluate(() =>
    (window as any).simulateDrop('#card-1', '#column-done')
  );
  await expect(page.locator('#column-done #card-1')).toBeVisible();
});
TypeScript — різниця між addScriptTag і addInitScript на прикладі
// ── addInitScript — виконується ДО завантаження ──────────────────
await page.addInitScript(() => {
  // Виконується ПЕРЕД будь-яким скриптом сторінки
  // Підміна тут гарантовано спрацює — сторінка ще не читала Math.random
  Math.random = () => 0.5;
});
await page.goto('/product'); // ← сторінка завантажується вже з нашим Math.random

// ── addScriptTag — виконується ПІСЛЯ завантаження ────────────────
await page.goto('/product'); // ← сторінка вже завантажилась
await page.addScriptTag({
  content: `Math.random = () => 0.5;`
  // Підміна після завантаження — сторінка вже виконала свій код з реальним Math.random
  // Наша підміна вплине тільки на майбутні виклики, не на вже виконані
});
💡 Коли що використовувати:
Вимкнути анімації для стабільних скріншотів → addStyleTag({ content: '* { animation: none }' })
Підмінити глобальний об'єкт до завантаження → addInitScript()
Додати бібліотеку або helper після завантаження → addScriptTag()
Приховати нестабільні елементи (чат, банер) → addStyleTag({ content: '.chat { display: none }' })
🔧 TypeScript / JS патерни в цьому прикладі await page.addStyleTag({ content: `...` }) — template literal (backtick) для багаторядкового рядка CSS; await page.addScriptTag({ content: `window.fn = function() {}` }) — ін'єкція JS через template literal; !important — CSS специфічність, не JS.
🗣 Як читати вголос page.addStyleTag() і page.addScriptTag() дозволяють динамічно додавати CSS або JavaScript на сторінку під час тесту. Для addStyleTag найчастіший кейс — стабілізація скріншотів для візуальних регресій: ми вимикаємо всі CSS анімації і transitions щоб скріншот завжди робився в фінальному стані без змазаних переходів, ховаємо cursor щоб він не потрапив у знімок, і прибираємо нестабільні елементи типу live chat або countdown timer які змінюються з кожним запуском. Для addScriptTag — динамічне додавання бібліотек або helper функцій після завантаження сторінки. Важлива різниця з addInitScript: addInitScript виконується ще до завантаження сторінки, тому підміна глобальних об'єктів типу Math.random гарантовано спрацює перш ніж сторінка їх використає. addScriptTag виконується після завантаження — підміна вплине тільки на майбутні виклики, не на код який вже виконався при завантаженні. Зручно комбінувати addStyleTag з auto fixture щоб вимикання анімацій відбувалось автоматично для всіх тестів без повторення в кожному.
54 Що таке page.emulateMedia() і як тестувати темний режим та режим друку?
EvaluateSenior

В CSS є медіа-запити які змінюють вигляд сторінки залежно від контексту:

/* Спрацьовує коли OS в темному режимі */
@media (prefers-color-scheme: dark) {
  body { background: black; color: white; }
}

/* Спрацьовує коли юзер натиснув "Друкувати" */
@media print {
  nav { display: none; } /* навігація не потрібна на папері */
}

Проблема: як тест може перевірити темний режим якщо OS на CI-сервері завжди світла? Як перевірити print-версію якщо реально нічого не друкуємо?

page.emulateMedia() — це спосіб сказати браузеру "вдавай що зараз темний режим" або "вдавай що юзер натиснув друкувати". Браузер вірить цьому і застосовує відповідний CSS — так само як в реальному житті.

// Кажемо браузеру: вдавай що OS в темному режимі
await page.emulateMedia({ colorScheme: 'dark' });
// Тепер @media (prefers-color-scheme: dark) спрацьовує
// body стає чорним, текст білим — і ми можемо це перевірити

// Кажемо браузеру: вдавай що юзер натиснув "Друкувати"
await page.emulateMedia({ media: 'print' });
// Тепер @media print спрацьовує
// nav зникає — і ми можемо це перевірити
  • colorScheme'dark', 'light', 'no-preference'
  • media'screen' (звичайний) або 'print' (режим друку)
  • reducedMotion'reduce' (вимкнути анімації) або 'no-preference'

Важливо: emulateMedia() можна викликати в будь-який момент — до або після goto(). Браузер одразу застосовує нові умови і CSS перераховується.

TypeScript — тестуємо темний режим
test('темний режим — фон темний, текст світлий', async ({ page }) => {
  // Вмикаємо темний режим до завантаження — CSS застосується одразу
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/product/trench-coat');

  // Перевіряємо що темна тема застосована
  const bgColor = await page.evaluate(() =>
    getComputedStyle(document.body).backgroundColor
  );
  expect(bgColor).toBe('rgb(18, 18, 18)'); // темний фон

  // Або візуальна перевірка через snapshot
  await expect(page).toHaveScreenshot('dark-mode.png');
});

test('перемикання між темним і світлим режимом', async ({ page }) => {
  await page.goto('/settings');

  // Починаємо зі світлого
  await page.emulateMedia({ colorScheme: 'light' });
  await expect(page.locator('.theme-icon')).toHaveClass(/sun/);

  // Перемикаємо в темний — emulateMedia можна викликати будь-коли
  await page.emulateMedia({ colorScheme: 'dark' });
  await expect(page.locator('.theme-icon')).toHaveClass(/moon/);
});
TypeScript — тестуємо режим друку
test('сторінка замовлення коректно виглядає при друці', async ({ page }) => {
  await page.goto('/order/ORD-12345');

  // Вмикаємо режим друку — спрацьовує @media print { } в CSS
  // Навігація, банери і зайві блоки зазвичай ховаються при друці
  await page.emulateMedia({ media: 'print' });

  // Перевіряємо що навігація схована при друці
  await expect(page.locator('nav.main-navigation')).not.toBeVisible();

  // Перевіряємо що деталі замовлення видимі
  await expect(page.locator('.order-details')).toBeVisible();
  await expect(page.locator('.order-total')).toBeVisible();

  // Скріншот print-версії для візуальної перевірки
  await expect(page).toHaveScreenshot('order-print.png');
});
TypeScript — reducedMotion і встановлення через context
// reducedMotion: 'reduce' — для тестів де анімації заважають стабільності
// або для перевірки що анімації вимикаються для accessibility
test('анімації вимкнені при prefers-reduced-motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/product');

  // Перевіряємо що CSS animation-duration: 0ms при reduced motion
  const duration = await page.evaluate(() =>
    getComputedStyle(document.querySelector('.hero-animation')!).animationDuration
  );
  expect(duration).toBe('0s');
});

// Або встановити для всіх тестів через context при створенні
test('через context — темна тема глобально для всіх сторінок', async ({ browser }) => {
  const context = await browser.newContext({
    colorScheme: 'dark',        // темна тема
    reducedMotion: 'reduce',    // без анімацій
  });
  const page = await context.newPage();
  await page.goto('/products');
  // Всі сторінки в цьому context матимуть темну тему
  await context.close();
});
💡 Коли використовувати emulateMedia:
Тестуєш темну тему → emulateMedia({ colorScheme: 'dark' })
Тестуєш print-версію сторінки → emulateMedia({ media: 'print' })
Хочеш стабільніші тести без анімацій → emulateMedia({ reducedMotion: 'reduce' })
Глобально для всього проєкту → use: { colorScheme: 'dark' } в playwright.config.ts
🔧 TypeScript / JS патерни в цьому прикладі await page.emulateMedia({ colorScheme: 'dark' }) — options object з рядковим enum; await page.evaluate(() => getComputedStyle(el).opacity) — стрілочна функція що виконується в браузері і повертає значення; getComputedStyle() — браузерний API.
🗣 Як читати вголос page.emulateMedia() дозволяє явно встановити медіа-умови браузера які в реальності визначаються системними налаштуваннями OS — темний чи світлий режим через colorScheme, режим друку через media: 'print', і зменшення анімацій через reducedMotion. Це важливо для тестування бо на CI-сервері ми не можемо змінити системні налаштування, а поведінка сторінки може кардинально відрізнятись: в темній темі інші кольори, в print-режимі навігація і банери ховаються через @media print CSS правила. Метод можна викликати в будь-який момент — до або після goto(), браузер одразу застосує нові умови і CSS перерахується. Також можна встановити ці параметри при створенні context через browser.newContext({ colorScheme: 'dark' }) — тоді всі сторінки в цьому context матимуть ці налаштування, або глобально через use в playwright.config.ts для всього проєкту.
55 Як тестувати геолокацію, дозволи браузера і timezone-залежну логіку?
EvaluateSenior

Уяви що тестуєш сайт який показує "Доставка доступна у вашому місті" — і визначає місто через GPS браузера. Або сторінку яка питає "Дозволити сповіщення?" — і поведінка залежить від того чи користувач натиснув "Дозволити". Або e-commerce з різними цінами для різних timezone (акція "до опівночі" — але чия північ?).

В реальному браузері все це недетерміновано: геолокація береться з GPS або IP — на CI-сервері це Амстердам або AWS-датацентр, а не Лондон де живе твій тест-юзер. Дозволи (notifications, camera, microphone) — браузер показує системний попап, який автоматизувати неможливо, тест просто зависне чекаючи кліку якого ніколи не буде. Timezone береться з ОС машини де запущений браузер — результат залежить від того де фізично знаходиться CI.

BrowserContext дозволяє встановити все це явно до того як сторінка завантажилась. Це частина Якоря 3 (явність) — замість того щоб покладатись на те де фізично знаходиться сервер, ти явно кажеш "цей тест виконується ніби юзер знаходиться в Парижі, в timezone Europe/Paris, і вже дав дозвіл на геолокацію".

⚠️ Однієї geolocation недостатньо. Якщо не передати permissions: ['geolocation'] — браузер покаже системний попап "Дозволити доступ до геолокації?" і тест зависне. Обидва параметри йдуть разом. А якщо тест залежить від регіону — додатково потрібні locale (формат цін, дат, мова) і timezoneId (акції "до опівночі", розклади):

await browser.newContext({
  geolocation: { latitude: 51.5074, longitude: -0.1278 },
  permissions: ['geolocation'], // ← без цього — системний попап і зависання
  locale: 'en-GB',             // формат цін £1,990.00, дат, мова
  timezoneId: 'Europe/London', // щоб "до опівночі" було детерміновано
});

Чому на рівні context, а не page чи browser: і геолокація, і дозволи належать BrowserContext — це "профіль браузера". Одне вікно має одну геолокацію і один набір дозволів для всіх своїх вкладок. Якщо треба тестувати два різних міста одночасно — потрібно два різних context. Це прямий наслідок ієрархії Browser → Context → Page (Якір 1).

TypeScript — геолокація: тестуємо контент залежний від міста
// Сценарій: сайт показує "Доставка доступна у Лондоні" якщо юзер у Лондоні

test('показує доставку для Лондона', async ({ browser }) => {
  // Створюємо context з явними координатами Лондона
  // Важливо: геолокація встановлюється на CONTEXT, не на page
  const context = await browser.newContext({
    geolocation: { latitude: 51.5074, longitude: -0.1278 }, // Лондон
    permissions: ['geolocation'], // одразу даємо дозвіл — без системного попапу
  });

  const page = await context.newPage();
  await page.goto('/delivery');

  // Сторінка запитала геолокацію → отримала Лондон → показує відповідний контент
  await expect(page.locator('.delivery-info'))
    .toContainText('Доставка доступна у Лондоні');

  await context.close(); // context створили вручну — самі закриваємо
});

// Порівнюємо два міста в одному тесті — потрібно два різних context
test('різний контент для різних міст', async ({ browser }) => {
  const londonCtx = await browser.newContext({
    geolocation: { latitude: 51.5074, longitude: -0.1278 },
    permissions: ['geolocation'],
  });
  const parisCtx = await browser.newContext({
    geolocation: { latitude: 48.8566, longitude: 2.3522 }, // Париж
    permissions: ['geolocation'],
  });

  const londonPage = await londonCtx.newPage();
  const parisPage = await parisCtx.newPage();

  await londonPage.goto('/delivery');
  await parisPage.goto('/delivery');

  await expect(londonPage.locator('.city')).toHaveText('London');
  await expect(parisPage.locator('.city')).toHaveText('Paris');

  await londonCtx.close();
  await parisCtx.close();
});
TypeScript — дозволи: notifications, camera, microphone
// Без grantPermissions браузер показує системний попап — тест зависне
// З grantPermissions — дозвіл вже виданий, попап не з'явиться взагалі

test('підписка на push-сповіщення', async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ['notifications'], // юзер вже "натиснув Дозволити"
  });
  const page = await context.newPage();
  await page.goto('/notifications-settings');

  // Кнопка "Підписатись" одразу доступна — без попапу дозволу
  await page.locator('button.subscribe').click();
  await expect(page.locator('.status')).toHaveText('Підписано');
  await context.close();
});

// Можна також видати дозвіл після створення context — через context.grantPermissions()
test('доступ до камери', async ({ context }) => {
  // context тут — стандартна fixture від Playwright
  await context.grantPermissions(['camera', 'microphone']);
  // Далі будь-яка page з цього context матиме ці дозволи
});
TypeScript — timezone: тестуємо "до опівночі" в різних часових поясах
// Сценарій: акція "знижка 20% до опівночі" — але чия північ?
// Для японського юзера опівніч настане раніше ніж для британського

test('акція активна для UK, але закінчилась для Японії', async ({ browser }) => {
  // UK юзер — зараз 23:00, акція ще діє
  const ukCtx = await browser.newContext({
    timezoneId: 'Europe/London', // IANA timezone name
  });

  // Японський юзер — ті самі UTC-мілісекунди, але вже завтра 08:00
  const jpCtx = await browser.newContext({
    timezoneId: 'Asia/Tokyo',
  });

  const ukPage = await ukCtx.newPage();
  const jpPage = await jpCtx.newPage();

  await ukPage.goto('/sale');
  await jpPage.goto('/sale');

  await expect(ukPage.locator('.sale-badge')).toBeVisible();    // акція ще є
  await expect(jpPage.locator('.sale-badge')).not.toBeVisible(); // акція скінчилась

  await ukCtx.close();
  await jpCtx.close();
});

// Або через playwright.config.ts — встановити timezone для цілого проєкту:
// use: { timezoneId: 'Europe/London', locale: 'en-GB' }
// Це гарантує що CI в Амстердамі і локальна машина дають однаковий результат
💡 Чому все це на рівні context, а не page: BrowserContext — це "профіль браузера". Одне вікно має одну геолокацію, один timezone, один набір дозволів для всіх вкладок всередині. Якщо треба два різних міста одночасно — треба два різних context, а отже і дві різних page. Це прямий наслідок ієрархії Browser → Context → Page (Якір 1).
🔧 TypeScript / JS патерни в цьому прикладі await context.setGeolocation({ latitude: 51.5, longitude: -0.1 }) — options object з числовими полями; await context.grantPermissions(['geolocation']) — масив рядків; timezoneId: 'Europe/Kyiv' — IANA timezone string; всі налаштування на рівні context.
🗣 Як читати вголос Проблема без цього API — в CI-середовищі геолокація визначається IP-адресою сервера (рандомний датацентр), timezone береться з ОС контейнера, а дозволи типу notifications викликають системний попап, який неможливо автоматизувати — тест просто зависне чекаючи кліку якого ніколи не буде. BrowserContext дозволяє встановити все це явно при створенні: передаємо geolocation з конкретними координатами, permissions з масивом дозволів які вже "видані", і timezoneId у форматі IANA назви timezone. Важливо розуміти чому саме context, а не page: context — це "профіль браузерного вікна", одне вікно має одну геолокацію і один timezone для всіх своїх вкладок. Якщо треба протестувати два різних міста паралельно — створюємо два різних context, кожен зі своєю page, і вони абсолютно ізольовані одне від одного, бо це Якір 1 — ізоляція через context. На практиці для великих multi-domain e-commerce проєктів зручніше встановити timezoneId і locale в playwright.config.ts на рівні всього проєкту — тоді результати тестів однакові незалежно від того де фізично запущений CI-сервер.
56 Що таке page.clock і як тестувати поведінку залежну від часу без реального очікування?
EvaluateSenior

Уяви що тестуєш функцію "сесія закінчується через 30 хвилин бездіяльності" — і показується попап "вас буде виlogged out". Як ти це тестуєш? Реально чекати 30 хвилин? Очевидно ні. Або: ти тестуєш що на сторінці показується "Акція діє ще 2 години" — і цей текст залежить від new Date() всередині React-компонента. Як переконатись що через "2 години" текст зміниться на "Акція закінчилась"?

Проблема в тому, що браузер живе в реальному часі. new Date() повертає справжній поточний час. setTimeout(fn, 30 * 60 * 1000) реально чекає 30 хвилин.

page.clock — це інструмент Playwright який повністю замінює годинник у браузері на підконтрольний тобі. Ти кажеш "зараз 10:00" — і браузер вірить. Потім кажеш "перемотай на 31 хвилину вперед" — і всі setTimeout, setInterval, Date, performance.now() в браузері реагують миттєво, ніби цей час справді минув. Без жодного реального очікування.

Важливо: page.clock впливає тільки на JavaScript в браузері. Node.js процес (де живе твій тест) продовжує жити в реальному часі — це не проблема, бо тест сам керує перемоткою.

TypeScript — базове використання: фіксуємо час і перемотуємо
// Сценарій: сесія закінчується через 30 хвилин, показується попап

test('попап "сесія закінчилась" зʼявляється через 30 хвилин', async ({ page }) => {

  // 1. ВСТАНОВЛЮЄМО час ДО переходу на сторінку
  // Фіксуємо "зараз" як 10:00:00 — браузер буде думати що зараз саме цей час
  await page.clock.install({ time: new Date('2024-01-15T10:00:00') });

  // 2. Заходимо на сторінку — всі таймери стартують з нашого фіксованого часу
  await page.goto('/dashboard');

  // 3. Перевіряємо що попапу ще немає (30 хвилин не минуло)
  await expect(page.locator('.session-timeout-modal')).not.toBeVisible();

  // 4. ПЕРЕМОТУЄМО час на 31 хвилину вперед — миттєво, без реального очікування
  // Всі setTimeout/setInterval в браузері спрацюють так ніби минуло 31 хвилина
  await page.clock.fastForward('31:00'); // формат 'хв:сек'

  // 5. Попап зʼявився — перевіряємо
  await expect(page.locator('.session-timeout-modal')).toBeVisible();
  await expect(page.locator('.session-timeout-modal h2'))
    .toHaveText('Ваша сесія закінчилась');
});
TypeScript — тестування UI що показує "залишилось X часу"
// Сценарій: банер "Акція діє ще 2 години" → "Акція закінчилась"

test('банер акції оновлюється коли час вийшов', async ({ page }) => {

  // Встановлюємо час: акція закінчується о 12:00, зараз 10:00
  await page.clock.install({ time: new Date('2024-01-15T10:00:00') });
  await page.goto('/sale');

  // Зараз 10:00 — банер показує залишок часу
  await expect(page.locator('.sale-banner')).toContainText('Акція діє ще');

  // Перемотуємо рівно на 2 години вперед — тепер "12:00:00"
  await page.clock.fastForward('2:00:00'); // формат 'год:хв:сек'

  // Банер оновився — показує що акція закінчилась
  await expect(page.locator('.sale-banner')).toContainText('Акція закінчилась');
});
TypeScript — tick() vs fastForward() і setSystemTime()
// Є три способи керувати часом, і вони різні:

// 1. fastForward(time) — перемотує час І одразу виконує всі таймери що спрацювали б
//    Якщо є setTimeout кожні 5 хвилин — всі 6 викликів за 30 хвилин виконаються
await page.clock.fastForward('30:00');

// 2. tick(milliseconds) — те саме, але аргумент в мілісекундах
await page.clock.tick(30 * 60 * 1000); // 30 хвилин в мс

// 3. setSystemTime(time) — просто зсуває годинник БЕЗ виконання таймерів
//    Корисно коли треба що Date.now() повертав інше число,
//    але не треба щоб спрацювали всі проміжні setInterval
await page.clock.setSystemTime(new Date('2024-12-31T23:59:00'));
// Тепер new Date() в браузері = 31 грудня 23:59, але таймери не спрацювали
💡 Важливий нюанс: page.clock.install() треба викликати ДО page.goto(). Якщо викликати після — деякі таймери і Date виклики що відбулись під час завантаження сторінки вже пройшли з реальним часом, і мокання буде неповним.
🔧 TypeScript / JS патерни в цьому прикладі await page.clock.install({ time: new Date('2024-01-15') }) — options з Date об'єктом; await page.clock.fastForward('30:00') — рядок у форматі MM:SS; new Date() — створення Date об'єкту; page.clock.setSystemTime(timestamp) — встановлення конкретного timestamp.
🗣 Як читати вголос Уяви що тестуєш функцію "сесія закінчується через 30 хвилин" — реально чекати 30 хвилин неможливо, а page.clock вирішує це елегантно: він повністю замінює годинник всередині браузера на підконтрольний нам. Ми встановлюємо фіксований час через clock.install() до того як заходимо на сторінку, і після цього браузер думає що зараз саме цей час — new Date() повертає його, всі setTimeout і setInterval відраховують від нього. Потім ми викликаємо fastForward('31:00') — і Playwright миттєво просуває час на 31 хвилину, виконуючи всі таймери які мали б спрацювати за цей період, без жодного реального очікування. Є три методи керування: fastForward і tick — обидва просувають час і виконують таймери, різниця лише в форматі аргументу; setSystemTime просто зсуває годинник без виконання таймерів — корисно коли треба лише щоб Date.now() повертав інше значення. Ключовий нюанс — install() завжди до goto(), бо інакше частина ініціалізаційного коду сторінки вже виконалась з реальним часом.
57 Коли page.clock не спрацює, хоча час "перемотався"?
Evaluate Senior

У браузері насправді два незалежні годинники. page.clock контролює тільки один з них — тому в частині сценаріїв fastForward() нічого не зробить, і це не баг у тесті, а межа можливостей інструменту.

  • Механічний годинникsetTimeout, setInterval, Date.now(), performance.now(). Саме його підміняє page.clock.install().
  • Анімаційний годинникdocument.timeline.currentTime. Він рахує час для CSS-анімацій (animation: progress 6s), CSS-переходів (transition: width 6s) і Web Animations API (element.animate(...)). page.clock його не чіпає — це визнане обмеження Playwright, ще не реалізовано (issue #38951).

Різницю легко пропустити, бо код виглядає так, ніби прив'язаний до таймера:

// ❌ page.clock НЕ допоможе — перемикання прив'язане до CSS-анімації,
// а не до setTimeout, навіть якщо всередині є якийсь async-код
animatePipProgress(6000)
  .onFinish(() => nextSlide());

// ✅ page.clock ДОПОМОЖЕ — перемикання прив'язане напряму до таймера
setTimeout(() => nextSlide(), 6000);

Як діагностувати: заміряй три дельти одразу після fastForward()Date.now(), performance.now() і document.timeline.currentTime. Якщо перші дві "перемотались" на очікувані 6000 мс, а третя лишилась близько нуля — маєш саме цей випадок: механічний годинник підмінено, анімаційний живе своїм реальним часом.

TypeScript — діагностика: який годинник насправді не рухається
const before = await page.evaluate(() => ({
  date: Date.now(),
  perf: performance.now(),
  timeline: document.timeline.currentTime,
}));

await page.clock.fastForward(6000);

const after = await page.evaluate(() => ({
  date: Date.now(),
  perf: performance.now(),
  timeline: document.timeline.currentTime,
}));

console.log({
  dateDelta: after.date - before.date,         // ≈ 6000 ✓ механічний перемотався
  perfDelta: after.perf - before.perf,         // ≈ 6000 ✓ механічний перемотався
  timelineDelta: after.timeline - before.timeline, // ≈ 1000 ✗ реальний час, не 6000!
});
// timelineDelta сильно менший за очікуване — ось і причина "тест не бачить перемикання"
Три варіанти рішення — від чіт-коду до правильної архітектури
// ❌ Варіант 1 — форсувати завершення анімації напряму. Технічно працює,
// але тестує НЕ те, що бачить юзер: він ніколи не викликає getAnimations()
await page.evaluate(() => {
  document.getAnimations().forEach(a => { try { a.finish(); } catch {} });
});

// ✅ Варіант 2 — чесний тест: чекаємо реальний час через expect.poll,
// test.slow() дає x3 таймаут щоб компенсувати повільність
test('carousel autoplay advances through slides', async ({ page }) => {
  test.slow();
  await expect
    .poll(() => getActiveSlideIndex(page), { timeout: 7000 })
    .toBe(2);
});

// ✅✅ Варіант 3 — найкраще в довгій перспективі: тестовий хук від фронтенду
// query-параметр що скорочує тривалість автоплею у не-прод збірці —
// тест більше не залежить ні від clock, ні від реального очікування
await page.goto('/plp?autoPlayDuration=100');
🗣 Як читати вголос В браузері насправді два годинники, і про це мало хто знає, поки не наступить на граблі. Один — механічний, він рахує час для setTimeout, setInterval, Date.now і performance.now. Другий — анімаційний, document.timeline.currentTime, і він рахує час окремо, для CSS-анімацій, transitions і Web Animations API. Playwright clock.install підміняє тільки перший. Другий він не чіпає, і це визнане обмеження, ще не реалізоване. На практиці це виглядає так: у тебе карусель, і перемикання слайду відбувається не по setTimeout, а по завершенню CSS-анімації прогрес-бару. Викликаєш fastForward на шість секунд — механічний годинник слухняно перемотується, а анімація як жила в реальному часі, так і живе. Слайд не перемикається, і тест падає, хоча код виглядає абсолютно правильним. Діагностика проста: заміряй три дельти одразу після fastForward — якщо dateDelta і perfDelta нормальні, а timelineDelta залишився крихітним — це саме воно. Є спокуса просто форснути всі анімації через getAnimations forEach finish, але це тестує не те, що бачить юзер. Чесний варіант — чекати реальний час з test.slow для збільшеного таймауту. А найкращий варіант у довгостроковій перспективі — попросити фронтенд додати тестовий хук, query-параметр який у не-проді скорочує тривалість автоплею, і тоді тест взагалі не залежить ні від годинника, ні від довгого реального очікування.
58 Як тестувати lazy-loaded контент і virtualized lists?
EvaluateMiddle

Lazy loading — контент завантажується тільки коли потрапляє в зону видимості. Virtualized lists — в DOM рендериться тільки видима частина (наприклад 10 рядків з 1000). Обидва патерни ламають наївний підхід "знайди всі елементи і порахуй" — бо в DOM їх просто немає поки не проскролити.

TypeScript
// Scroll до елементу щоб тригернути lazy load
await page.locator('.product-card').last().scrollIntoViewIfNeeded();

// Прокрутити до кінця і порахувати всі продукти
let prevCount = 0;
while (true) {
  const cards = page.locator('.product-card');
  const count = await cards.count();
  if (count === prevCount) break; // більше не завантажується
  prevCount = count;
  await cards.last().scrollIntoViewIfNeeded();
  await page.waitForTimeout(500); // виняток: немає DOM сигналу — єдиний виправданий waitForTimeout
}

// Virtualized list — DOM рендерить тільки видимі елементи
// Шукаємо конкретний елемент через scroll
await page.evaluate(async () => {
  const container = document.querySelector('.virtual-list')!;
  container.scrollTop = 5000;
});
await expect(page.getByText('Product #200')).toBeVisible();
🔧 TypeScript / JS патерни в цьому прикладі const seenHrefs = new Set() — TypeScript generic: Set означає "Set який містить тільки string"; seenHrefs.add(href) і seenHrefs.size — методи і властивості Set; while (seenHrefs.size < totalCount) {} — цикл while з умовою.
🗣 Як читати вголос locator('.product-card').last() — беремо локатор всіх карток, потім .last() — остання з них, і .scrollIntoViewIfNeeded() — скролимо до неї якщо вона поза viewport. В циклі while(true): кожну ітерацію рахуємо cards.count(), порівнюємо з попереднім значенням — якщо не змінилось, break. Для virtualized list — page.evaluate виконує код в браузері де напряму встановлюємо container.scrollTop, це прокручує контейнер до потрібної позиції щоб потрібний елемент відрендерився в DOM.
59 Як Playwright взаємодіє з Service Workers і чому вони можуть ламати page.route() і page.on('request')?
EvaluateSenior

Service Worker — це JavaScript файл який браузер реєструє окремо від сторінки і який працює як "проксі" між сторінкою і мережею. Він може перехоплювати мережеві запити, кешувати відповіді, відповідати без реального звернення до сервера. Використовується для PWA, офлайн-режиму і push-сповіщень.

Проблема в тестах: коли ти пишеш page.route() або page.on('request') — Playwright перехоплює запити на рівні браузерного процесу. Але Service Worker перехоплює запити ДО того як вони досягають цього рівня. Якщо Service Worker вирішив відповісти з кешу — page.route() взагалі не побачить цього запиту. Для Playwright запиту ніби не існувало.

  • Твій page.route() мок не спрацює — відповідь прийшла з Service Worker кешу
  • page.on('request') не зафіксує запит — він не дійшов до рівня де Playwright слухає
  • Тест може проходити або падати залежно від стану кешу — класичний недетермінований результат

Два способи вирішення: serviceWorkers: 'block' — вимкнути Service Workers повністю для тестового context, всі запити йдуть напряму в мережу і Playwright їх бачить. Або page.on('worker') — взаємодіяти з Service Worker напряму якщо треба тестувати саму PWA функціональність.

playwright.config.ts — вимикаємо Service Workers для всіх тестів
export default defineConfig({
  use: {
    // 'block' — Service Workers не реєструються взагалі
    // Всі мережеві запити йдуть напряму, page.route() і page.on('request') працюють нормально
    serviceWorkers: 'block',

    // 'allow' — за замовчуванням, Service Workers працюють як в реальному браузері
    // serviceWorkers: 'allow',
  },
});
TypeScript — або вимикаємо для конкретного context
test('мок API відповіді на сторінці з PWA', async ({ browser }) => {
  // Створюємо context з вимкненим Service Worker
  const context = await browser.newContext({
    serviceWorkers: 'block',
  });
  const page = await context.newPage();

  // Тепер page.route() точно спрацює — Service Worker не перехоплює
  await page.route('**/api/products', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ items: [{ id: 1, name: 'Trench Coat' }] }),
    });
  });

  await page.goto('/products');
  await expect(page.locator('.product-name')).toHaveText('Trench Coat');
  await context.close();
});
TypeScript — page.on('worker'): чекаємо і взаємодіємо з Service Worker
// Якщо треба ТЕСТУВАТИ саму PWA функціональність — не вимикаємо, а взаємодіємо
test('Service Worker реєструється успішно', async ({ page }) => {
  const workers: any[] = [];

  // Підписуємось до goto — щоб не пропустити реєстрацію при завантаженні
  page.on('worker', worker => {
    workers.push(worker);
    console.log('Worker зареєстровано:', worker.url());
  });

  await page.goto('/');

  // Чекаємо поки Service Worker зареєструється
  await page.waitForFunction(() =>
    navigator.serviceWorker.ready.then(() => true)
  );

  // Перевіряємо що worker зареєстрований
  expect(workers.length).toBeGreaterThan(0);
  expect(workers[0].url()).toContain('service-worker.js');
});

test('сторінка працює офлайн (PWA кеш)', async ({ page, context }) => {
  // Спочатку завантажуємо онлайн — Service Worker кешує ресурси
  await page.goto('/');
  await page.waitForFunction(() =>
    navigator.serviceWorker.ready.then(() => true)
  );

  // Вмикаємо офлайн режим через context
  await context.setOffline(true);

  // Перезавантажуємо — має відповісти Service Worker з кешу
  await page.reload();

  // Сторінка має завантажитись навіть без мережі
  await expect(page.locator('h1')).toBeVisible();
  await expect(page.locator('.offline-banner')).toBeVisible();
});
💡 Коли що використовувати:
Тестуєш бізнес-логіку сторінки і хочеш мокати API → serviceWorkers: 'block' в конфізі або context — Service Worker не заважатиме page.route()
Тестуєш саму PWA функціональність (офлайн режим, кешування, push) → лишаєш 'allow' і взаємодієш через page.on('worker') і context.setOffline()
🔧 TypeScript / JS патерни в цьому прикладі serviceWorkers: 'block' — рядкове enum значення в конфігурації; page.on('worker', worker => {}) — підписка на появу worker; navigator.serviceWorker.ready.then(() => true) — Promise chain у браузерному коді; context.setOffline(true) — булевий аргумент.
🗣 Як читати вголос Service Worker — це окремий JavaScript процес в браузері який сидить між сторінкою і мережею і може перехоплювати запити та відповідати з кешу. Проблема для Playwright в тому що page.route() і page.on('request') перехоплюють запити на рівні браузерного процесу — але якщо Service Worker відповів з кешу, запит до цього рівня взагалі не дійшов. Playwright його не бачить. Це класичне джерело нестабільних тестів де результат залежить від стану кешу Service Worker. Є два підходи. Якщо нас не цікавить PWA функціональність і ми просто хочемо тестувати бізнес-логіку — встановлюємо serviceWorkers: 'block' в конфізі або при створенні context, і тоді Service Workers взагалі не реєструються і всі запити йдуть напряму через Playwright. Якщо ж ми тестуємо саму PWA — наприклад що сторінка працює офлайн — тоді лишаємо Service Workers увімкненими, підписуємось на page.on('worker') щоб відстежувати реєстрацію, і використовуємо context.setOffline(true) щоб симулювати відсутність мережі і перевіряти що Service Worker коректно відповідає з кешу.
60 Що таке PWA і як тестувати PWA функціональність в Playwright?
EvaluateSenior

PWA (Progressive Web App) — це веб-сайт який поводиться як нативний додаток. Користувач може встановити його на головний екран, він працює офлайн і може отримувати push-сповіщення — але це все ще веб-сайт, не App Store.

Три ключові компоненти PWA:

  • Service Worker — JavaScript файл який працює у фоні окремо від сторінки. Перехоплює мережеві запити, кешує ресурси, дає офлайн режим. Детально в наступній картці.
  • Web App Manifest — JSON файл з описом додатку: назва, іконки, колір, режим відображення. Саме завдяки йому браузер пропонує "Встановити".
  • HTTPS — обов'язково. Без нього Service Worker не реєструється.

Реальний приклад: ти відкриваєш myshop.com в Chrome на телефоні. Браузер пропонує "Додати на головний екран". Після цього застосунок відкривається без адресного рядка, зберігає частину даних офлайн і може надсилати push про нові колекції.

Для тестування: Playwright дозволяє тестувати офлайн режим через context.setOffline(true), слухати Service Workers через page.on('worker', ...), і емулювати мобільні девайси через devices['iPhone 14']. Важливий нюанс: Service Worker може перехоплювати запити ДО page.route() — детально в наступній картці.

TypeScript — тестування PWA офлайн режиму
test('сторінка показує кешований контент офлайн', async ({ page, context }) => {
  // 1. Завантажуємо сторінку онлайн — Service Worker кешує ресурси
  await page.goto('/products');
  await page.waitForLoadState('networkidle');

  // 2. Вимикаємо мережу
  await context.setOffline(true);

  // 3. Перезавантажуємо — має показати кешований контент
  await page.reload();
  await expect(page.locator('.product-grid')).toBeVisible();
  await expect(page.locator('.offline-banner')).toBeVisible();
});
TypeScript — перевірка Web App Manifest
test('PWA manifest налаштований коректно', async ({ page }) => {
  await page.goto('/');

  // Перевіряємо що manifest підключений
  const manifestLink = page.locator('link[rel="manifest"]');
  await expect(manifestLink).toHaveAttribute('href', /manifest\.json/);

  // Завантажуємо і перевіряємо вміст manifest
  const manifestUrl = await manifestLink.getAttribute('href');
  const response = await page.request.get(manifestUrl!);
  const manifest = await response.json();

  expect(manifest.name).toBeTruthy();
  expect(manifest.icons).toHaveLength(expect.any(Number));
  expect(manifest.start_url).toBeTruthy();
});
🔧 TypeScript / JS патерни в цьому прикладі context.setOffline(true) — метод на рівні контексту (не page); await page.reload() — перезавантаження сторінки; page.request.get(url) — HTTP запит через Playwright без браузера; manifestUrl! — non-null assertion в TypeScript: кажемо компілятору що значення точно не null.
🗣 Як читати вголос PWA — Progressive Web App — це веб-сайт який поводиться як нативний додаток: встановлюється на головний екран, працює офлайн, отримує push-сповіщення. Складається з трьох компонентів: Service Worker який кешує ресурси і перехоплює мережеві запити, Web App Manifest — JSON файл з описом додатку завдяки якому браузер пропонує встановлення, і обов'язковий HTTPS. Playwright дозволяє тестувати PWA через context.setOffline(true) для симуляції офлайн режиму — завантажуємо сторінку онлайн щоб Service Worker скешував ресурси, потім вимикаємо мережу і перевіряємо що сторінка все одно показує контент. Також можна перевірити наявність і вміст Web App Manifest через звичайний HTTP запит і DOM перевірку наявності link[rel="manifest"].
7 8
61 Як перехопити мережевий запит і замокати відповідь?
МережаMiddle

page.route() — це метод який каже Playwright: "коли браузер спробує зробити запит на цю адресу — не відправляй його, а спочатку запитай мене що робити".

route — це екземпляр класу Route з Playwright. Коли Playwright передає route в твій callback — він передає об'єкт цього класу з усіма його методами. В TypeScript можна написати явно: async (route: Route) => {} — але зазвичай тип виводиться автоматично.

Що є в об'єкті route:

route.request()           // інформація про запит — екземпляр класу Request
route.request().url()      // URL запиту
route.request().method()   // GET, POST, PUT...
route.request().headers()  // headers запиту
route.request().postData() // тіло POST запиту

route.fulfill()   // відповісти своєю відповіддю
route.continue()  // пропустити запит далі на сервер
route.fetch()     // виконати запит і отримати відповідь в Node.js
route.abort()     // заблокувати запит повністю

route — це об'єкт який Playwright передає тобі в callback. Він представляє один перехоплений запит і дає тобі вибір що з ним робити. Тобто page.route() — це "митниця" для мережевих запитів. Кожен запит що відповідає паттерну проходить через тебе, і ти вирішуєш що з ним робити.

Тести залежать від реального API — і це проблема. API може бути недоступний в CI, повертати різні дані кожного разу, або робити реальні операції (списувати гроші, надсилати листи). page.route() перехоплює мережевий запит і дозволяє повернути власну відповідь замість звернення до реального сервера.

Як це працює: page.route(pattern, callback) — це "митниця" для мережевих запитів. Перший аргумент — паттерн URL (наприклад '**/api/**' — всі запити до /api/). Другий — функція яку Playwright викличе для кожного запиту що збігається з паттерном.

route — об'єкт що передається в callback. Представляє один перехоплений запит. Дає вибір що з ним робити:

  • route.fulfill() — повернути власну відповідь, реальний сервер не викликається
  • route.continue() — відправити запит далі, але можна змінити headers або тіло
  • route.abort() — заблокувати запит повністю
  • route.fetch() — виконати реальний запит, отримати відповідь і вирішити що повернути браузеру

Це не обман тесту — це контроль над залежностями. Тест перевіряє логіку UI, а не стабільність зовнішнього сервісу.

context.route() vs page.route(): page.route() перехоплює запити тільки з конкретної сторінки. context.route() — для всіх сторінок в BrowserContext. Корисно для глобальних моків що мають спрацьовувати на всіх вкладках. Порядок обробки: спочатку спрацьовує page.route(), потім context.route() — можна перекривати глобальні роути локальними.

TypeScript — route interception
// Замінити відповідь API
await page.route('**/api/products', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Test Product', price: 99.99 }
    ]),
  });
});

// Модифікувати реальну відповідь
await page.route('**/api/user', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  json.isPremium = true;  // додаємо поле
  await route.fulfill({ json });
});

// Блокувати запити (аналітика, ads)
await page.route('**/*.{png,jpg,css}', route => route.abort());

// Шпигувати за запитами (без перехоплення)
page.on('request', req => {
  if (req.url().includes('/api/')) console.log(req.method(), req.url());
});
🔧 TypeScript / JS патерни в цьому прикладі await page.route('**/api/**', async route => {}) — передача async стрілочної функції як callback; route.fulfill({ status: 200, body: JSON.stringify(data) }) — options object; JSON.stringify() — серіалізація об'єкта в JSON рядок; glob pattern **/api/** — wildcard: ** означає "будь-яка кількість символів включно з /".
🗣 Як читати вголос Викликаємо page.route, передаємо патерн URL і асинхронний колбек з об'єктом route. Всередині — route.fulfill, відкриваємо дужки, передаємо об'єкт: status — HTTP статус, contentType — заголовок, body — тіло як рядок через JSON.stringify. Другий варіант: викликаємо route.fetch() — він справді іде на сервер, отримуємо реальну відповідь, модифікуємо її і передаємо в fulfill({ json }). route.abort() — просто скасовує запит, браузер отримає мережеву помилку. page.on('request', ...) — лише спостереження без зміни запиту.
62 Як модифікувати запит або відповідь через page.route()? Різниця між fulfill, continue, fetch і abort
МережаSenior

page.route() — це метод який каже Playwright: "коли браузер спробує зробити запит на цю адресу — не відправляй його, а спочатку запитай мене що робити". route — це об'єкт який Playwright передає в callback і який представляє один перехоплений запит.

Більшість знають тільки один сценарій — повністю замінити відповідь моком. Насправді є чотири різних дії, і кожна для свого випадку:

  • route.fulfill() — повернути відповідь без реального запиту. Ти вигадуєш що повернути. Сервер не викликається.
  • route.continue() — відправити запит далі на реальний сервер, але з можливістю змінити headers, метод, тіло або URL перед відправкою. Відповідь іде напряму в браузер.
  • route.fetch() — виконати реальний запит, отримати відповідь в Node.js, змінити її і повернути браузеру. "Людина посередині".
  • route.abort() — заблокувати запит повністю. Браузер отримає мережеву помилку.

Головна різниця між continue() і fetch(): continue() відправляє запит і забуває — відповідь іде напряму в браузер, ми її не бачимо. fetch() відправляє запит, ми отримуємо відповідь в Node.js, читаємо і змінюємо, і тільки потім віддаємо браузеру через fulfill().

TypeScript — route.fulfill(): повний мок без реального запиту
// Реальний сервер не викликається — відповідь повністю вигадана
await page.route('**/api/products', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      items: [
        { id: 'SKU-001', name: 'Trench Coat', price: 1990 },
        { id: 'SKU-002', name: 'Scarf', price: 290 },
      ]
    }),
  });
});

await page.goto('/products');
// Сторінка отримала наш мок — завжди одні й ті самі дані, стабільно
TypeScript — route.continue(): модифікуємо запит перед відправкою
// Запит іде на реальний сервер, але з нашими зміненими headers
await page.route('**/api/**', async route => {
  // Беремо оригінальні headers і додаємо свої
  const headers = {
    ...route.request().headers(), // всі оригінальні headers
    'Authorization': 'Bearer test-token-123', // додаємо токен
    'X-Test-Mode': 'true',               // додаємо кастомний header
  };

  // Відправляємо з нашими headers — відповідь іде напряму в браузер
  await route.continue({ headers });
});

// Можна також змінити тіло POST запиту:
await page.route('**/api/cart/add', async route => {
  const originalBody = JSON.parse(route.request().postData() || '{}');

  // Підміняємо SKU на тестовий товар
  const modifiedBody = { ...originalBody, sku: 'TEST-SKU-999' };

  await route.continue({
    postData: JSON.stringify(modifiedBody),
    headers: {
      ...route.request().headers(),
      'Content-Type': 'application/json',
    },
  });
});
TypeScript — route.fetch(): отримуємо реальну відповідь і модифікуємо її
// "Людина посередині": запит пішов на сервер, відповідь прийшла до нас,
// ми її змінили і віддали браузеру
await page.route('**/api/products', async route => {
  // 1. Виконуємо реальний запит — отримуємо справжню відповідь
  const response = await route.fetch();
  const body = await response.json();

  // 2. Модифікуємо відповідь — додаємо тестовий товар у список
  body.items.push({ id: 'TEST-001', name: 'Test Product', price: 0 });

  // 3. Повертаємо модифіковану відповідь браузеру
  await route.fulfill({
    status: response.status(),          // оригінальний статус
    headers: response.headers(),        // оригінальні headers
    body: JSON.stringify(body),          // модифіковане тіло
  });
});
TypeScript — route.abort(): блокуємо запити
// Блокуємо всі запити до аналітики — тести не залежать від сторонніх сервісів
await page.route('**/{analytics,tracking,ads}/**', route => route.abort());

// Блокуємо зображення — тести швидші якщо нам не важлива візуальна частина
await page.route('**/*.{png,jpg,jpeg,gif,webp,svg}', route => route.abort());

// Перевіряємо що запит до видаленого ресурсу повертає 404:
await page.route('**/api/deleted-product', route =>
  route.fulfill({ status: 404, body: 'Not Found' })
);
TypeScript — комбінуємо кілька route handlers в одному тесті
// Різні правила для різних URL — призначаємо кілька route одночасно
test('checkout з моком API і реальним сервером авторизації', async ({ page }) => {
  // Мокаємо продукти — стабільні тестові дані
  await page.route('**/api/products/**', route =>
    route.fulfill({ status: 200, body: JSON.stringify({ price: 1990 }) })
  );

  // Блокуємо аналітику — не хочемо зовнішніх запитів
  await page.route('**/gtm.js', route => route.abort());

  // Auth запити — додаємо тестовий токен але відправляємо реально
  await page.route('**/api/auth/**', async route => {
    await route.continue({
      headers: { ...route.request().headers(), 'X-Test-Token': 'abc123' }
    });
  });

  await page.goto('/checkout');
});
💡 Коли що використовувати:
Потрібні стабільні тестові дані → route.fulfill() — повний мок
Треба додати header або токен → route.continue({ headers }) — модифікуємо запит
Треба змінити відповідь зберігши реальні дані → route.fetch() + route.fulfill()
Треба прибрати зовнішні залежності (аналітика, реклама) → route.abort()
🔧 TypeScript / JS патерни в цьому прикладі await page.route('**/api/**', async route => {}) — async callback для route handler; const response = await route.fetch() — await на fetch всередині route handler; const body = await response.json() — другий await; body.items.push({ ... }) — мутація масиву в об'єкті.
🗣 Як читати вголос page.route() перехоплює мережеві запити і дає чотири варіанти що з ними робити. fulfill() — повністю вигадати відповідь: реальний сервер не викликається, браузер отримує те що ми описали у статус, contentType і body. continue() — відправити запит далі на реальний сервер, але перед цим можна змінити headers, тіло POST запиту, метод або URL; відповідь від сервера іде напряму в браузер без нашої участі. fetch() — виконати реальний запит і отримати відповідь в Node.js: це дає можливість прочитати що сервер повернув, змінити це і тільки потім передати браузеру через fulfill() — класична схема "людина посередині". abort() — заблокувати запит повністю: браузер отримає мережеву помилку, корисно щоб відрізати зовнішні сервіси типу аналітики або зображення для прискорення тестів. Можна комбінувати кілька page.route() в одному тесті для різних URL — Playwright застосує перший handler який збігається.
63 Навіщо після route.fetch() ще й route.fulfill()? Як це працює разом?
МережаSenior

Без перехоплення відповідь іде напряму від сервера в браузер — ти її не бачиш і не можеш змінити. Щоб змінити відповідь — треба спочатку "забрати" її собі, а потім явно віддати браузеру.

route.fetch() — каже "зроби реальний запит але принеси відповідь мені в Node.js, а не відправляй одразу в браузер". Браузер чекає.

route.fulfill() — каже "ось що відправ браузеру". Тільки після цього браузер отримує відповідь.

БЕЗ перехоплення:
Сервер → відповідь → Браузер

З route.fetch() + fulfill():
Сервер → відповідь → Node.js (ти читаєш і змінюєш) → Браузер
                         ↑                                  ↑
                      route.fetch()                    route.fulfill()

Важливо: fetch() тільки отримує відповідь в Node.js — але не відправляє її браузеру автоматично. Без fulfill() в кінці браузер чекатиме вічно. fetch() = "принеси мені". fulfill() = "тепер віддай браузеру".

📌 Playwright 1.48+: виклики route.fulfill() більше не показуються в Trace Viewer. Замість цього перехоплені запити видно у вкладці Network в trace — там показано що запит був перехоплений і яку відповідь отримав браузер.

TypeScript — fetch() + fulfill(): додаємо тестовий товар до реальних даних
await page.route('**/api/products', async route => {
  // 1. Виконуємо реальний запит — отримуємо справжні дані від сервера
  //    Браузер чекає, він ще нічого не отримав
  const response = await route.fetch();
  const body = await response.json();

  // 2. Модифікуємо — додаємо тестовий товар до реального списку
  body.items.push({ id: 'TEST-001', name: 'Test Product', price: 0 });

  // 3. Тільки тепер відправляємо браузеру — з нашою зміною
  //    Без цього рядка браузер чекатиме вічно!
  await route.fulfill({
    status: response.status(),     // оригінальний статус від сервера
    headers: response.headers(),   // оригінальні headers
    body: JSON.stringify(body),     // наше модифіковане тіло
  });
});
TypeScript — умовний мок: є продукт → пропускаємо, немає → мок
// Реальний кейс: сервер не завжди повертає тестовий продукт
// Якщо є — пропускаємо без змін. Якщо немає — підміняємо моком.
await page.route('**/api/products/**', async route => {
  // 1. Завжди виконуємо реальний запит
  const response = await route.fetch();
  const body = await response.json();

  if (body.items && body.items.length > 0) {
    // 2a. Продукт є — передаємо реальну відповідь браузеру без змін
    await route.fulfill({
      status: response.status(),
      headers: response.headers(),
      body: JSON.stringify(body),
    });
  } else {
    // 2b. Продукту немає — повертаємо мок
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        items: [{ id: 'TEST-SKU-001', name: 'Trench Coat', price: 1990 }]
      }),
    });
  }
});

// Важливо: НЕ використовуємо continue() після fetch() —
// continue() зробив би ДРУГИЙ реальний запит.
// Ми вже маємо відповідь — просто передаємо через fulfill().
🔧 TypeScript / JS патерни в цьому прикладі await route.fetch() — async виклик що повертає Response об'єкт; await response.json() — другий await для читання тіла (воно теж асинхронне); body.items.push() — мутація масиву всередині об'єкта; JSON.stringify(body) — серіалізація назад в рядок перед передачею.
🗣 Як читати вголос route.fetch() і route.fulfill() завжди використовуються разом коли треба отримати реальну відповідь і щось в ній змінити. fetch() виконує реальний запит до сервера але замість відправити відповідь одразу в браузер — повертає її в Node.js. Браузер в цей момент чекає. Ми читаємо відповідь, змінюємо що треба, і тільки потім через fulfill() явно кажемо що саме відправити браузеру. Без fulfill() браузер чекатиме вічно бо Playwright перехопив запит але так і не дав відповідь. Думай про це як "людина посередині": запит пішов на сервер, відповідь прийшла до нас, ми її прочитали і змінили, і тільки потім передали браузеру.
64 Як винести логіку page.route() на рівень конфігурації щоб вона спрацьовувала для всіх тестів?
МережаSenior

Якщо одна і та сама логіка перехоплення потрібна в багатьох тестах — не треба повторювати її в кожному. Виноситься в auto fixture в базовому файлі. Fixture спрацьовує автоматично для кожного тесту який імпортує test з цього файлу.

Два варіанти: auto: true — для всіх тестів автоматично. Без auto — тільки для тестів де явно запитуєш fixture.

fixtures/base.ts — логіка route() в auto fixture
import { test as base } from '@playwright/test';

export const test = base.extend({
  // auto: true — спрацьовує для ВСІХ тестів автоматично
  mockMissingProducts: [async ({ page }, use) => {
    await page.route('**/api/products/**', async route => {
      const response = await route.fetch();
      const body = await response.json();

      if (body.items?.length > 0) {
        // Є продукт — пропускаємо реальну відповідь
        await route.fulfill({
          status: response.status(),
          headers: response.headers(),
          body: JSON.stringify(body),
        });
      } else {
        // Немає продукту — повертаємо мок
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({
            items: [{ id: 'TEST-SKU-001', name: 'Trench Coat', price: 1990 }]
          }),
        });
      }
    });

    await use(undefined); // тест виконується тут
    // route автоматично очищається після тесту
  }, { auto: true }], // ← auto: true = для всіх тестів автоматично
});
TypeScript — як використовувати в тестах
// ✅ Варіант 1: auto: true — тест нічого не знає про мок
import { test } from '../fixtures/base'; // ← важливо: наш test, не з @playwright/test

test('перевірка PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');
  // mockMissingProducts спрацює автоматично
});

// ✅ Варіант 2: без auto — тільки де явно запитуєш
export const test2 = base.extend({
  mockMissingProducts: async ({ page }, use) => { /* та сама логіка */ }
  // без { auto: true }
});

test('тест без моку', async ({ page }) => { /* мок не спрацює */ });
test('тест з моком', async ({ page, mockMissingProducts }) => { /* спрацює */ });
💡 Коли auto: true а коли без:
auto: true — логіка потрібна завжди для всіх тестів (блокування аналітики, умовні моки)
Без auto — логіка потрібна тільки в конкретних тестах або файлах
🔧 TypeScript / JS патерни в цьому прикладі base.extend({ fixtureName: [async fn, { auto: true }] }) — масив де перший елемент функція, другий конфігурація; await use(undefined) — передаємо undefined бо fixture нічого не повертає в тест; ?.length — optional chaining: якщо items не існує — повертає undefined а не кидає помилку.
🗣 Як читати вголос Щоб логіка page.route() спрацьовувала для всіх тестів без повторення — виносимо її в auto fixture в базовому файлі. Fixture визначається через base.extend() де другий елемент масиву це об'єкт { auto: true } — це означає що fixture запускається для кожного тесту автоматично без оголошення в параметрах. Тест імпортує test з нашого базового файлу замість стандартного @playwright/test і більше нічого не робить — мок спрацьовує сам. Якщо мок потрібен не всім тестам — прибираємо auto: true і явно запитуємо fixture в параметрах тих тестів де він потрібен.
65 Що таке HAR-файли і як використовувати page.routeFromHAR() для стабільних тестів без живого API?
МережаSenior

HAR розшифровується як HTTP Archive — це JSON-формат який містить повний запис всіх мережевих запитів і відповідей: URL, метод, headers, тіло запиту, тіло відповіді, статус код, час — все.

Навіщо це в тестах? Уяви що тестуєш сторінку яка залежить від зовнішнього API — наприклад, сервіс рекомендацій товарів або платіжний шлюз. У тебе три проблеми: зовнішній API може бути недоступний в CI; може повертати різні дані кожного разу і тести стають недетермінованими; кожен реальний запит до платіжного шлюзу — це або гроші, або складний тестовий аккаунт.

HAR вирішує це елегантно: ти один раз записуєш реальну сесію з живим API, і потім Playwright відтворює ці відповіді локально без жодних реальних запитів. Браузер думає що спілкується з API — але насправді отримує збережені відповіді з файлу.

  • ЗаписrouteFromHAR(path, { update: true }). Playwright робить реальні мережеві запити і одночасно записує їх у файл. Запускаєш один раз — отримуєш HAR файл.
  • ВідтворенняrouteFromHAR(path). Playwright перехоплює запити і замість реальних викликів повертає збережені відповіді. Швидко, стабільно, без залежності від зовнішніх сервісів.

Різниця від page.route() з ручним fulfill(): HAR автоматично записує реальні відповіді з усіма headers, cookies і тілом — не треба руками описувати мок-відповідь. Особливо цінно для складних API де відповідь має специфічну структуру яку важко відтворити вручну.

TypeScript — крок 1: запис HAR файлу (один раз з живим API)
// Цей тест запускаємо ОДИН РАЗ щоб записати реальні відповіді
// update: true — режим запису: робить реальні запити І зберігає їх у файл
test('записати HAR для сторінки рекомендацій', async ({ page }) => {
  await page.routeFromHAR(
    './test-data/recommendations.har', // куди зберегти файл
    {
      update: true,       // режим запису (не відтворення)
      url: '**/api/**',   // записувати тільки API запити, не статику
    }
  );

  await page.goto('/product/trench-coat');
  // Playwright зробив реальні запити до API і зберіг відповіді у .har файл
  // Файл виглядає як JSON з масивом entries де кожен entry — запит + відповідь
});
TypeScript — крок 2: відтворення HAR у всіх наступних тестах
// Тепер HAR файл є — використовуємо його для стабільних тестів
// БЕЗ update: true — режим відтворення: перехоплює запити і повертає з файлу
test('блок рекомендацій показує 4 товари', async ({ page }) => {
  await page.routeFromHAR('./test-data/recommendations.har', {
    url: '**/api/**', // перехоплювати тільки API — статика йде в мережу
  });

  await page.goto('/product/trench-coat');
  // Запити до /api/** → відповіді з HAR файлу (без реального API)
  // Запити до статики → реальна мережа

  await expect(page.locator('.recommendation-card')).toHaveCount(4);
  // Завжди 4 — бо відповідь зафіксована в HAR, API більше не викликається
});

test('платіжна форма з тестовим токеном', async ({ page }) => {
  // Записали HAR з реальним платіжним шлюзом один раз
  // Тепер тести не роблять реальних запитів до платіжника
  await page.routeFromHAR('./test-data/payment.har');
  await page.goto('/checkout/payment');
  await expect(page.locator('.payment-form')).toBeVisible();
});
TypeScript — налаштування що робити якщо запит не знайдений в HAR
// За замовчуванням якщо запит не знайдений в HAR — Playwright кидає помилку
// Можна змінити поведінку через fallback:
await page.routeFromHAR('./test-data/api.har', {
  url: '**/api/**',
  fallback: 'continue', // якщо не знайдено в HAR → зробити реальний запит
  // або 'abort' → заблокувати запит (за замовчуванням 'abort' → помилка)
});

// notFound: 'fallback' — більш явна назва для того самого
await page.routeFromHAR('./test-data/api.har', {
  notFound: 'fallback', // незнайомі запити йдуть в реальну мережу
});

// Корисно при поступовому переході: спочатку частина запитів з HAR,
// нові endpoint-и ще не записані — fallback дозволяє тестам продовжувати працювати
TypeScript — HAR через контекст (для всіх сторінок в тесті)
// page.routeFromHAR() — тільки для цієї page
// context.routeFromHAR() — для ВСІХ page в цьому контексті
test('multi-page сценарій з HAR', async ({ context }) => {
  // Всі сторінки що відкриються в цьому context — отримають HAR
  await context.routeFromHAR('./test-data/full-session.har', {
    url: '**/api/**',
  });

  const page1 = await context.newPage();
  const page2 = await context.newPage(); // теж отримає HAR автоматично

  await page1.goto('/products');
  await page2.goto('/cart');
});
💡 Практичний workflow з HAR:
1. Запускаєш тест з update: true один раз — HAR файл записаний
2. Комітиш HAR файл у репозиторій разом з тестами
3. В CI тести використовують HAR — жодних реальних запитів до зовнішніх API
4. Коли API змінилось — перезаписуєш HAR: знову запускаєш з update: true

HAR файл — це "знімок API" який живе в git разом з кодом тестів.
🔧 TypeScript / JS патерни в цьому прикладі await page.routeFromHAR('file.har', { update: true, url: '**/api/**' }) — options object; { update: true } — булевий прапор; notFound: 'fallback' — рядкове enum; glob pattern **/api/** — wildcard для URL matching.
🗣 Як читати вголос HAR — це HTTP Archive, JSON файл який містить повний запис мережевої сесії: кожен запит, кожна відповідь, всі headers і тіло. page.routeFromHAR() має два режими. З update: true — це запис: Playwright робить реальні мережеві запити і паралельно зберігає їх у файл. Цей режим запускається один раз. Без update: true — це відтворення: Playwright перехоплює мережеві запити і замість реальних викликів повертає збережені відповіді з файлу, тобто зовнішнє API взагалі не викликається. Головна перевага перед ручним page.route() з fulfill() в тому що не треба описувати мок-відповідь вручну — HAR автоматично зберігає реальну відповідь з усіма деталями. Особливо цінно для складних API типу платіжних шлюзів де структура відповіді складна і важко відтворити її руками. Workflow простий: записуєш HAR один раз, комітиш у репозиторій, і тепер CI-тести не залежать від зовнішніх сервісів. Коли API змінилось — перезаписуєш HAR знову з update: true.
66 Як тестувати WebSocket з'єднання?
МережаSenior

WebSocket — це постійне двостороннє з'єднання між браузером і сервером. Використовується для real-time даних: чат, live ціни, сповіщення. Є два підходи: спостерігати за реальним WebSocket через page.on('websocket'), або перехоплювати і мокати через page.routeWebSocket() — новий API з Playwright 1.48+.

TypeScript — Підхід 1: спостерігаємо за реальним WebSocket
test('real-time notifications via WebSocket', async ({ page }) => {
  const messages: string[] = [];

  // Підписуємось на WS події ДО навігації
  page.on('websocket', (ws) => {
    ws.on('framesent', frame => console.log('sent:', frame.payload));
    ws.on('framereceived', frame => {
      messages.push(frame.payload.toString());
    });
    ws.on('close', () => console.log('WS closed'));
  });

  await page.goto('/notifications');
  await page.request.post('/api/trigger-notification');

  await expect.poll(() => messages.length, { timeout: 10_000 })
    .toBeGreaterThan(0);

  const parsed = JSON.parse(messages[0]);
  expect(parsed.type).toBe('order_update');
});
TypeScript — Підхід 2: мокаємо WebSocket через routeWebSocket() (Playwright 1.48+)
test('WebSocket mock — live price updates', async ({ page }) => {
  // Перехоплюємо WebSocket з'єднання до вказаного URL
  await page.routeWebSocket('wss://prices.myshop.com/ws', async ws => {
    // ws — об'єкт WebSocketRoute

    // Слухаємо повідомлення від браузера до сервера
    ws.onMessage(message => {
      console.log('browser sent:', message);
    });

    // Надсилаємо мок-повідомлення від "сервера" браузеру
    ws.send(JSON.stringify({ price: '£1,990', sku: '8078560' }));
  });

  await page.goto('/product/trench-coat');
  await expect(page.locator('.live-price')).toHaveText('£1,990');
});

// Також доступно на рівні context — для всіх сторінок:
// await context.routeWebSocket('wss://prices.myshop.com/ws', handler);
💡 Коли який підхід:
page.on('websocket') — спостерігаємо за реальним WebSocket, сервер відповідає справжніми даними
page.routeWebSocket() — мокаємо WebSocket повністю, реальний сервер не потрібен. Корисно для тестування real-time UI без залежності від backend.
🔧 TypeScript / JS патерни в цьому прикладі page.on('websocket', ws => {}) — підписка на подію; ws.on('framereceived', event => {}) — вкладена підписка; page.routeWebSocket(url, handler) — перехоплення WebSocket з'єднання (Playwright 1.48+); ws.onMessage() — слухаємо повідомлення від браузера; ws.send() — надсилаємо повідомлення браузеру.
🗣 Як читати вголос Є два підходи до тестування WebSocket в Playwright. Перший — спостерігати за реальним з'єднанням через page.on('websocket'): підписуємось на події ДО навігації, отримуємо об'єкт ws і слухаємо framereceived — кадри від сервера. Другий підхід — page.routeWebSocket() доданий в Playwright 1.48: перехоплює WebSocket з'єднання до вказаного URL і дає повний контроль — можна слухати повідомлення від браузера через ws.onMessage() і надсилати мок-відповіді через ws.send(). Реальний сервер не потрібен. Також доступно на рівні context через context.routeWebSocket() — спрацьовує для всіх сторінок в контексті.
67 Як тестувати API requests напряму (без браузера) в Playwright?
МережаMiddle

Не все треба тестувати через браузер. Якщо потрібно перевірити що API повертає правильні дані, або підготувати тестові дані перед UI тестом — fixture request робить HTTP запити напряму. Браузер взагалі не відкривається — запити виконуються через мережевий стек Node.js. Це в 10-20 разів швидше.

⚠️ request fixture vs page.request — різні речі:

  • request (fixture) — незалежний APIRequestContext. Свої cookies, не пов'язаний з жодним BrowserContext. Для чистого API тестування.
  • page.request — прив'язаний до BrowserContext сторінки і використовує його cookies. Тобто якщо ти залогінена в браузері — page.request вже автентифікований. Для запитів "від імені" залогіненого юзера.

Це класичне питання на Senior інтерв'ю. Практично: підготувати дані перед тестом → request. Перевірити API-виклик який робить залогінений юзер → page.request.

TypeScript
import { test, expect } from '@playwright/test';

test('POST /api/users creates user', async ({ request }) => {
  const response = await request.post('/api/users', {
    headers: { 'Authorization': 'Bearer token123' },
    data: { name: 'John', email: 'john@test.com' },
  });

  await expect(response).toBeOK();
  expect(response.status()).toBe(201);

  const body = await response.json();
  expect(body).toMatchObject({
    name: 'John',
    email: 'john@test.com',
    id: expect.any(Number),
  });
});

// Глобальна авторизація для APIRequestContext
const apiContext = await request.newContext({
  baseURL: 'https://api.myapp.com',
  extraHTTPHeaders: { 'x-api-key': process.env.API_KEY! },
});
🔧 TypeScript / JS патерни в цьому прикладі const response = await request.get('/api/products') — await на методі що повертає Promise; await response.json() — другий await для читання тіла відповіді; expect(response).toBeOK() — перевірка статус коду через matcher; await response.text() — альтернативний метод для текстової відповіді.
🗣 Як читати вголос Тест деструктурує request із fixtures — це APIRequestContext без жодного браузера. Викликаємо request.post, передаємо URL і об'єкт з headers і data — дані серіалізуються автоматично. expect(response).toBeOK() перевіряє що статус 2xx. response.status() — метод що повертає число, порівнюємо з 201. response.json() парсить тіло, toMatchObject перевіряє що об'єкт містить очікувані поля, expect.any(Number) — що поле id є числом будь-якого значення.
68 Як перехопити і валідувати GTM/dataLayer події?
МережаMiddle

GTM (Google Tag Manager) — це інструмент маркетингу який відстежує дії користувачів на сайті: клікнув кнопку, додав товар в кошик, оформив замовлення. Ці дані відправляються в Google Analytics, Facebook Pixel і інші системи аналітики.

dataLayer — це масив в JavaScript через який сторінка "розмовляє" з GTM. Коли юзер додає товар — код сайту пушить подію в dataLayer. GTM читає цей масив і відправляє дані в аналітику.

// Так виглядає подія в dataLayer коли юзер додає товар:
window.dataLayer.push({
  event: 'add_to_cart',
  ecommerce: {
    items: [{ item_id: 'SKU-123', item_name: 'Trench Coat', price: 1990 }]
  }
});

Навіщо тестувати? Якщо GTM подія не спрацює — бізнес не побачить що товар додали в кошик, не зможе рахувати конверсії і налаштувати ремаркетинг. Це напряму впливає на revenue. Тому в e-commerce тестування GTM подій — критична частина QA.

TypeScript — ✅ простий спосіб: expect.poll()
test('fires add_to_cart GTM event', async ({ page }) => {
  await page.goto('/product/123');
  await page.getByRole('button', { name: 'Add to Cart' }).click();

  // expect.poll() ретраїть функцію поки вона не поверне truthy значення
  // Playwright сам повторює evaluate() поки dataLayer не матиме потрібну подію
  await expect.poll(async () => {
    return await page.evaluate(() =>
      (window as any).dataLayer?.find((e: any) => e.event === 'add_to_cart')
    );
  }).toMatchObject({
    event: 'add_to_cart',
    ecommerce: { items: [{ item_id: 'SKU-123' }] }
  });
});
TypeScript — просунутий спосіб: перехоплення push() через Promise (коли потрібна синхронність)
// Використовується коли потрібно перехопити подію ДО того як GTM її обробить
// Складніше але гарантує що не пропустимо миттєву подію
test('fires add_to_cart GTM event', async ({ page }) => {
  await page.goto('/product/123');

  // Підміняємо dataLayer.push своєю функцією ДО кліку
  const dataLayerPromise = page.evaluate(() =>
    new Promise(resolve => {
      (window as any).dataLayer = (window as any).dataLayer || [];
      const orig = (window as any).dataLayer.push.bind((window as any).dataLayer);
      (window as any).dataLayer.push = (...args: any[]) => {
        if (args[0]?.event === 'add_to_cart') resolve(args[0]);
        return orig(...args); // GTM продовжує працювати
      };
    })
  );

  await page.getByRole('button', { name: 'Add to Cart' }).click();
  const event = await dataLayerPromise;
  expect(event).toMatchObject({ event: 'add_to_cart' });
});
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForFunction(() => {}) — передача стрілочної функції яка виконується в браузері; (window as any).dataLayer — type assertion; dl.some((e: any) => e.event === 'add_to_cart') — Array.some() з стрілочним callback; dl.find() — Array.find() повертає перший елемент що відповідає умові.
🗣 Як читати вголос Викликаємо page.evaluate — це виконання коду всередині браузера, а не в Node.js. Всередині повертаємо new Promise, тобто проміс що зрезолвиться коли GTM надішле потрібну подію. Зберігаємо оригінальний dataLayer.push в змінну orig, потім перезаписуємо метод своєю функцією: вона перевіряє кожен push — якщо event дорівнює 'add_to_cart', резолвить проміс з цим об'єктом, і завжди викликає оригінальний orig щоб GTM продовжував працювати. Клік відбувається після того як перехоплювач встановлено, потім await dataLayerPromise чекає поки GTM спрацює.
8 6
69 Як ти дебажиш тест, що падає тільки в CI, а локально проходить?
DebugMiddle

Тест падає в CI але локально проходить — одна з найнеприємніших ситуацій. Зазвичай причина в різниці середовища: інший viewport, швидкість мережі, headless режим без GPU, або різний порядок при паралельному запуску.

Режими trace — важливо знати всі:

  • trace: 'on' — записує для кожного тесту (повільно, для дебагу)
  • trace: 'retain-on-failure' — записує для всіх, видаляє для успішних
  • trace: 'on-first-retry'найпоширеніший у production: записує тільки при першому retry. Не навантажує CI при нормальному прогоні
  • trace: 'off' — вимкнено
Workflow: Playwright Inspector, page.pause(), Trace Viewer
// 1. Локальна репродукція: запускаємо headless, як в CI (не headed!)
// npx playwright test login.spec.ts --headed=false --workers=1

// 2. Вмикаємо trace ЗАВЖДИ при retry (типове налаштування для CI)
// playwright.config.ts:
export default defineConfig({
  use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' },
});

// 3. Точкова зупинка прямо в коді тесту (працює тільки headed/--debug)
await page.pause(); // відкриває Playwright Inspector, можна крокувати вручну

// 4. Аналіз trace.zip з CI артефактів — без повторного запуску
// npx playwright show-trace trace.zip
// показує DOM-снепшоти, мережу, консоль і timeline КОЖНОЇ дії
Інструментарій дебагу — codegen, Inspector, UI Mode
# ── Codegen: записує твої дії в браузері як код ────────────────
npx playwright codegen https://myshop.com
npx playwright codegen --target=typescript https://myshop.com
npx playwright codegen --device="iPhone 14" https://myshop.com
# ⚠️ Codegen генерує хорошу ОСНОВУ, а не готовий тест.
# Згенерований код майже завжди треба рефакторити: винести в Page Object,
# замінити крихкі локатори на семантичні, додати нормальні assertions.
# Основна цінність — підказує який локатор обрати для елемента.

# ── Playwright Inspector: покрокове виконання ──────────────────
PWDEBUG=1 npx playwright test login.spec.ts
# PWDEBUG=1 — найпоширеніший спосіб: відкриває Inspector,
# вимикає таймаути і запускає headed. Можна крокувати по діях,
# дивитись locator playground і правити селектори наживо.

npx playwright test --debug   # те саме через CLI флаг

# ── UI Mode: головна перевага — швидкий re-run ─────────────────
npx playwright test --ui
# Дозволяє перезапускати ОКРЕМІ тести без рестарту всього процесу —
# правиш код, тиснеш re-run, бачиш результат за секунду.
# Плюс watch mode, timeline, DOM-снепшоти і фільтри по тегах.
TypeScript — page.pause(): точка зупинки прямо в коді
test('дебаг конкретного місця', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay' }).click();

  await page.pause();
  // ↑ Зупиняє виконання і відкриває Playwright Inspector.
  // Далі можна крокувати вручну, пробувати локатори в playground,
  // дивитись реальний стан сторінки в цей момент.
  // Працює тільки в headed режимі (--debug / PWDEBUG=1 / --headed).
  // ⚠️ Не забути прибрати перед комітом — в CI тест зависне.

  await expect(page.locator('.order-confirmed')).toBeVisible();
});
💡 Типові причини "тільки в CI": інша швидкість мережі/рендеру (CI повільніший за локальну машину), інший viewport/headless-режим, паралелізм воркерів конкурує за ресурси, відсутні env-змінні/секрети, інша timezone або locale контейнера.
🔧 TypeScript / JS патерни в цьому прикладі Переважно bash команди і конфігурація. PWDEBUG=1 npx playwright test — встановлення environment variable перед командою; --headed / --slowmo — CLI флаги; trace: 'on' — рядкове enum значення в конфіг об'єкті.
🗣 Як читати вголос Перший крок — спробувати відтворити локально в тих самих умовах, що й CI: headless-режим, а не headed, і з тим самим числом workers, бо паралелізм часто і є причиною — тести конкурують за CPU чи мережу, і race condition, який непомітний при одному worker, зʼявляється при шести. Якщо локально не відтворюється, я покладаюсь на артефакти з самого CI-прогону: налаштування trace: 'retain-on-failure' зберігає Playwright trace тільки для тестів, що впали, і його можна відкрити локально командою show-trace — це дає повний timeline, мережеві запити, DOM-снепшот до і після кожної дії, консольні логи, без потреби перезапускати щось у CI. Якщо я підозрюю конкретний момент у тесті, додаю page.pause() — це відкриває Playwright Inspector і дозволяє покроково виконувати дії вручну, дивлячись, що саме відбувається на сторінці. Найчастіші причини розбіжності CI проти локального — це швидкість рендеру, бо CI-контейнер повільніший, інший viewport за замовчуванням, або відсутні env-змінні, які локально підхоплюються з .env, а в CI мають бути явно прописані як secrets.
70 Як зробити скриншот і відео в тесті?
DebugJunior

Playwright вміє автоматично робити скріншоти і записувати відео при падінні тесту — це налаштовується в конфізі. Але іноді потрібно зробити скріншот вручну в певний момент тесту — наприклад щоб прикріпити до звіту або порівняти візуально.

Важливо про відео: файл відео фіналізується після закриття BrowserContext, тому він стає доступним тільки після завершення тесту. Під час виконання тесту відео ще не є готовим файлом.

fullPage: true — робить скріншот всієї сторінки включаючи частини поза viewport (потрібен скрол для перегляду). Без цього параметру знімається тільки видима область. Використовується для довгих сторінок або коли потрібно захопити весь контент.

TypeScript
// Скриншот вручну
await page.screenshot({ path: 'screenshot.png', fullPage: true });

// Скриншот конкретного елементу
await page.locator('.product-card').screenshot({ path: 'card.png' });

// Відео та trace — в config:
use: {
  video: 'retain-on-failure',    // always | never | retain-on-failure
  screenshot: 'only-on-failure',
  trace: 'on-first-retry',
}

// Переглянути trace:
// npx playwright show-trace trace.zip
🔧 TypeScript / JS патерни в цьому прикладі { path: 'screenshot.png', fullPage: true } — options object з булевим полем; await page.locator('.hero').screenshot() — method chaining: спочатку знаходимо елемент, потім робимо screenshot; use: { screenshot: 'only-on-failure' } — конфігурація через рядкові enum-значення.
🗣 Як читати вголос page.screenshot — метод на об'єкті page, передаємо об'єкт з path куди зберегти і fullPage: true щоб захопити всю сторінку, а не тільки viewport. Щоб зробити скриншот конкретного елементу — беремо локатор і викликаємо .screenshot() вже на ньому. В конфігу video: 'retain-on-failure' означає: записувати відео, але зберігати тільки якщо тест впав. trace: 'on-first-retry' — збираємо trace тільки при першому повторному запуску. Потім переглядаємо через CLI команду show-trace.
71 Як налаштувати retry та обробити flaky тести?
DebugMiddle

Flaky тест — тест який то проходить, то падає без змін в коді. Причини: race condition, залежність від зовнішнього сервісу, нестабільне середовище.

Важливо: retries не виправляють flaky тести — вони лише дозволяють їм пройти повторно. Це механізм підвищення стабільності CI, а не спосіб усунення першопричини. Flaky тести необхідно аналізувати і виправляти. Retries використовуються як тимчасове рішення або для нестабільних зовнішніх залежностей.

Ізоляція при retry: перед кожною повторною спробою Playwright створює новий BrowserContext — це гарантує що retry виконується в повністю ізольованому середовищі, без стану від попередньої спроби.

TypeScript
// Глобально в config:
retries: process.env.CI ? 2 : 0,

// Для конкретного тесту:
test('flaky animation test', { retries: 3 }, async ({ page }) => {
  // ...
});

// Визначити що тест на retry:
test('...', async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    console.log(`Retry #${testInfo.retry}`);
    await page.reload(); // clean state
  }
});

// Позначити відомо нестабільний тест:
test.fixme('known flaky: ticket PW-123', async () => { /* */ });
test.skip('skip on CI', () => process.env.CI === 'true', async () => { /* */ });
🔧 TypeScript / JS патерни в цьому прикладі retries: process.env.CI ? 2 : 0 — тернарний оператор condition ? valueIfTrue : valueIfFalse; process.env.CI — доступ до змінної оточення (environment variable) через глобальний об'єкт Node.js; test.fail() — виклик методу без аргументів.
🗣 Як читати вголос У конфігу retries: process.env.CI ? 2 : 0 — тернарний вираз: якщо в CI то два повтори, локально нуль. Для конкретного тесту — передаємо третім аргументом до test() об'єкт options з retries. У тілі тесту testInfo.retry — це поточний номер спроби, нуль при першому запуску. test.fixme — позначає тест як відомо зламаний, він буде скіпнутий і помічений у звіті окремо. test.skip з функцією-умовою — пропускає тест якщо умова true.
72 Що таке race condition і як вона проявляється в тестах?
DebugSenior

Race condition — це коли результат програми залежить від того в якому порядку виконались дві дії, і цей порядок непередбачуваний.

Простий приклад: два тести одночасно читають і змінюють одну змінну. Обидва прочитали 0 і обидва записали 1. Хто встиг другим — перезаписав результат першого. Очікувалось 2, отримали 1.

У тестах race condition — головна причина flaky тестів. Тест то проходить, то падає — залежно від того наскільки швидко спрацював UI або мережа в конкретний момент.

TypeScript — race condition з UI і як її уникнути
// ❌ Race condition — click може відбутись до того як UI оновився
await page.click('.add-to-cart');
expect(await page.locator('.cart-count').textContent()).toBe('1');
// cart-count може ще не оновитись в цей момент — тест flaky

// ✅ Web-first assertion сама чекає поки умова стане true
await page.click('.add-to-cart');
await expect(page.locator('.cart-count')).toHaveText('1');
// Playwright ретраїть перевірку поки не побачить '1' або не вийде таймаут
TypeScript — race condition з новою вкладкою і Promise.all як рішення
// ❌ Race condition — click може відкрити вкладку ДО того
// як waitForEvent встановлено, і ми її пропустимо
await page.click('.open-link');
const newPage = await context.waitForEvent('page'); // вже пізно!

// ✅ Promise.all гарантує порядок:
// спочатку встановлюємо слухача, потім клікаємо
// навіть якщо вкладка відкриється миттєво — ми вже слухаємо
const [newPage] = await Promise.all([
  context.waitForEvent('page'), // спочатку підписуємось
  page.click('.open-link'),   // потім клікаємо
]);
💡 Коротко: race condition — "хто перший той і правий" але ти не контролюєш хто буде першим.

Як уникати в Playwright:
— Замість await locator.textContent()await expect(locator).toHaveText()
— Замість "клік потім слухай" → Promise.all([слухай, клікай])
— Замість waitForTimeout(1000)waitForSelector() або web-first assertion
🔧 TypeScript / JS патерни в цьому прикладі Promise.all([p1, p2]) — запускає два Promise одночасно і чекає обидва; порядок в масиві визначає порядок підписки але не порядок виконання; const [newPage] = await Promise.all([...]) — деструктуризація масиву результатів; race condition — це про непередбачуваний порядок асинхронних операцій.
🗣 Як читати вголос Race condition — це коли результат залежить від порядку виконання двох операцій, і цей порядок непередбачуваний. У тестах це головна причина flaky тестів — тест то проходить, то падає залежно від швидкості UI або мережі. Типовий приклад: клікнули і одразу перевіряємо textContent — але UI ще не встиг оновитись. Рішення: web-first assertions які самі ретраяться поки умова не стане true. Інший приклад: спочатку клікаємо посилання що відкриває нову вкладку, потім намагаємось підписатись на подію — але вкладка вже відкрилась і ми пропустили подію. Рішення: Promise.all де спочатку встановлюємо слухача, потім клікаємо — навіть якщо вкладка відкриється миттєво ми вже слухаємо.
73 Які класи помилок є в Playwright і коли використовувати try/catch у тестах?
DebugSenior

Коли тест падає — Playwright кидає виключення. Але не одне універсальне "щось пішло не так", а конкретний клас помилки залежно від того що саме трапилось.

  • TimeoutError — найчастіший клас. Спрацьовує коли будь-який таймаут вийшов: action timeout на click(), expect timeout на toBeVisible(), або test timeout на весь тест.
  • strict mode violation — локатор знайшов більше одного елемента і ти намагаєшся виконати дію над "одним".
  • Target closed — сторінка або контекст були закриті під час виконання дії.
  • net::ERR_CONNECTION_REFUSED — браузер не зміг підключитись до URL. Або сервер не запущений, або неправильний порт.

Навіщо взагалі try/catch якщо Playwright сам ловить помилки?

Ти абсолютно права: у 95% випадків try/catch не потрібен. Playwright сам ловить помилку, записує у звіт, робить скріншот, і тест падає з червоним статусом. Це правильна поведінка. Але є три конкретні ситуації де без try/catch не обійтись:

Ситуація 1: елемент який може бути, а може не бути — і обидва варіанти правильні

Уяви банер "Безкоштовна доставка від 100$" — він зʼявляється тільки для певних юзерів або певних країн. Твій тест перевіряє щось інше (наприклад checkout), але хоче закрити цей банер якщо він є. Якщо написати просто click() без try/catch — тест впаде коли банера немає. А відсутність банера — це не баг, це нормально. Тут try/catch говорить "ця відсутність — очікувана, йдемо далі".

Ситуація 2: ти тестуєш що щось НЕ працює — негативний сценарій

Наприклад, неавторизований юзер не може зайти в /account. Ти навмисно хочеш побачити помилку — і треба це явно сказати Playwright через .rejects.toThrow(), інакше він сам зловить її і зафейлить тест.

Ситуація 3: хочеш додати контекст до помилки

Тест все одно впаде — але замість загального "timeout exceeded" ти отримаєш конкретне повідомлення з URL і заголовком сторінки де це сталось. Корисно для дебагу в CI де немає можливості відкрити браузер вручну.

⚠️ Головне: try/catch в Playwright — це не спосіб "врятувати" тест від падіння. Це спосіб сказати "ця конкретна помилка є очікуваною і я знаю як її обробити". Якщо ти ловиш помилку і тест йде далі як ні в чому не бувало — це майже завжди антипатерн.

TypeScript — читаємо повідомлення про помилку і розуміємо клас
// TimeoutError: expect(locator).toBeVisible() timeout 5000ms exceeded
// → expect timeout. Елемент не зʼявився за 5с
// → або елемент взагалі немає на сторінці, або треба більше часу

// TimeoutError: page.click: Timeout 30000ms exceeded
// → action timeout. Елемент знайдений але не став клікабельним
// → перекритий іншим елементом? disabled? анімується?

// Error: strict mode violation: locator('button') resolved to 5 elements
// → локатор занадто широкий, знайшов 5 кнопок замість однієї
// → звузь через .filter(), .first(), або більш специфічний selector

// Error: page.goto: net::ERR_CONNECTION_REFUSED
// → сервер не відповідає на цьому порту
// → перевір що baseURL в конфізі правильний і сервер запущений

// Error: Target closed
// → сторінка або context закрились поки ти з ними взаємодіяв
// → popup закрився сам? context.close() викликали занадто рано?
TypeScript — Ситуація 1: елемент який може бути, а може не бути
import { errors } from '@playwright/test';

test('checkout flow з опціональним банером', async ({ page }) => {
  await page.goto('/checkout');

  // ❌ Без try/catch — тест впаде якщо банера немає, хоча це нормально
  // await page.locator('.promo-banner .close').click();

  // ✅ З try/catch — обробляємо обидва варіанти
  try {
    await page.locator('.promo-banner .close').waitFor({ timeout: 2000 });
    await page.locator('.promo-banner .close').click();
  } catch (e) {
    if (!(e instanceof errors.TimeoutError)) throw e; // решту перекидаємо!
    // банера немає — нормально, йдемо далі
  }

  // Тест продовжується незалежно від наявності банера
  await page.locator('button.place-order').click();
});
TypeScript — Ситуація 2: негативний сценарій — перевіряємо що дія кидає помилку
test('неавторизований юзер не може зайти в /account', async ({ page }) => {
  // .rejects.toThrow() — говоримо Playwright: "я очікую що це впаде"
  // якщо НЕ впаде — тест зафейлиться (несподівано пройшло)
  await expect(
    page.goto('/account/orders')
  ).rejects.toThrow();

  // Або перевіряємо конкретний клас помилки
  await expect(
    page.locator('.admin-panel').click({ timeout: 1000 })
  ).rejects.toThrow(errors.TimeoutError);
});
TypeScript — Ситуація 3: додаємо корисний контекст до помилки перед падінням
test('оплата кредитною карткою', async ({ page }) => {
  await page.goto('/checkout');

  try {
    await page.locator('.payment-form').waitFor({ timeout: 10000 });
  } catch (e) {
    if (e instanceof errors.TimeoutError) {
      // Тест все одно впаде — але з корисним повідомленням для CI дебагу
      throw new Error(
        `Форма оплати не зʼявилась.
URL: ${page.url()}
Title: ${await page.title()}`
      );
    }
    throw e; // решту перекидаємо без змін
  }
});
TypeScript — три антипатерни і правильна версія
// ❌ Антипатерн 1: порожній catch — ковтає ВСІ помилки мовчки
try {
  await page.locator('.buy-btn').click();
} catch (e) {
  // нічого — тест піде далі навіть якщо кнопка не знайдена!
}

// ❌ Антипатерн 2: catch логує і йде далі — помилка прихована
try {
  await page.locator('.buy-btn').click();
} catch (e) {
  console.log('помилка але йдемо далі', e); // тест зелений, баг прихований
}

// ❌ Антипатерн 3: catch всього підряд — ловимо навіть "Target closed" і мережеві помилки
try {
  await page.locator('.banner').waitFor({ timeout: 2000 });
} catch (e) {
  // нічого не перевіряємо — ковтаємо все підряд
}

// ✅ Правильно: ловимо тільки конкретний клас, решту обовʼязково перекидаємо
try {
  await page.locator('.banner').waitFor({ timeout: 2000 });
} catch (e) {
  if (!(e instanceof errors.TimeoutError)) throw e;
  // тільки тут — обробляємо відсутність банера як нормальну ситуацію
}
💡 Золоте правило try/catch в тестах:
Перш ніж писати try/catch — запитай себе: "чи це легітимна опціональна поведінка або я намагаюсь обійти нестабільний тест?" Якщо друге — треба фіксити тест, а не ловити помилку. Якщо перше — лови тільки конкретний клас і завжди перекидай решту.
🔧 TypeScript / JS патерни в цьому прикладі try { ... } catch (e) { if (e instanceof errors.TimeoutError) { ... } else { throw e; } } — try/catch з instanceof перевіркою типу; instanceof — оператор перевірки типу об'єкту; throw e — повторне кидання помилки; new Error(\`message\`) — створення Error з template literal.
🗣 Як читати вголос Playwright кидає різні класи помилок залежно від того що трапилось, і читання повідомлення про помилку — перший крок діагностики. TimeoutError з текстом expect(locator).toBeVisible() timeout 5000ms exceeded — це expect timeout, елемент не зʼявився за 5 секунд. TimeoutError з текстом page.click: timeout exceeded — це action timeout, елемент знайдений але не став клікабельним. strict mode violation — локатор знайшов кілька елементів. Target closed — сторінка закрилась під час взаємодії. Кожен клас — окремий діагноз і окреме рішення. Щодо try/catch: в Playwright тестах він потрібен рідко, бо помилки автоматично потрапляють у звіт. Три легітимних кейси: перехоплення опціонального елемента де відсутність — це нормально, перевірка що дія очікувано кидає помилку через .rejects.toThrow(), і логування деталей помилки перед перекиданням далі. Головні антипатерни — порожній catch і catch який просто логує і йде далі: обидва роблять тест "зеленим" з прихованою проблемою. Правило: якщо ловиш помилку — лови тільки конкретний клас через instanceof, і все що не цей клас — обов'язково перекидай через throw e.
74 Розкажи про складний flaky-баг, який ти діагностувала і виправила
DebugSenior

Реальний кейс з E-commerce PLP: тест підрахунку товарів на PLP нестабільно падав в CI. Проблема виявилась у virtualized list — DOM рендерить тільки видиму частину, тому locator.count() повертав різні числа залежно від стану скролу. Рішення: перейти від DOM-count до GTM dataLayer як source of truth.

Кейс: virtualization ламала DOM-based assertions на PLP
// ❌ Було: рахуємо елементи в DOM напряму
const count = await page.locator('.product-tile').count();
expect(count).toBe(48); // flaky: virtual list рендерить лише видиму частину

// ✅ Стало: GTM dataLayer як source of truth
const events: DataLayerEvent[] = [];
await page.exposeFunction('captureEvent', (e: DataLayerEvent) => events.push(e));
await page.addInitScript(() => {
  window.dataLayer = window.dataLayer || [];
  const originalPush = window.dataLayer.push;
  window.dataLayer.push = function (...args: any[]) {
    (window as any).captureEvent(args[0]);
    return originalPush.apply(window.dataLayer, args);
  };
});

const plpViewEvent = events.find(e => e.event === 'plp_view');
expect(plpViewEvent?.items.length).toBe(48); // стабільно: GTM фіксує повний список незалежно від рендеру
💡 Чому це сильна відповідь: показує не просто фікс, а зміну джерела правди — від крихкого UI-стану до бізнес-події. Це демонструє senior-мислення: симптом (flaky test) → корінна причина (virtualization рендерить тільки viewport) → архітектурне рішення, а не патч (retry/wait).
🔧 TypeScript / JS патерни в цьому прикладі Поведінковий кейс без складного коду. Ключові терміни: flaky — нестабільний тест (то проходить то падає); virtualization — рендеринг тільки видимих елементів; dataLayer — масив подій Google Tag Manager; source of truth — єдине надійне джерело даних.
🗣 Як читати вголос Проблема була в тому, що список товарів на PLP — virtualized: у DOM в кожен момент є лише видимі картки, решта довантажується при скролі. Тест, який рахував .product-tile елементи напряму, був недетермінованим — результат залежав від того, скільки встигло відрендеритись. Рішення: замість перевірки DOM я перехоплюю dataLayer.push через page.addInitScript ще до завантаження сторінки, підміняючи метод push так, щоб кожна подія додатково передавалась у Playwright через exposeFunction. GTM фіксує бізнес-подію plp_view з повним масивом items одразу при завантаженні, незалежно від того, що зараз у viewport. Це дало стабільний source of truth і прибрало клас flaky-тестів повністю, а не одну симптоматичну дефектну точку.
9 10
75 Що таке Page Object Model (POM) в Playwright? Покажи реалізацію.
АрхітектураMiddle

POM — паттерн інкапсуляції UI-логіки в класах. Відокремлює "де знаходяться елементи" від "що тестуємо".

Але "одна Page Object на кожну сторінку" — не догма. Сучасний Playwright частіше використовує гнучкіші структури:

  • Component Objects — клас на переюзабельний UI-компонент (хедер, кошик, картка товару), а не на сторінку. Компонент з'являється на десятках сторінок — Page Object на кожну дублював би його.
  • Fragments — частини складної сторінки як окремі об'єкти. PDP = Gallery + SizeSelector + AddToBag, а не один клас на 400 рядків.
  • Application Actions — методи на рівні бізнес-дії (loginAs(user), addToBag(sku)), які можуть ходити через API замість UI. Швидше і стабільніше для setup-кроків.

Component Object vs Helper — різниця:

  • Component Object — має стан: тримає свій root локатор і всі елементи всередині нього. new CartWidget(page.locator('.cart')) — знає де він на сторінці.
  • Helper — stateless функція: отримує все аргументами і нічого не пам'ятає. formatPrice(1990), expectValidPrice(price). Немає прив'язки до DOM.

Композиція замість успадкування: не роби class ProductPage extends BasePage. Замість цього class ProductPage яка містить this.header = new Header(page) і this.cart = new CartWidget(page). Успадкування створює крихку ієрархію — зміна в BasePage ламає все нижче. Композиція дає гнучкість: сторінка бере тільки ті компоненти які на ній реально є. Це зараз рекомендація більшості Playwright-експертів.

pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitBtn: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitBtn = page.getByRole('button', { name: 'Sign in' });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitBtn.click();
    await this.page.waitForURL('**/dashboard');
  }
}

// Використання в тесті
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'pass123');
});
🔧 TypeScript / JS патерни в цьому прикладі class ProductPage { constructor(page: Page) {} } — TypeScript клас з constructor; private readonly page: Page — модифікатори доступу (private) і незмінності (readonly); this.page — посилання на екземпляр класу; new ProductPage(page) — створення екземпляру через new.
🗣 Як читати вголос Оголошуємо клас LoginPage, він експортується. Три приватні readonly поля типу Locator — це просто посилання на елементи, вони ще нічого не шукають в DOM. У конструкторі отримуємо page через private readonly — це TypeScript shorthand що одночасно оголошує поле і присвоює. Ініціалізуємо локатори через page.getByLabel і page.getByRole. Метод login — асинхронний, приймає рядки, послідовно заповнює поля і клікає кнопку, потім чекає редірект. В тесті просто створюємо екземпляр через new LoginPage(page) і викликаємо методи.
76 Як використовувати test.step() для структурування тестів?
АрхітектураMiddle

test.step() — це в першу чергу про структуру коду всередині тесту. Великий тест з 30 рядками дій перетворюється на читабельні логічні блоки — одразу видно що відбувається і в якому порядку.

Бонус: ця ж структура автоматично відображається у звіті (Allure, HTML report) і в trace. При падінні одразу видно який крок впав — "Логін" чи "Оформлення замовлення" — без читання всього stack trace. Але головне — читабельність коду.

test.step() повертає значення — це часто пропускають. Те що повернув колбек, повертає і сам step:

const orderId = await test.step('Оформити замовлення', async () => {
  await page.getByRole('button', { name: 'Pay' }).click();
  return await page.locator('.order-id').textContent(); // ← повертається назовні
});
expect(orderId).toMatch(/^ORD-/); // використовуємо результат далі

Чого test.step() НЕ робить: не впливає на виконання тесту, не створює свій таймаут, не ізолює стан, не є точкою retry. Це чисто організаційний і звітний механізм. Якщо крок впав — падає весь тест, як і без step. Поширена помилка на інтерв'ю — думати що step дає якусь ізоляцію чи окремий таймаут.

TypeScript — ❌ без test.step: 30 рядків без структури
test('checkout flow', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'test@test.com');
  await page.fill('#password', '123');
  await page.click('.submit');
  await page.goto('/product/123');
  await page.click('.add-to-cart');
  await page.waitForURL('**/cart');
  await page.click('.checkout');
  // ще 20 рядків... важко зрозуміти що відбувається
});
TypeScript — ✅ з test.step: логічні блоки, одразу видно структуру
test('checkout flow', async ({ page }) => {
  await test.step('Логін', async () => {
    await page.goto('/login');
    await page.fill('#email', 'test@test.com');
    await page.fill('#password', '123');
    await page.click('.submit');
  });

  await test.step('Додати товар', async () => {
    await page.goto('/product/123');
    await page.click('.add-to-cart');
    await page.waitForURL('**/cart');
  });

  await test.step('Оформити замовлення', async () => {
    await page.click('.checkout');
    await page.fill('#card', '4111111111111111');
    await page.click('.place-order');
    await expect(page.getByText('Order confirmed')).toBeVisible();
  });
});

// Бонус: та сама структура у звіті при падінні:
// ✅ Логін
// ✅ Додати товар
// ❌ Оформити замовлення  ← одразу видно де впало
🔧 TypeScript / JS патерни в цьому прикладі await test.step('назва', async () => {}) — test.step приймає рядок і async callback; всі дії всередині callback — це логічний крок; вкладені степи — callbacks всередині callbacks.
🗣 Як читати вголос test.step() — це про структуру коду всередині тесту. Великий тест з багатьма рядками розбивається на іменовані логічні блоки — одразу видно що відбувається і в якому порядку: спочатку логін, потім додавання товару, потім оформлення. Код виконується однаково, але читати набагато простіше. Бонус: ця структура автоматично відображається у звіті і trace — при падінні одразу видно який крок впав, без читання всього stack trace.
77 Що таке data-driven тестування в Playwright? Як реалізувати?
АрхітектураMiddle

Data-driven тестування — це коли логіка тесту одна, але він запускається з різними вхідними даними. Замість копіювати один тест п'ять разів змінюючи тільки SKU або email — описуємо масив тестових випадків і генеруємо тести автоматично. Додати новий випадок = додати один рядок в масив.

TypeScript
const browsers = [
  { country: 'GB', currency: '£', locale: 'en-GB' },
  { country: 'DE', currency: '€', locale: 'de-DE' },
  { country: 'US', currency: '$', locale: 'en-US' },
];

for (const { country, currency, locale } of browsers) {
  test(`price displays in ${currency} for ${country}`, async ({ page }) => {
    await page.goto(`/${country.toLowerCase()}/product/123`);
    await expect(page.locator('.price'))
      .toContainText(currency);
  });
}

// Або через test.describe з параметром
const loginScenarios = [
  { desc: 'valid credentials', email: 'user@ok.com', pass: 'valid', expected: 'dashboard' },
  { desc: 'wrong password',   email: 'user@ok.com', pass: 'wrong', expected: 'error'  },
  { desc: 'unknown user',     email: 'nope@no.com', pass: 'pass',  expected: 'error'  },
];

for (const scenario of loginScenarios) {
  test(`login with ${scenario.desc}`, async ({ page }) => { /* ... */ });
}
🔧 TypeScript / JS патерни в цьому прикладі const testData = [{ ... }, { ... }] — масив об'єктів; testData.forEach(({ sku, name }) => { test(...) }) — forEach з деструктуризацією параметра callback; динамічна генерація тестів: test() викликається всередині forEach — тести створюються під час збору (collection phase).
🗣 Як читати вголос Оголошуємо масив об'єктів з тестовими даними. Потім звичайний for...of цикл на TypeScript рівні — не Playwright-специфіка. Всередині циклу викликаємо test() з template literal назвою, куди підставляємо поточне значення з масиву. Playwright зареєструє стільки тестів скільки елементів у масиві — кожен з унікальною назвою. Це простіший підхід ніж параметризовані тести в інших фреймворках.
78 Як спроєктувати стратегію test data governance для e-commerce платформи?
АрхітектураSenior

Test data governance — це система правил яка гарантує що тести використовують тільки безпечні тестові дані і не впливають на production. В e-commerce це критично: тест може випадково списати реальні гроші або зіпсувати реальне замовлення якщо використає неправильний SKU або токен.

Кейс: Multi-region e-commerce — BlockedProductList у зовнішньому PIM (BUG-5521)
// Проблема: тести падали, бо QA/мерчандайзери випадково
// міняли тестові товари в проді (ціна, наявність, статус)

// Рішення: ізольований allowlist-шар поверх зовнішнього PIM
type BlockedProductList = {
  sku: string;
  reservedFor: string; // suite/team, що володіє товаром
  lockedFields: (keyof ProductData)[];
};

async function assertProductIsGovernanceLocked(sku: string) {
  const entry = await getBlockedProductEntry(sku);
  if (!entry) {
    throw new Error(
      `SKU ${sku} не в BlockedProductList — додай товар в BUG-5521 перед використанням у тестах`
    );
  }
}
💡 Додатково: Governance-шар запускається в pre-suite hook, а не покладається на дисципліну людей — некерована тестова data це основна причина "сьогодні працювало, завтра ні" на великих e-commerce платформах з 45 доменами.
🔧 TypeScript / JS патерни в цьому прикладі const BLOCKED_SKUS = new Set(['sku1', 'sku2']) — Set зі списку рядків; BLOCKED_SKUS.has(sku) — O(1) пошук в Set; if (!BLOCKED_SKUS.has(sku)) { ... } — заперечення boolean; allowlist vs blocklist — патерни дозволу/заборони.
🗣 Як читати вголос Корінна проблема test data governance на великих платформах — тестові дані не ізольовані від бойових процесів: мерчандайзер може змінити ціну чи прибрати товар з наявності, і тест, який про це не знає, почне падати без жодної зміни в коді. Рішення — окремий шар BlockedProductList у зовнішньому PIM: список SKU, зарезервованих за конкретними suite, з полем lockedFields, яке каже, які саме атрибути товару не можна чіпати. Функція assertProductIsGovernanceLocked запускається до того, як тест почне використовувати товар, і явно фейлить з людським повідомленням, якщо SKU не зареєстрований — це перетворює мовчазний flaky-фейл на явну й зрозумілу помилку конфігурації, яку легко виправити.
79 Як організувати стратегію тег-маркування та запускати підмножини тестів?
АрхітектураSenior

Не всі тести треба запускати завжди. Smoke тести перевіряють найкритичніші функції за 5 хвилин, regression — повна перевірка за годину. Теги дозволяють розмітити тести за важливістю і запускати тільки потрібну підмножину.

Є два підходи для тегування — і між ними є різниця:

Підхід 1 — тег в назві тесту:

test('@smoke перевірка кошика', async ({ page }) => {})
// Тег — частина рядка назви. Простіше але назва виглядає брудно.

Підхід 2 — тег в options (Playwright 1.42+):

test('перевірка кошика', { tag: ['@smoke', '@critical'] }, async ({ page }) => {})
// Тег зберігається окремо від назви — чистіше і семантично правильніше

Практична різниця:

  • Фільтрація — однакова: --grep @smoke працює для обох підходів
  • Звіт — підхід 2 виглядає чистіше: назва без тегів, теги в окремій колонці в HTML report і Allure
  • Програмний доступ — підхід 2 дозволяє читати testInfo.tags як масив у fixture або reporter
  • Інтеграція з TMS — теги (через testInfo.tags) можна використовувати для зв'язку тестів з Test Case ID в TestRail, Zephyr, Xray та інших TMS через custom reporter
  • Читабельність — підхід 1 засмічує назву, підхід 2 тримає назву чистою

Коли що: підхід 1 — якщо проєкт старий і теги вже скрізь в назвах. Підхід 2 — новий проєкт або Playwright 1.42+. Чистіше і правильніше семантично.

TypeScript
// Теги в назві тесту (офіційний підхід з Playwright 1.42+)
test('homepage loads @smoke @critical', async ({ page }) => {});
test('checkout flow @regression @payment', async () => {});
test('email validation @regression @forms', async () => {});

// Або через test options:
test('login', { tag: ['@smoke', '@auth'] }, async ({ page }) => {});

// Запуск по тегах:
// npx playwright test --grep @smoke           # smoke тести
// npx playwright test --grep "@smoke|@critical" # OR логіка
// npx playwright test --grep "(?=.*@smoke)(?=.*@auth)" # AND
// npx playwright test --grep-invert @slow    # виключити

// Стратегія для великого e-commerce проєкту:
// @smoke     → PR checks (5 хв)
// @critical  → post-deploy (15 хв)
// @regression → нічний запуск (3 год)
🔧 TypeScript / JS патерни в цьому прикладі test('@smoke перевірка', ...) — тег в рядку назви тесту; --grep '@smoke' — регулярний вираз в CLI; test.describe.configure({ tag: '@critical' }) — конфігурація рівня describe; --grep-invert — заперечення grep.
🗣 Як читати вголос Перший підхід: теги прямо в назві тесту через @-префікс — Playwright 1.42+ розпізнає їх автоматично. Другий підхід: третій аргумент test() — об'єкт options з полем tag як масив рядків. --grep приймає регулярний вираз: @smoke знайде всі тести з цим тегом в назві; пайп | — OR логіка; lookahead (?=.*@smoke)(?=.*@auth) — AND логіка, тест повинен мати обидва теги. --grep-invert — інвертує: запускаємо все крім позначеного.
80 Що таке test.skip(), test.fixme(), test.fail() і test.slow()? Чим вони відрізняються між собою?
АрхітектураSenior

Коли є велика кодова база тестів, виникають ситуації які не вкладаються в бінарну логіку "тест проходить / тест падає": є відомий баг і тест правильно його описує але ми не хочемо його видаляти; тест нестабільний тільки на певній ОС; тест займає 3 хвилини і Playwright вбиває його раніше часу; тест описує поведінку яка ще не реалізована. Для всіх цих випадків є анотації — це не просто "вимкнути тест", кожна несе конкретну семантику і по-різному відображається у звіті Allure/HTML.

  • test.skip() — тест пропускається, у звіті позначається як "skipped". Використовується коли тест тимчасово нерелевантний: фіча вимкнена для певного середовища, або тест залежить від чогось що ще не готове. Можна передати умову і причину.
  • test.fixme() — схожий на skip, але семантика інша: "цей тест зламаний і потребує виправлення". У звіті позначається окремо. Це сигнал команді що тут є технічний борг який треба вирішити.
  • test.fail() — найцікавіший і найменш очевидний. Каже Playwright: "я знаю що цей тест зараз падає, і це очікувано". Якщо тест впав — все ок, "expected failure". Але якщо тест раптом пройшов — це теж фейл, бо означає щось змінилось і анотацію треба прибрати.
  • test.slow() — не пропускає і не фейлить тест. Просто каже Playwright що цей тест повільний — і Playwright автоматично множить test timeout на 3. Не треба вручну рахувати скільки часу потрібно.
TypeScript — test.skip(): умовне пропускання з поясненням
// Простий skip — завжди пропускається
test('платіж через Apple Pay', async ({ page }) => {
  test.skip(); // пропустити — Apple Pay ще не реалізований
  // ... код тесту ...
});

// Умовний skip — пропускається тільки якщо умова true
test('drag-and-drop в кошику', async ({ page, isMobile }) => {
  test.skip(isMobile, 'drag-and-drop не підтримується на мобільному');
  // На desktop виконається, на мобільному — пропуститься зі зрозумілою причиною
  await page.goto('/cart');
  // ...
});

// Skip через browserName — лише для певного браузера
test('WebGL рендеринг', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'WebGL поводиться інакше у Firefox — окремий тікет');
  // ...
});
TypeScript — test.fixme(): маркуємо технічний борг
// fixme — тест зламаний, потребує виправлення
// Семантично відрізняється від skip: це не "нерелевантно", а "треба полагодити"
test('фільтр за розміром на PLP', async ({ page }) => {
  test.fixme(); // зламано після рефакторингу фільтрів — BUG-7742
  await page.goto('/women/dresses');
  // ...
});

// fixme теж підтримує умову
test('анімація переходу між сторінками', async ({ page, browserName }) => {
  test.fixme(browserName === 'webkit', 'анімація некоректна в Safari — BUG-4408');
  // ...
});
TypeScript — test.fail(): документуємо відомий баг
// test.fail() — найнетиповіша анотація
// "Я знаю що цей тест падає. Якщо він раптом пройде — скажи мені."
test('промокод застосовується до sale-товарів', async ({ page }) => {
  test.fail(); // відомий баг BUG-3310, промокод ігнорується для sale

  await page.goto('/cart');
  await page.locator('#promo-input').fill('SAVE10');
  await page.locator('#promo-apply').click();

  // Цей assertion ВПАДЕ (знижка не застосується) — і це очікувано
  // Якщо раптом пройде — Playwright скаже "unexpected pass" і зафейлить тест
  await expect(page.locator('.discount-line')).toBeVisible();
});

// ⚠️ Що відбувається в різних сценаріях з test.fail():
// тест впав  → ✅ "expected failure" — все як очікувалось
// тест пройшов → ❌ "unexpected pass" — баг пофіксили, прибери анотацію!
TypeScript — test.slow(): множимо timeout без ручних розрахунків
// test.slow() — автоматично множить test timeout на 3
// Якщо config timeout = 30с, тут тест матиме 90с
test('повний checkout flow з 3D Secure', async ({ page }) => {
  test.slow(); // 3D Secure redirect займає до 40 секунд

  await page.goto('/checkout');
  // ... довгий сценарій з редиректом на банківську сторінку ...
});

// slow теж підтримує умову
test('завантаження каталогу', async ({ page, browserName }) => {
  test.slow(browserName === 'webkit', 'Safari рендерить каталог вдвічі довше');
  // ...
});

// Можна використовувати на рівні describe — всі тести в блоці отримають x3 timeout
test.describe('важкі e2e сценарії', () => {
  test.slow();

  test('тест 1', async ({ page }) => { /* x3 timeout */ });
  test('тест 2', async ({ page }) => { /* x3 timeout */ });
});
💡 Як вибрати правильну анотацію:
Тест тимчасово нерелевантний (фіча вимкнена, середовище не підтримує) → test.skip()
Тест правильний але зламаний, є тікет на виправлення → test.fixme()
Тест описує відомий баг, хочемо отримати сигнал коли його пофіксять → test.fail()
Тест правильно працює але займає багато часу → test.slow()
🔧 TypeScript / JS патерни в цьому прикладі test.skip(condition, 'reason') — виклик з булевою умовою і рядком; test.fail() — виклик без аргументів; process.env.CI — environment variable; ({ browserName }) => browserName === 'firefox' — стрілочна функція з деструктуризацією що повертає boolean.
🗣 Як читати вголос Анотації в Playwright — це спосіб передати команді контекст про стан тесту, а не просто вмикати або вимикати його. test.skip() пропускає тест і позначає його як "skipped" у звіті — він може приймати умову, наприклад isMobile або browserName === 'firefox', що робить його ситуативним а не глобальним. test.fixme() семантично відрізняється: це не "нерелевантно", а "зламано, потребує уваги" — окреме позначення у звіті нагадує команді що є технічний борг. test.fail() — найнетиповіший: він говорить "цей тест очікувано падає", і Playwright навпаки зафейлить його якщо він раптом пройде — це механізм автоматичного сповіщення коли баг пофіксили але анотацію забули прибрати, дуже корисно для документування відомих проблем. test.slow() не змінює результат тесту взагалі — просто множить test timeout на три, щоб Playwright не вбивав повільний але правильний тест раніше часу, без необхідності вручну рахувати скільки саме секунд потрібно.
81 Як запустити тести в конкретному браузері? Як налаштувати playwright.config.ts?
АрхітектураJunior

Ми розібрали основні концепти Playwright — локатори, auto-waiting, fixtures, і чому ElementHandle застарів. Тепер подивимось на конфігурацію — де всі ці налаштування збираються разом.

playwright.config.ts — це головний файл конфігурації де задаються налаштування для всього проєкту: де шукати тести, скільки workers запускати паралельно, які браузери використовувати, що робити при фейлі.

Найважливіші поля конфігу які питають на інтерв'ю: testDir — де шукати тести, timeout — максимальний час на один тест, expect — налаштування assertions (timeout, toMatchSnapshot), reporter — формат звіту, use — глобальні налаштування для всіх тестів (baseURL, headless, trace, screenshot).

browserName vs channel — важлива різниця:

  • browserName: 'chromium' — використовує Playwright-специфічну збірку Chromium яка постачається разом з Playwright
  • channel: 'chrome' — використовує встановлений на машині Google Chrome (або 'msedge' для Edge). Відрізняються версією і поведінкою

Ключова частина — projects. Це масив де кожен елемент — окремий браузер або девайс. Для кожного проєкту пишемо use: { ...devices['Desktop Chrome'] } — спред-оператор "розгортає" готовий профіль з viewport, userAgent, hasTouch тощо.

⚠️ Що насправді емулює device descriptor: viewport, userAgent, hasTouch, deviceScaleFactor, isMobile — і все. Це не справжній телефон. Не емулюються: продуктивність реального заліза, жести операційної системи, поведінка мобільного Safari чи WebView, нативна клавіатура, апаратні особливості. devices['iPhone 14'] у WebKit наближає рендеринг до Safari, але це все ще десктопний рушій з мобільним viewport. Емуляція ловить проблеми верстки і responsive — реальні девайси чи BrowserStack потрібні для решти.

viewport: null — вимикає фіксований viewport, вікно браузера використовує свій реальний розмір. Зручно при локальному дебагу в headed режимі, коли хочеш розтягнути вікно вручну. У CI не використовується — там потрібен детермінований розмір.

CLI команди: --grep фільтрує тести за назвою або тегом (@smoke, @critical), --ui відкриває інтерактивний режим де можна бачити тести і запускати їх вручну.

playwright.config.ts — з поясненням кожного поля
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',         // де шукати тести
  timeout: 30_000,             // максимальний час на один тест (мс)
  retries: 2,                  // скільки разів перезапустити при падінні
  workers: 4,                  // скільки тестів паралельно
  reporter: [['html'], ['allure-playwright']],

  expect: {                     // налаштування assertions
    timeout: 5_000,            // таймаут для web-first assertions
    toMatchSnapshot: { maxDiffPixelRatio: 0.01 },
  },

  use: {                        // глобальні налаштування для всіх тестів
    baseURL: 'https://staging.myapp.com',
    headless: true,
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },

  projects: [
    // browserName: 'chromium' — Playwright-специфічна збірка Chromium
    // channel: 'chrome' — системний Google Chrome (різна версія і поведінка!)
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['iPhone 14'] } },
    // metadata — довільні дані про проєкт для звітів:
    { name: 'chrome', use: { channel: 'chrome' }, metadata: { team: 'checkout' } },
    // dependencies — гарантує порядок: setup виконається перший
    { name: 'e2e', dependencies: ['setup'], use: { storageState: '.auth/user.json' } },
  ],
});
CLI команди
npx playwright test                          # всі тести
npx playwright test --project=chromium       # один проєкт
npx playwright test tests/login.spec.ts      # один файл
npx playwright test --grep @smoke            # за тегом
npx playwright test --grep-invert @slow      # всі крім @slow
npx playwright test --ui                     # UI mode — інтерактивний режим
npx playwright test --trace on               # записати trace для всіх тестів
npx playwright test --last-failed            # тільки тести що впали в останньому запуску
npx playwright test --list                   # показати список тестів без запуску
npx playwright show-report                   # відкрити HTML звіт
🔧 TypeScript / JS патерни в цьому прикладі defineConfig({ ... }) — функція яка приймає конфігураційний об'єкт (configuration object pattern); ...devices['Desktop Chrome'] — spread operator: "розгортає" об'єкт і вставляє всі його поля; devices['Desktop Chrome'] — доступ до властивості об'єкта через квадратні дужки (bracket notation), еквівалент devices.'Desktop Chrome' але для ключів з пробілами.
🗣 Як читати вголос У конфігу: defineConfig — функція що приймає об'єкт налаштувань. testDir — де шукати тести. workers: 4 — скільки тестів паралельно. use — глобальні налаштування для всіх тестів: baseURL дозволяє писати goto('/') замість повного URL, headless: true — без вікна браузера. projects — масив браузерів, кожен спред-оператором бере девайс-профіль з бібліотеки Playwright. У CLI --grep фільтрує за регуляркою або тегом, --ui відкриває інтерактивний режим.
82 Навіщо в конфізі іноді бачимо channel: 'chrome'? Чим це відрізняється від звичайного Chromium?
АрхітектураSenior

Playwright за замовчуванням завантажує власну збірку Chromium — браузерний рушій без Google-специфічних доповнень. Іноді тест потребує саме реального Google Chrome: якщо функціональність залежить від Google-акаунту, DRM або Chrome розширень.

channel: 'chrome' каже Playwright використати Chrome який вже встановлений на машині замість власного Chromium. Різниця: версія, поведінка з деякими API і доступ до Google-сервісів.

playwright.config.ts — Chromium bundle vs реальний Chrome
// За замовчуванням: Playwright використовує власний бандл Chromium —
// окрема збірка, не той самий бінарник, що встановлений у користувача
projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },

  // ✅ Явно тестуємо РЕАЛЬНИЙ Google Chrome, не Playwright-бандл
  { name: 'chrome-stable', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },

  // Або Edge, теж через channel
  { name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
]
💡 Коли це важливо: корпоративні білди Chrome з власними policy-розширеннями, DRM-залежний контент (Widevine), або коли треба гарантовано відтворити баг, який репортить користувач саме в "звичайному" Chrome, а не в Playwright-специфічному бандлі.
🗣 Як читати вголос За замовчуванням, коли ми пишемо просто chromium як browser engine, Playwright використовує власну збірку Chromium, яку команда Playwright тестує і патчить під свої потреби — це не той самий бінарник, що користувач завантажив би з сайту Google Chrome. У більшості випадків різниця непомітна, бо обидва базуються на тому самому рушії, але іноді треба тестувати саме реальний продакшн-браузер: наприклад, якщо є DRM-залежний контент через Widevine, якого немає в чистому Chromium-бандлі, або якщо компанія використовує корпоративну збірку Chrome з власними policy-розширеннями й треба перевірити сумісність саме з нею. Для цього в конфігурації проєкту додається поле channel: 'chrome' — це каже Playwright використати реально встановлений на машині стабільний Google Chrome замість вбудованого бандла, аналогічно є channel: 'msedge' для Microsoft Edge. Головне обмеження — такий браузер має бути фізично встановлений на машині, де запускаються тести, на відміну від Playwright-бандла, який встановлюється автоматично через npx playwright install.
83 Що таке project dependencies в playwright.config.ts і як гарантувати порядок виконання?
АрхітектураSenior

Playwright запускає тести паралельно. Якщо auth.setup.ts і product.spec.ts запустяться одночасно — продуктовий тест почне виконуватись до того як логін завершився і файл збережений. Тест впаде бо user.json ще не існує.

Рішення — два окремих проєкти в конфізі з явним порядком через dependencies.

Що таке проєкт в конфізі: це група тестів з певними налаштуваннями. Зазвичай це браузер — chromium, firefox, webkit. Але проєкт може бути і для інших цілей — наприклад тільки для запуску setup файлу.

testMatch — говорить "цей проєкт запускає тільки файли що відповідають цьому паттерну".

dependencies — говорить "не починай поки не виконається цей проєкт". Це гарантія порядку.

playwright.config.ts — два проєкти з залежністю
projects: [
  // Проєкт 1: тільки логін
  {
    name: 'setup',
    testMatch: '**/auth.setup.ts', // запускати тільки цей файл
  },

  // Проєкт 2: всі основні тести
  {
    name: 'chromium',
    dependencies: ['setup'],       // спочатку виконай 'setup', потім мене
    use: {
      storageState: '.auth/user.json' // використовуй збережену сесію
    },
  },
],

// Порядок виконання:
// 1. Playwright бачить dependencies: ['setup']
// 2. Спочатку запускає проєкт 'setup' → auth.setup.ts → логін → зберігає user.json
// 3. Тільки після цього запускає 'chromium' → кожен тест починає вже залогіненим
Схема — як це виглядає в часі
// БЕЗ dependencies — паралельно, product.spec.ts впаде:
// auth.setup.ts   ──────────────► зберіг user.json
// product.spec.ts ──► впав (user.json ще не існує!)

// З dependencies — гарантований порядок:
// auth.setup.ts   ──────────────► зберіг user.json
//                                              ↓
// product.spec.ts                 ────────────► ок (user.json вже є)
💡 Аналогія: setup це як підготовча зміна на кухні — спочатку вони готують, потім відкривається ресторан (основні тести). dependencies — це замок на дверях ресторану який не відчиняється поки кухня не готова.
🔧 TypeScript / JS патерни в цьому прикладі dependencies: ['setup'] — масив рядків як список залежностей; testMatch: '**/auth.setup.ts' — glob паттерн: ** означає "в будь-якій папці"; конфігурація через масив об'єктів де кожен об'єкт — окремий проєкт з власними налаштуваннями.
🗣 Як читати вголос Playwright запускає тести паралельно — тому якщо просто покласти auth setup і звичайні тести в один проєкт, вони можуть стартувати одночасно і тест впаде бо файл з сесією ще не існує. Рішення: два окремих проєкти в конфізі. Перший — setup — має testMatch що вказує тільки на auth файл, тобто запускає виключно логін. Другий — chromium — має dependencies: ['setup'], що означає "не починай поки setup не завершився", і storageState що вказує на збережений JSON файл. Playwright сам дотримується цього порядку: спочатку повністю виконує setup проєкт, зберігає сесію, і тільки потім запускає основні тести — кожен з яких вже починає залогіненим.
84 globalSetup vs project dependencies для авторизації — який підхід зараз вважається best practice?
АрхітектураSenior

Є два способи виконати авторизацію один раз перед всіма тестами: історично перший — через globalSetup файл, сучасний — через dependencies між проєктами в конфізі. Важливо: globalSetup не deprecated — він досі повністю підтримується і має свої сценарії (наприклад підняти БД чи зовнішній сервіс до старту раннера). Але для авторизації Playwright Team зараз рекомендує project dependencies — вони гнучкіші і мають доступ до всіх можливостей Playwright включно з fixtures, trace і звітами.

playwright.config.ts — сучасний підхід через projects + dependencies
// Історичний підхід: globalSetup — окремий файл, логін один раз перед усіма тестами
// playwright.config.ts: globalSetup: './global-setup.ts'
// Проблема: важко дебажити, нема трейсів/звітів для самого логіну,
// не масштабується на кілька ролей (admin/guest/premium)

// ✅ Сучасний підхід: окремий "setup" проєкт + dependencies
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
      dependencies: ['setup'], // чекає завершення setup-проєкту перед запуском
    },
  ],
});

// auth.setup.ts — звичайний тест, що зберігає сесію
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'test@test.com');
  await page.click('#submit');
  await page.context().storageState({ path: '.auth/user.json' });
});
💡 Чому новий підхід кращий: setup-логін тепер звичайний тест — з власним трейсом, скріншотом при фейлі, retry. Легко масштабується на кілька ролей через кілька setup-проєктів і dependencies.
🔧 TypeScript / JS патерни в цьому прикладі dependencies: ['setup'] — масив рядків як залежності між проєктами; { name: 'setup', testMatch: '**/auth.setup.ts' } — project конфігурація з glob pattern; testMatch — glob для вибору файлів; відмінність від globalSetup: setup-проєкт це звичайний тест з fixtures.
🗣 Як читати вголос Історично перший підхід — globalSetup у конфізі. Це окрема функція, яка виконується один раз перед усім тестовим прогоном, поза звичайним test-раннером. Він не deprecated і досі корисний для інфраструктурних задач типу підняття бази. Але для авторизації є проблема: якщо логін там впаде — це не дає трейсу, скріншоту чи нормального звіту, як впав би звичайний тест, дебажити складно. Сучасний офіційний підхід — оголосити окремий setup проєкт у масиві projects, який матчиться за патерном файлу типу *.setup.ts, і всередині нього логін написаний як звичайний Playwright-тест: він заходить на сторінку логіну, заповнює форму, і в кінці зберігає сесію через context().storageState() у файл. Інші проєкти, наприклад chromium, вказують dependencies: ['setup'] — це означає, що Playwright гарантовано виконає setup-проєкт першим і тільки після його успішного завершення запустить основні тести, які вже підхоплюють збережений storageState через use. Перевага — це звичайний тест зі всіма інструментами дебагу, і легко масштабується: якщо потрібні кілька ролей, просто додаємо кілька setup-проєктів, наприклад admin-setup і guest-setup, кожен зі своїм storageState-файлом.
10 14
85 Що таке CI runner і як GitLab запускає тести паралельно на різних машинах?
ProductionSenior

Коли говоримо "окрема машина" в контексті CI — маємо на увазі runner. Runner — це процес (або контейнер, або віртуальна машина) який GitLab виділяє для виконання одного job.

Коли GitLab бачить parallel: 4 в конфігурації — він запускає 4 незалежних job. Кожен job виконується на своєму runner. Вони працюють паралельно і не знають один про одного.

GitLab CI → parallel: 4
├── Job 1 (Runner A) → --shard=1/4  → тести 1-750
├── Job 2 (Runner B) → --shard=2/4  → тести 751-1500
├── Job 3 (Runner C) → --shard=3/4  → тести 1501-2250
└── Job 4 (Runner D) → --shard=4/4  → тести 2251-3000
     ↑
  Кожен runner — ізольований процес з власною пам'яттю і CPU

Що фізично є runner:

  • Docker контейнер — найчастіше. Окремий ізольований контейнер на тому самому сервері
  • Віртуальна машина — в хмарі (AWS EC2, Google Cloud). GitLab автоматично запускає і зупиняє
  • Фізичний сервер — рідше, для специфічних вимог

Для тебе як QA — без різниці що фізично є runner. Головне що кожен job отримує ізольоване середовище з власною пам'яттю і CPU. Якщо runners Docker контейнери на одному сервері — вони все одно ізольовані і не впливають один на одного.

.gitlab-ci.yml — як GitLab розподіляє jobs по runners
test:
  parallel: 4   # GitLab запустить 4 копії цього job одночасно
  script:
    # GitLab автоматично встановлює змінні для кожного job:
    # Job 1: CI_NODE_INDEX=1, CI_NODE_TOTAL=4
    # Job 2: CI_NODE_INDEX=2, CI_NODE_TOTAL=4
    # Job 3: CI_NODE_INDEX=3, CI_NODE_TOTAL=4
    # Job 4: CI_NODE_INDEX=4, CI_NODE_TOTAL=4
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

# Playwright сам визначає яку частину тестів запускати
# на основі номера shard і загальної кількості
Схема: як виглядає pipeline в GitLab UI
Pipeline #1234
├── install    ✅ 45s   (один job — встановлюємо залежності)
├── test                (4 паралельних job на різних runners)
│   ├── test 1/4  ✅ 4m  Runner A
│   ├── test 2/4  ✅ 4m  Runner B
│   ├── test 3/4  ✅ 4m  Runner C
│   └── test 4/4  ❌ 4m  Runner D  ← впав, артефакти збережені
└── merge-reports  (один job — збираємо звіти)

# Загальний час: 45s + 4m + merge = ~5 хвилин
# Замість 60 хвилин на одному runner
🔧 TypeScript / JS патерни в цьому прикладі $CI_NODE_INDEX і $CI_NODE_TOTAL — bash змінні оточення які GitLab встановлює автоматично; parallel: 4 — YAML конфігурація GitLab CI; кожен runner отримує окремий $CI_NODE_INDEX від 1 до N.
🗣 Як читати вголос Runner в GitLab CI — це ізольоване середовище де виконується один job. Зазвичай це Docker контейнер або віртуальна машина. Коли в конфігурації GitLab пишемо parallel: 4 — GitLab автоматично запускає 4 копії цього job на 4 окремих runners паралельно і встановлює для кожного змінні CI_NODE_INDEX від 1 до 4 і CI_NODE_TOTAL рівне 4. Playwright читає ці змінні через --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL і визначає яку частину тестів запускати. Для QA інженера неважливо що фізично є runner — важливо що кожен job ізольований, виконується паралельно з іншими, і якщо один впав — артефакти зберігаються і можна подивитись trace.
86 Як організувати CI/CD pipeline для Playwright тестів на GitLab CI?
ProductionSenior

CI/CD pipeline автоматично запускає тести при кожному push або merge request. Для Playwright в GitLab CI треба вирішити кілька питань: де зберігати браузери між запусками (кешування), як запускати тести паралельно (sharding), і як зберігати артефакти при падінні (trace, screenshots).

.gitlab-ci.yml
stages: [install, test, report]

# Playwright Docker image вже містить Node.js v22 LTS і всі браузери
image: mcr.microsoft.com/playwright:v1.61.0-noble

variables:
  PLAYWRIGHT_BROWSERS_PATH: "/cache/playwright"

install:
  stage: install
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths: [node_modules/, /cache/playwright]
  script:
    - npm ci
    - npx playwright install --with-deps chromium

e2e:smoke:
  stage: test
  parallel: 4                   # 4 паралельні джоби
  script:
    - npx playwright test
        --grep @smoke
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [playwright-report/, test-results/]
    expire_in: 7 days

e2e:regression:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  parallel: 6
  script:
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [allure-results/]
💡 Sharding: --shard=1/4 запускає першу чверть тестів. Комбінація з parallel: 4 дає лінійне масштабування.
🔧 TypeScript / JS патерни в цьому прикладі YAML синтаксис для GitLab CI. extends: .playwright-base — наслідування конфігурації; --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL — template string у bash з env variables; паралелізм через matrix strategy.
🗣 Як читати вголос YAML-файл конфігурації GitLab. stages — масив етапів що виконуються послідовно. variables — глобальні змінні середовища. Джоб install: cache зберігає node_modules і браузери між запусками по ключу гілки. script — список команд. Джоб e2e:smoke: parallel: 4 — GitLab запустить 4 копії цього джобу, кожна отримає змінні CI_NODE_INDEX і CI_NODE_TOTAL. Ці змінні підставляємо в --shard, тоді кожен джоб запускає свою чверть тестів. artifacts.when: always — зберігаємо звіти навіть якщо тести впали.
87 Як масштабувати тести за допомогою sharding та повного паралелізму?
ProductionSenior

Workers — це паралельність всередині однієї машини. Одна машина запускає N процесів і кожен виконує свою частину тестів.

Sharding — це паралельність між машинами. Ти розбиваєш всі тести на N рівних частин і запускаєш кожну частину на окремій машині в CI. Машини працюють незалежно і паралельно.

Workers — паралельність ВСЕРЕДИНІ однієї машини:
──────────────────────────────────────────────
Одна машина, workers: 4
  Worker 1 → тест 1, тест 5, тест 9...
  Worker 2 → тест 2, тест 6, тест 10...
  Worker 3 → тест 3, тест 7, тест 11...
  Worker 4 → тест 4, тест 8, тест 12...

Sharding — паралельність МІЖ машинами в CI:
──────────────────────────────────────────────
Машина 1: --shard=1/4  → тести 1-750
Машина 2: --shard=2/4  → тести 751-1500
Машина 3: --shard=3/4  → тести 1501-2250
Машина 4: --shard=4/4  → тести 2251-3000

Комбінація: 4 машини × 4 workers = 16 паралельних тестів

Результат: 3000 тестів замість 60 хвилин на одній машині — 4 хвилини на 4 машинах.

Як це працює в GitLab CI: кожен shard — це окремий CI job. Вони запускаються паралельно. Після завершення всіх jobs — збираємо звіти разом.

Важливо: sharding і workers — незалежні рівні паралелізму. Sharding ділить тести між машинами. Workers ділять тести між процесами на одній машині. Можна використовувати разом або окремо.

Термінал — запуск shard вручну
# Запускаємо першу чверть всіх тестів
npx playwright test --shard=1/4

# Запускаємо другу чверть
npx playwright test --shard=2/4

# Кожна команда виконується на своїй машині паралельно
# Playwright сам рівномірно розподіляє тести між shards
playwright.config.ts — конфігурація для великого сьюту
export default defineConfig({
  workers: process.env.CI ? 6 : 2,     // 6 workers на CI машині, 2 локально
  fullyParallel: true,                  // кожен тест в своєму worker
  retries: process.env.CI ? 2 : 0,      // retry тільки в CI
});

// В CI запускаємо 4 паралельних job:
// Job 1: npx playwright test --shard=1/4 --workers=6
// Job 2: npx playwright test --shard=2/4 --workers=6
// Job 3: npx playwright test --shard=3/4 --workers=6
// Job 4: npx playwright test --shard=4/4 --workers=6
// 4 машини × 6 workers = 24 паралельних тести одночасно
.gitlab-ci.yml — паралельні shard jobs
test:
  parallel: 4              # GitLab запустить 4 копії цього job паралельно
  script:
    - npx playwright test
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
        --workers=6
  artifacts:
    when: always
    paths:
      - playwright-report/  # кожен shard зберігає свій звіт

# Після всіх 4 jobs — збираємо звіти разом:
merge-reports:
  needs: [test]
  script:
    - npx playwright merge-reports ./playwright-report
🔧 TypeScript / JS патерни в цьому прикладі process.env.CI ? 6 : 2 — тернарний оператор для різних значень в CI vs локально; $CI_NODE_INDEX/$CI_NODE_TOTAL — змінні оточення GitLab CI для визначення номера поточного shard; parallel: 4 — YAML конфігурація GitLab яка автоматично встановлює CI_NODE_INDEX від 1 до 4.
🗣 Як читати вголос Sharding і workers — це два незалежних рівні паралелізму. Workers — це паралельність всередині однієї машини: одна машина запускає кілька процесів одночасно. Sharding — це паралельність між машинами: ти розбиваєш всі тести на N рівних частин через прапор --shard=1/4, --shard=2/4 і так далі, і кожна частина виконується на окремій машині в CI. Playwright сам рівномірно розподіляє тести між shards. На практиці комбінуємо обидва рівні: наприклад 4 CI machines кожна з 6 workers дає 24 паралельних тести одночасно. 3000 тестів що займали 60 хвилин на одній машині виконуються за 4 хвилини. В GitLab CI це конфігурується через parallel: 4 в yaml — GitLab автоматично встановлює змінні CI_NODE_INDEX і CI_NODE_TOTAL для кожного job.
TypeScript
// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 6 : 2,     // паралельних воркерів
  fullyParallel: true,                  // кожен тест в своєму воркері
});

// Для тестів що мають бути sequential:
test.describe.configure({ mode: 'serial' }); // в describe-блоці

// CLI — розбиваємо на 4 шарди між машинами/джобами:
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
// npx playwright test --shard=3/4
// npx playwright test --shard=4/4

// Об'єднати HTML звіти після sharding:
// npx playwright merge-reports --reporter html ./blob-reports

// Приклад для великого проєкту:
// 4000 тестів / 6 workers = ~15 хвилин → full regression
💡 Приклад: ~4000 тестів на 6 воркерах = ~3 години без sharding. Sharding на 3 CI jobs × 6 workers = ~1 година.
🔧 TypeScript / JS патерни в цьому прикладі --shard=1/4 — CLI аргумент у форматі дробу; fullyParallel: true — булева конфігурація; test.describe.configure({ mode: 'serial' }) — виклик методу на вкладеному об'єкті.
🗣 Як читати вголос workers — кількість паралельних процесів в межах однієї машини. fullyParallel: true — кожен окремий тест запускається в своєму воркері, без цього тести в одному файлі йдуть послідовно. test.describe.configure({ mode: 'serial' }) — переводить конкретний describe-блок назад в послідовний режим. Sharding — це розподіл між різними машинами або CI-джобами: --shard=2/4 означає "запусти другу чверть з чотирьох". merge-reports — CLI команда що об'єднує blob-звіти від усіх шардів в один HTML.
88 Які репортери є в Playwright? Як налаштувати Allure і кілька репортерів одночасно?
ProductionSenior

Після того як тести виконались — потрібно щось читати. Не лог в терміналі де все змішалось, а структурований звіт де можна побачити що впало, чому, з яким скріншотом і на якому кроці. Playwright має кілька рівнів репортінгу.

Вбудовані репортери з коробки:

  • list — найпростіший, виводить кожен тест рядком в термінал. Добре для локальної розробки де треба бачити прогрес.
  • html — генерує повноцінний HTML-звіт з фільтрами, скріншотами, відео, трейсами. Відкривається в браузері через npx playwright show-report. Playwright генерує його автоматично після прогону.
  • json — виводить результати як JSON файл. Корисно для інтеграції з іншими інструментами або парсингу результатів в CI-скриптах.
  • junit — XML формат який розуміє більшість CI систем (Jenkins, GitLab, GitHub Actions). Вони автоматично парсять його і показують test summary прямо в інтерфейсі пайплайну.
  • dot — мінімалістичний: крапка для passed, "F" для failed. Для тихого CI де не потрібен детальний вивід в логах.
  • blobключовий для sharding. Кожен shard пише свій .zip замість готового звіту. Потім npx playwright merge-reports --reporter html ./blob-reports зшиває їх в один HTML. Без blob кожна з 4 машин згенерувала б окремий звіт на чверть тестів — і побачити повну картину прогону було б неможливо.

Allure — зовнішній репортер з historical trends:

Allure не вбудований — підключається через пакет allure-playwright. Дає набагато більше ніж вбудований html: historical trends (як змінювався pass rate з часу в часі), розбивка по suite/feature/story, красиві графіки для стейкхолдерів. Для персистентності між прогонами (щоб history не губилась) — зберігаємо allure-results в S3 і підтягуємо перед кожним новим прогоном.

Кілька репортерів одночасно — важливий нюанс:

Можна запускати кілька репортерів паралельно в одному прогоні. Наприклад: html для розробника, junit для CI/CD pipeline, allure для стейкхолдерів. Кожен отримує свій формат без додаткових запусків.

playwright.config.ts — вбудовані репортери і їх налаштування
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Один репортер:
  reporter: 'html',

  // Або кілька одночасно — масив пар [назва, опції]
  reporter: [
    ['list'],                                        // в термінал під час прогону
    ['html', { outputFolder: 'playwright-report' }], // HTML звіт для розробника
    ['junit', { outputFile: 'results/junit.xml' }],  // для GitLab/GitHub CI
    ['json', { outputFile: 'results/results.json' }], // для кастомних скриптів
  ],

  use: {
    // Що зберігати автоматично при фейлі
    screenshot: 'only-on-failure', // або 'on' (завжди) або 'off'
    video: 'retain-on-failure',     // або 'on' або 'off'
    trace: 'retain-on-failure',     // або 'on' або 'off'
  },
});
terminal — Allure: встановлення і запуск
# Встановлення
npm install --save-dev allure-playwright

# playwright.config.ts — додаємо до масиву reporter:
# ['allure-playwright', { outputFolder: 'allure-results' }]

# Після прогону тестів — генеруємо і відкриваємо звіт
npx allure generate allure-results --clean -o allure-report
npx allure open allure-report

# Або одразу serve (генерує + відкриває в браузері)
npx allure serve allure-results
playwright.config.ts — Allure з historical trends через S3
// Щоб history не губилась між прогонами — зберігаємо в S3
// В GitLab CI pipeline (спрощено):

// 1. Перед прогоном — підтягуємо попередню history з S3
// aws s3 sync s3://bucket/allure-history ./allure-results/history

// 2. Запускаємо тести — allure-playwright пише в allure-results/
// npx playwright test

// 3. Генеруємо звіт (вже з history всередині)
// npx allure generate allure-results -o allure-report

// 4. Зберігаємо нову history назад в S3
// aws s3 sync ./allure-report/history s3://bucket/allure-history

// Результат: кожен новий прогін бачить всі попередні → trend графіки

// playwright.config.ts:
reporter: [
  ['list'],
  ['allure-playwright', {
    outputFolder: 'allure-results',
    suiteTitle: false,
    environmentInfo: {
      Project: 'E-commerce PLP',
      Environment: process.env.ENV || 'staging',
    },
  }],
  ['junit', { outputFile: 'allure-results/junit.xml' }],
],
TypeScript — testInfo.attach(): прикріплюємо артефакти вручну до звіту
import { test, expect } from '@playwright/test';

test('перевірка API відповіді і скріншот на кроці', async ({ page, request }, testInfo) => {
  // Прикріплюємо JSON відповідь API — буде видно прямо в HTML/Allure звіті
  const response = await request.get('/api/products');
  const body = await response.json();
  await testInfo.attach('API response', {
    body: JSON.stringify(body, null, 2),
    contentType: 'application/json',
  });

  // Прикріплюємо скріншот на конкретному кроці (не тільки при фейлі)
  await page.goto('/products');
  await testInfo.attach('PLP after load', {
    body: await page.screenshot(),
    contentType: 'image/png',
  });

  // Прикріплюємо текстовий лог
  await testInfo.attach('test context', {
    body: `URL: ${page.url()}\nTimestamp: ${new Date().toISOString()}`,
    contentType: 'text/plain',
  });

  await expect(page.locator('.product-card')).toHaveCount(48);
});
💡 Яку комбінацію використовувати на практиці:
Локальна розробка → list + html
CI pipeline → dot + junit (тихо в логах, результати в GitLab test summary)
Стейкхолдери → allure-playwright з S3 history (тренди, графіки, розбивка по suite)
Дебаг конкретного падіння → trace: 'retain-on-failure' + npx playwright show-trace
🔧 TypeScript / JS патерни в цьому прикладі reporter: [['list'], ['html', { outputFolder }], ['junit', { outputFile }]] — масив масивів: кожен елемент це [назва, опції]; process.env.ENV || 'staging' — logical OR як default value; testInfo.attach('name', { body, contentType }) — options object.
🗣 Як читати вголос Playwright має кілька вбудованих репортерів для різних потреб: list виводить кожен тест рядком в термінал — зручно локально бачити прогрес. html генерує повноцінний статичний звіт з фільтрами, скріншотами і трейсами який відкривається через show-report. junit — XML формат який GitLab, GitHub Actions і Jenkins розуміють нативно і показують test summary прямо в інтерфейсі пайплайну без додаткових налаштувань. Важливий нюанс: репортери запускаються паралельно — можна одночасно мати list для розробника, junit для CI і allure-playwright для стейкхолдерів в одному прогоні. Allure дає historical trends — графік як змінювався pass rate з часу в часі, розбивку по suite і feature — але для цього потрібна персистентна history між прогонами, тому ми зберігаємо allure-results/history в S3 і підтягуємо її перед кожним новим прогоном. testInfo.attach() дозволяє вручну прикріпити до тесту будь-який артефакт: JSON відповідь API, скріншот на конкретному кроці, текстовий лог — і все це буде видно в HTML або Allure звіті прямо поруч з тестом, що сильно прискорює дебаг.
89 Як реалізувати кастомний Reporter в Playwright?
ProductionSenior

Вбудовані репортери Playwright (html, junit, allure) покривають більшість потреб. Кастомний Reporter потрібен коли треба надсилати результати в специфічну систему — Slack, Jira, власний дашборд — або формувати звіт у нестандартному форматі.

Reporter — це клас який імплементує інтерфейс Reporter з методами-хуками: onBegin, onTestEnd, onEnd. Playwright викликає їх автоматично.

reporters/SlackReporter.ts
import { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';

export default class SlackReporter implements Reporter {
  private failed: string[] = [];
  private passed = 0;

  onTestEnd(test: TestCase, result: TestResult) {
    if (result.status === 'failed') {
      this.failed.push(`❌ ${test.title()}`);
    } else if (result.status === 'passed') {
      this.passed++;
    }
  }

  async onEnd(result: FullResult) {
    const total = this.passed + this.failed.length;
    const emoji = result.status === 'passed' ? '✅' : '🔴';

    await fetch(process.env.SLACK_WEBHOOK!, {
      method: 'POST',
      body: JSON.stringify({
        text: `${emoji} E2E Results: ${this.passed}/${total} passed`,
        blocks: this.failed.length > 0 ? [{
          type: 'section',
          text: { type: 'mrkdwn', text: this.failed.join('\n') }
        }] : [],
      }),
    });
  }
}
playwright.config.ts
reporter: [
  ['html'],
  ['allure-playwright'],
  ['./reporters/SlackReporter'],
]
🔧 TypeScript / JS патерни в цьому прикладі class MyReporter implements Reporter {} — implements: TypeScript interface implementation; onTestEnd(test: TestCase, result: TestResult): void — метод з типізованими параметрами і void return type; result.status === 'failed' — порівняння рядків.
🗣 Як читати вголос Клас SlackReporter реалізує інтерфейс Reporter з Playwright. Два приватних поля: масив failed і лічильник passed. Метод onTestEnd — Playwright викликає його після кожного тесту, передає об'єкти TestCase і TestResult. Перевіряємо result.status і додаємо назву тесту в масив або інкрементуємо лічильник. Метод onEnd — після всіх тестів, отримує FullResult зі загальним статусом. Робимо fetch на Slack webhook, передаємо JSON з текстом і блоками. В конфігу додаємо шлях до нашого файлу в масив reporter.
90 Як реалізувати visual regression testing в Playwright?
ProductionSenior

Visual regression testing — перевірка що UI не змінився візуально між версіями. Playwright має вбудований toHaveScreenshot() — він робить скріншот і порівнює з еталоном. Якщо є відмінність — тест падає і показує diff.

Важливо: toHaveScreenshot() — це web-first assertion. Багато хто думає що скріншот робиться один раз — насправді Playwright робить скріншот, порівнює, і якщо не збігається — повторює спробу до таймауту. Це рятує від flaky через анімації чи пізній рендер. Тест падає тільки якщо за весь таймаут жоден скріншот не збігся.

Три параметри порівняння — не плутати:

  • maxDiffPixels: 50 — абсолютна кількість: допускаємо до 50 різних пікселів
  • maxDiffPixelRatio: 0.01 — частка: допускаємо до 1% від усіх пікселів
  • threshold: 0.2 — чутливість до різниці кольору одного пікселя (0 до 1). Наскільки колір має відрізнятись щоб піксель вважався "різним"

Де живуть baseline: поруч з тестом у папці <назва-тесту>.spec.ts-snapshots/, з іменем що містить назву, платформу і браузер — наприклад homepage-chromium-darwin.png. Тому скріншоти з macOS не збігаються з Linux у CI (різний рендеринг шрифтів) — baseline генерують у Docker образі того самого середовища. npx playwright test --update-snapshots перезаписує еталони новими; після цього їх треба переглянути в git diff перед комітом — інакше можна закомітити реальний баг як новий еталон.

Головна складність: динамічні елементи (дати, анімації, live chat) завжди відрізнятимуться. Їх треба маскувати або стабілізувати.

TypeScript — pixel comparison
// Вбудований snapshot comparison
await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixels: 50,             // допускаємо до 50 пікселів різниці
  threshold: 0.1,               // 10% різниця на піксель
  animations: 'disabled',       // зупиняємо CSS анімації
});

// Snapshot окремого компонента
await expect(
  page.locator('.product-card').first()
).toHaveScreenshot('product-card.png');

// Оновити базові знімки:
// npx playwright test --update-snapshots

// ✅ mask передається в тесті — page тут доступний
await expect(page).toHaveScreenshot('homepage.png', {
  mask: [
    page.locator('.timestamp'),   // маскуємо динамічні дати
    page.locator('.ad-banner'),    // маскуємо рекламу
  ],
  maxDiffPixels: 50,
});

// ❌ НЕ ПРАВИЛЬНО — page.locator() недоступний в defineConfig()
// Глобально в конфізі можна задати тільки числові пороги:
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,     // ✅ числовий поріг — глобально ок
      threshold: 0.1,        // ✅ числовий поріг — глобально ок
      animations: 'disabled', // ✅ рядкове значення — глобально ок
      // mask: [] ← ❌ page недоступний в defineConfig
    },
  },
});
🔧 TypeScript / JS патерни в цьому прикладі await expect(page).toHaveScreenshot('name.png') — snapshot assertion; { mask: [page.locator('.dynamic')] } — масив Locator об'єктів як опція маскування; maxDiffPixels — числовий threshold.
🗣 Як читати вголос expect(page).toHaveScreenshot('homepage.png', { ... }) — при першому запуску створює базовий знімок, при наступних — порівнює піксель за пікселем. maxDiffPixels: 50 — допускаємо абсолютну різницю до 50 пікселів. threshold: 0.1 — допустима різниця кольору на піксель від 0 до 1. animations: 'disabled' — Playwright зупинить CSS-анімації перед знімком. mask в конфігу — масив локаторів що будуть зафарбовані чорним перед порівнянням, це для динамічних зон на кшталт дати або реклами. Оновити еталони: --update-snapshots.
91 Як тестувати доступність (a11y) в Playwright?
ProductionSenior

Accessibility (a11y) тестування перевіряє що сайт доступний для людей з обмеженими можливостями — screen readers, клавіатурна навігація, достатній контраст. Playwright інтегрується з бібліотекою axe-core яка автоматично знаходить порушення WCAG стандарту.

Сучасний підхід — Locator Assertions замість page.accessibility.snapshot(). Старий API page.accessibility.snapshot() ще існує, але після появи a11y-матчерів використовується рідко. Для точкових перевірок зараз рекомендують:

await expect(locator).toHaveAccessibleName('Add to Cart');
await expect(locator).toHaveAccessibleDescription('Adds item to your bag');
await expect(locator).toHaveRole('button');

Чому getByRole() сам по собі тестує a11y: він працює через Accessibility Tree браузера, а не через DOM-атрибути. Браузер будує це дерево з семантики HTML — тому <button> має роль button навіть без явного role="button". Саме через це getByRole іноді знаходить елементи без атрибута role — і навпаки, якщо getByRole не бачить елемент, значить і screen reader його не побачить.

TypeScript — @axe-core/playwright integration
import { AxeBuilder } from '@axe-core/playwright'; // npm i @axe-core/playwright

test('product page is accessible', async ({ page }) => {
  await page.goto('/product/123');

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa']) // перевіряємо тільки ці стандарти
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]); // нуль порушень
});

// Вбудована перевірка через ARIA tree
// getByRole() сам тестує a11y — якщо елемент не видно в tree,
// значить він не доступний для screen reader

// Snapshot ARIA tree
await expect(page.locator('nav')).toMatchAriaSnapshot(`
  - navigation:
    - link "Home"
    - link "Products"
    - link "Cart [2]"
`);
🔧 TypeScript / JS патерни в цьому прикладі new AxeBuilder({ page }).withTags([...]).analyze() — builder pattern: кожен метод повертає той самий об'єкт для chaining; await тільки перед analyze() — він повертає Promise; { violations } — деструктуризація результату; violations.length — властивість масиву.
🗣 Як читати вголос Для a11y тестування використовуємо офіційний пакет @axe-core/playwright. Створюємо екземпляр AxeBuilder передаючи page, потім через builder pattern налаштовуємо: withTags(['wcag2a', 'wcag2aa']) — перевіряємо тільки ці стандарти WCAG. analyze() — запускає перевірку і повертає результат. Перевіряємо що масив violations порожній. toMatchAriaSnapshot — вбудований в Playwright метод: знімає ARIA-дерево елементу і порівнює з template literal, де дефіси це рівні вкладеності ролей.
92 Як аналізувати performance під час тестів через Playwright?
ProductionSenior

Playwright дає доступ до браузерних performance метрик через Chrome DevTools Protocol. Можна вимірювати Web Vitals — LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), FID — прямо в тестах і перевіряти що вони не погіршуються між релізами.

TypeScript — Web Vitals + CDP
test('PDP meets Core Web Vitals thresholds', async ({ page }) => {
  // Підключаємо CDP для performance
  const client = await page.context().newCDPSession(page);
  await client.send('Performance.enable');

  await page.goto('/product/123');

  // Web Vitals через PerformanceObserver
  const vitals = await page.evaluate(() =>
    new Promise(resolve => {
      const result: Record<string, number> = {};
      new PerformanceObserver(list => {
        list.getEntries().forEach(entry => {
          if (entry.entryType === 'largest-contentful-paint')
            result.LCP = entry.startTime;
          if (entry.entryType === 'layout-shift')
            result.CLS = (result.CLS || 0) + (entry as any).value;
        });
        setTimeout(() => resolve(result), 1000);
      }).observe({ type: 'largest-contentful-paint', buffered: true });
    })
  );

  // Assert thresholds (Google рекомендує LCP < 2500ms)
  expect(vitals.LCP).toBeLessThan(2500);
  expect(vitals.CLS).toBeLessThan(0.1);
});
💡 Додатково: Playwright Trace Viewer показує network waterfall та timeline. Combine з Allure для трендового моніторингу performance.
🔧 TypeScript / JS патерни в цьому прикладі await page.context().newCDPSession(page) — ланцюжок методів; await client.send('Performance.enable') — виклик з рядковим аргументом (CDP command); new Promise((resolve) => { new PerformanceObserver((list) => { resolve(data) }) }) — Promise constructor з PerformanceObserver callback.
🗣 Як читати вголос page.context().newCDPSession(page) — відкриваємо Chrome DevTools Protocol сесію для конкретної вкладки. client.send('Performance.enable') — вмикаємо domain Performance через CDP. Потім page.evaluate — виконуємо код в браузері, повертаємо new Promise. Всередині браузера створюємо PerformanceObserver, підписуємось на largest-contentful-paint, в колбеку записуємо startTime як LCP і акумулюємо layout-shift для CLS. buffered: true — отримаємо і вже минулі події. Після секунди резолвимо проміс з зібраними метриками і перевіряємо порогові значення через звичайні матчери.
93 Як використовувати toMatchSnapshot() для JSON і тексту, а не тільки для скріншотів?
ProductionSenior

Більшість знають toHaveScreenshot() для візуальних регресій. Але є ще toMatchSnapshot() — для будь-яких даних: JSON, текст, HTML рядки.

Ідея проста: перший запуск — Playwright зберігає "еталон" у файл поруч з тестом. Кожен наступний запуск — порівнює з еталоном. Якщо відрізняється — тест падає і показує diff де саме.

Навіщо для JSON: API повертає складний об'єкт або структуру dataLayer події. Замість двадцяти expect(event.field).toBe(value) — зберігаємо весь об'єкт як еталон і порівнюємо цілком. Якщо хтось випадково змінить структуру — тест одразу покаже що саме змінилось.

  • toEqual() — ти вручну описуєш очікуване значення прямо в коді тесту
  • toMatchSnapshot() — очікуване значення зберігається у зовнішньому файлі. Playwright генерує його сам при першому запуску

Оновлення еталону: коли структура навмисно змінилась — запускаєш з --update-snapshots і Playwright перезаписує файл еталону.

TypeScript — snapshot для GTM dataLayer події
test('структура події add_to_cart відповідає еталону', async ({ page }) => {
  await page.goto('/product/trench-coat');
  await page.locator('button.add-to-cart').click();

  await page.waitForFunction(() =>
    (window as any).dataLayer?.some((e: any) => e.event === 'add_to_cart')
  );

  const event = await page.evaluate(() =>
    (window as any).dataLayer.find((e: any) => e.event === 'add_to_cart')
  );

  // ❌ Без snapshot — треба вручну описати всю структуру
  expect(event.event).toBe('add_to_cart');
  expect(event.ecommerce.items[0].item_id).toBe('SKU-123');
  expect(event.ecommerce.items[0].item_name).toBe('Trench Coat');
  // ... ще 15 полів

  // ✅ З snapshot — зберігаємо весь об'єкт як еталон
  expect(JSON.stringify(event, null, 2)).toMatchSnapshot('add-to-cart-event.txt');
  // Перший запуск: створює файл add-to-cart-event.txt з поточним JSON
  // Наступні запуски: порівнює з файлом, показує diff якщо щось змінилось
});
TypeScript — snapshot для тексту і HTML
test('SEO мета-теги відповідають еталону', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // Збираємо всі мета-теги як текст
  const metaTags = await page.evaluate(() => {
    const tags = document.querySelectorAll('meta');
    return [...tags].map(tag => ({
      name: tag.getAttribute('name'),
      property: tag.getAttribute('property'),
      content: tag.getAttribute('content'),
    }));
  });

  // Snapshot для всієї структури — якщо хтось змінить og:title або description,
  // тест одразу покаже diff
  expect(JSON.stringify(metaTags, null, 2)).toMatchSnapshot('pdp-meta-tags.txt');
});

test('email шаблон відповідає еталону', async ({ page }) => {
  await page.goto('/email-preview/order-confirmation');

  // Snapshot для HTML — виявляє ненавмисні зміни в шаблоні листа
  const emailHtml = await page.locator('.email-body').innerHTML();
  expect(emailHtml).toMatchSnapshot('order-confirmation-email.html');
});
Термінал — перший запуск, фейл зі змінами і оновлення еталону
# Перший запуск — Playwright створює файл еталону
npx playwright test product.spec.ts

# ✓ структура події add_to_cart відповідає еталону
#   → snapshot written: tests/__snapshots__/product.spec.ts/add-to-cart-event.txt

# ─────────────────────────────────────────────────────────────────
# Наступний запуск — хтось змінив структуру події
npx playwright test product.spec.ts

# ✗ структура події add_to_cart відповідає еталону
#   Snapshot comparison failed:
#   - Expected  + Received
#   {
#     "event": "add_to_cart",
#     "ecommerce": {
#       "items": [{
# -       "item_name": "Trench Coat",
# +       "item_name": "Classic Trench Coat",  ← хтось змінив назву
# -       "currency": "GBP"
# +       "currency": "USD"                    ← змінився домен
#       }]
#     }
#   }

# ─────────────────────────────────────────────────────────────────
# Зміна навмисна — оновлюємо еталон і комітимо
npx playwright test product.spec.ts --update-snapshots

# ✓ структура події add_to_cart відповідає еталону
#   → snapshot updated: tests/__snapshots__/product.spec.ts/add-to-cart-event.txt
Git — snapshot файли обов'язково комітити в репозиторій
// Snapshot файли — це частина кодової бази, не артефакти збірки
// Без них CI не має з чим порівнювати → тести або падуть або втратять сенс

// Структура файлів:
// tests/
//   product.spec.ts
//   __snapshots__/          ← ЦЯ ПАПКА ЙДЕ В GIT
//     product.spec.ts/
//       add-to-cart-event.txt
//       pdp-meta-tags.txt

// .gitignore — переконайся що __snapshots__/ НЕ ігнорується
// (іноді випадково додають разом з іншими артефактами)

// Правильний workflow:
// 1. Написав тест → запустив локально → snapshot файл створено
// 2. git add tests/__snapshots__/ → закомітив разом з тестом
// 3. CI запускає → порівнює з тим самим файлом що в git
// 4. Хтось навмисно змінив структуру → --update-snapshots локально
// 5. git add tests/__snapshots__/ → закомітив оновлений еталон
// 6. CI бачить новий еталон → тест проходить
💡 Коли toMatchSnapshot() корисний а коли ні:
Корисний: складна структура з багатьма полями (GTM події, API відповіді, мета-теги), HTML шаблони листів або PDF прев'ю, конфігураційні JSON де важлива кожна деталь
Не корисний: прості значення де ліпше toEqual(), дані що часто змінюються навмисно (ціни, дати), дані з id або timestamp які різні кожного разу — спершу треба їх відфільтрувати
🔧 TypeScript / JS патерни в цьому прикладі JSON.stringify(obj, null, 2) — серіалізація з відступами: null (replacer не потрібен), 2 (відступ 2 пробіли); .toMatchSnapshot('name.txt') — snapshot assertion зберігає рядок у файл; --update-snapshots — CLI флаг для оновлення еталонів.
🗣 Як читати вголос toMatchSnapshot() для тексту і JSON працює так само як для скріншотів — при першому запуску Playwright зберігає поточне значення у файл-еталон поруч з тестом, і кожен наступний запуск порівнює з цим файлом, показуючи diff якщо щось змінилось. Це особливо зручно для складних структур типу GTM dataLayer подій де є десятки полів — замість того щоб писати двадцять окремих assertions, ми серіалізуємо весь об'єкт через JSON.stringify і порівнюємо з еталоном одним рядком. Якщо хтось випадково змінить назву товару або валюту в події — тест впаде і покаже точно яке поле змінилось і з якого значення на яке. Головне що треба памятати: дані з динамічними значеннями типу timestamp або id треба відфільтровувати перед snapshot, інакше тест буде падати щоразу через різні значення. Коли структура навмисно змінилась — оновлюємо еталон через --update-snapshots.
94 Як тестувати локалізацію і коректний routing на платформі з десятками country domains?
ProductionSenior

Платформа з 45 країновими доменами — це 45 варіантів мови, валюти, формату дат і URL структури. Тестувати кожен домен вручну нереально. Data-driven підхід: описуємо конфігурацію кожного домену в масиві і генеруємо тести автоматично.

Кейс: Multi-domain e-commerce — 45 country domains, 9 мов
// Data-driven підхід: один тест, матриця доменів через playwright.config.ts projects
type DomainConfig = {
  domain: string;
  locale: string;
  currency: string;
  expectedLangAttr: string; // html lang= для перевірки SEO/a11y
};

const domains: DomainConfig[] = [
  { domain: 'us', locale: 'en-US', currency: 'USD', expectedLangAttr: 'en' },
  { domain: 'fr', locale: 'fr-FR', currency: 'EUR', expectedLangAttr: 'fr' },
  { domain: 'jp', locale: 'ja-JP', currency: 'JPY', expectedLangAttr: 'ja' },
];

for (const { domain, locale, currency, expectedLangAttr } of domains) {
  test(`PDP коректний для домену ${domain}`, async ({ browser }) => {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();
    await page.goto(`https://${domain}.myshop.com/product/123`);

    await expect(page.locator('html')).toHaveAttribute('lang', expectedLangAttr);
    await expect(page.locator('.price')).toContainText(currency);
  });
}
💡 Що ще тестується окремо: hreflang-теги (SEO), редиректи між доменами при зміні мови, geo-IP based default routing, форматування цін/дат за locale (не лише currency symbol, а й розрядність — 1,000.00 vs 1.000,00), переклад GTM dataLayer подій (event names мають лишатись англійською, а content — локалізований).
🔧 TypeScript / JS патерни в цьому прикладі const domains: DomainConfig[] = [{ ... }, { ... }] — TypeScript масив з типом; domains.forEach(({ country, locale, currency, domain }) => { test(...) }) — forEach з деструктуризацією вкладеного об'єкту; data-driven pattern: дані окремо від логіки тесту.
🗣 Як читати вголос На платформі з 45 доменами і 9 мовами неможливо й не потрібно дублювати кожен тест 45 разів вручну — натомість я виношу конфігурацію домену, locale, валюти й очікуваного lang-атрибута в окремий масив DomainConfig і прогоняю один і той самий сценарій data-driven циклом, де для кожного домену створюється власний BrowserContext з потрібним locale, бо locale впливає на формат дат і чисел, які повертає браузер. У кожній ітерації перевіряю не тільки видимий контент, а й технічні речі, важливі для SEO та a11y: атрибут lang у тегу html має відповідати мові домену, бо це впливає на screen readers і пошукову індексацію. Окремо це покриває валюту — текст ціни має містити правильний currency code чи символ для регіону. За межами цього прикладу команда ще окремо перевіряє hreflang-теги для SEO, коректність geo-IP редиректів при заході з різних країн, і що важливо для нашої GTM-інтеграції — event names в dataLayer завжди лишаються англійською незалежно від домену, а локалізується тільки контент, який бачить користувач, бо аналітика має бути уніфікованою для всіх ринків.
95 Що таке Playwright Component Testing і чим воно відрізняється від E2E?
ProductionSenior

Component Testing — можливість монтувати окремий React/Vue/Svelte компонент у справжньому браузері і тестувати його ізольовано, без запуску всього застосунку. Тобто це не E2E і не unit — щось посередині.

Unit (Jest + jsdom)   → компонент у ФЕЙКОВОМУ DOM, дуже швидко,
                         але jsdom != браузер: немає layout, CSS, реальних подій

Component (Playwright) → компонент у СПРАВЖНЬОМУ браузері, ізольовано,
                         є CSS, layout, реальні кліки — але без бекенду і роутингу

E2E (Playwright)      → весь застосунок цілком, реальний бекенд,
                         найповільніше, але найближче до юзера

Навіщо це існує: уяви компонент <SizeSelector> з 12 станами — sold out, one left, preorder, різні країни. Тестувати всі 12 через E2E — треба 12 різних товарів у базі і 12 повільних прогонів. У component test ти просто передаєш props і перевіряєш рендер за секунду. При цьому це справжній браузер — CSS і layout працюють, на відміну від jsdom.

⚠️ Важливо для інтерв'ю: Component Testing у Playwright досі має статус experimental — API може змінитись. Потрібен окремий пакет @playwright/experimental-ct-react і окремий конфіг playwright-ct.config.ts. Тому на питання "чи використовували б ви це в production" чесна відповідь: для критичних UI-компонентів зі складною матрицею станів — так, але з розумінням що це experimental. Основне покриття все одно E2E.

playwright-ct.config.ts + приклад component test
// npm init playwright@latest -- --ct
// Створює playwright-ct.config.ts і playwright/index.tsx

// SizeSelector.spec.tsx — зверни увагу на розширення .tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { SizeSelector } from './SizeSelector';

test('показує Sold out і блокує вибір недоступного розміру', async ({ mount }) => {
  // mount — спеціальна fixture: монтує компонент у браузер
  // Повертає Locator на змонтований компонент
  const component = await mount(
    <SizeSelector
      sizes={[
        { label: 'S', available: true },
        { label: 'M', available: false },
      ]}
      onSelect={() => {}}
    />
  );

  // Далі — звичайні локатори і web-first assertions
  await expect(component.getByRole('button', { name: 'M' })).toBeDisabled();
  await expect(component.getByText('Sold out')).toBeVisible();

  // Реальний браузер — можна перевіряти CSS, чого jsdom не вміє
  await expect(component.getByRole('button', { name: 'M' }))
    .toHaveCSS('text-decoration-line', 'line-through');
});

// Мокання props-колбеків — перевіряємо що компонент їх викликає
test('викликає onSelect з обраним розміром', async ({ mount }) => {
  const selected: string[] = [];
  const component = await mount(
    <SizeSelector
      sizes={[{ label: 'S', available: true }]}
      onSelect={(size) => selected.push(size)}
    />
  );

  await component.getByRole('button', { name: 'S' }).click();
  expect(selected).toEqual(['S']);
  // ↑ колбек живе в Node.js, Playwright прокидає виклик через міст
});
🔧 TypeScript / JS патерни в цьому прикладі @playwright/experimental-ct-react — окремий пакет, не @playwright/test; mount — fixture що повертає Locator на змонтований компонент; JSX прямо в тесті — тому файл має розширення .tsx; onSelect={(size) => selected.push(size)} — колбек визначений у Node.js але викликається з браузера: Playwright серіалізує виклик через свій міст (те саме обмеження серіалізації що й у evaluate).
🗣 Як читати вголос Component Testing у Playwright дозволяє змонтувати окремий React або Vue компонент у справжньому браузері і протестувати його ізольовано. Це проміжний рівень між unit і E2E: на відміну від Jest з jsdom, тут справжній браузерний рушій — працює CSS, layout, реальні події. На відміну від E2E — немає бекенду і роутингу, компонент отримує props напряму. Головний сценарій: компонент зі складною матрицею станів. Замість дванадцяти повільних E2E з дванадцятьма товарами в базі — дванадцять швидких component-тестів де ти просто передаєш різні props. Технічно потрібен окремий пакет experimental-ct-react, окремий конфіг playwright-ct.config.ts, і файли тестів мають розширення tsx бо всередині JSX. Ключова fixture — mount: вона монтує компонент і повертає звичайний Locator, далі працюєш як у будь-якому Playwright тесті. Важливий нюанс для інтерв'ю: це досі experimental, API може змінитись, тому основне покриття все одно E2E.
96 Як ти дебажила CI pipeline після переходу на Node.js v24?
ProductionSenior

Реальний кейс: після оновлення Node.js з v20 до v24 CI pipeline почав падати з незрозумілими помилками. Проблема виявилась у несумісності ES modules і CommonJS в helper скриптах, плюс зміна в обробці environment variables. Вирішення — поетапне: спочатку ізолювати причину, потім конвертувати скрипти.

Кейс: branch ta/PROJ-1102 — GitLab CI compatibility fix
// Проблема 1: TS helper-скрипти падали на require() у Node 24 (ESM-first)
// ❌ import { getDefectLabel } from './helpers/labels'; // top-level await issues
// ✅ Конвертація в CommonJS для helper-скриптів CI (не для тестів)
const { getDefectLabel } = require('./helpers/labels.cjs');

// Проблема 2: токен не співпадав через зміну формату GitLab CI variable masking
// ❌ const token = process.env.CI_JOB_TOKEN; // маскувався частково в логах, ламав auth header
// ✅ Явне зчитування з protected variable + перевірка перед використанням
const token = process.env.GITLAB_API_TOKEN;
if (!token) throw new Error('GITLAB_API_TOKEN відсутній у CI variables');

// Проблема 3: label-names не співпадали з Jira sync (PROJ-1102)
// ❌ labels: ['defect'] // GitLab створював, але Jira-sync шукав 'bug-auto'
// ✅ Уніфікований label-mapping на обидва боки інтеграції
const LABEL_MAP = { defect: 'bug-auto' } as const;
💡 Чому це гарний приклад: три різні класи проблем (модульна система, секрети, конфігурація інтеграції) в одному переході версії — показує, що CI debugging рідко зводиться до однієї причини.
🔧 TypeScript / JS патерни в цьому прикладі module.exports = { ... } — CommonJS export (не ES modules); require('path') — CommonJS import; різниця від ES modules: import/export vs require/module.exports; process.env.VARIABLE — environment variable в Node.js.
🗣 Як читати вголос Перехід на Node.js 24 зламав одразу декілька речей. По-перше, helper-скрипти, написані під TypeScript з ESM-синтаксисом, почали конфліктувати з тим, як новий Node резолвить модулі — я перевела саме CI-скрипти (не самі тести) на CommonJS через require(), оскільки для разових helper-задач це простіше й надійніше за дебаг ESM top-level await. По-друге, формат маскування GitLab CI variables змінився, і токен, який раніше читався з CI_JOB_TOKEN, почав приходити в неправильному форматі — перейшла на явну protected variable з перевіркою на undefined перед використанням, щоб помилка падала одразу з зрозумілим повідомленням, а не на кроці автентифікації. По-третє, в інтеграції PROJ-1102 — автостворення GitLab issues з синком у Jira — назви лейблів розійшлись між системами, тому я зробила єдиний LABEL_MAP як shared mapping-шар, щоб зміна назви в одному місці не ламала інтеграцію.
97 Як ти, як QA Lead, балансуєш швидкість релізів і стабільність тестів?
ProductionSenior

Це баланс між швидкістю доставки нових фіч і впевненістю що релізи стабільні. Надто багато тестів уповільнюють pipeline і блокують розробку. Надто мало — пропускають баги в production. Стратегія: тести за рівнями ризику з різними тригерами запуску.

Контекст: e-commerce проєкт — 2-тижневий реліз-цикл, ~4000 тестів, 6 паралельних воркерів
// Рівні захисту замість "запускати все на кожен PR"
const pipelineStrategy = {
  onPR:        { tags: ['@smoke'],      duration: '~5 хв',  workers: 6 },
  onMergeToMain: { tags: ['@critical'], duration: '~15 хв', workers: 6 },
  nightly:     { tags: ['@regression'], duration: '<3 год', workers: 6 },
};

// Метрика, яку я відстежую щотижня, а не тільки pass rate:
const healthMetrics = {
  passRate: '96-100%',
  criticalProdDefects: 0,
  flakyTestsTrend: 'через Allure history (S3, 14 suite folders)',
  aira: 4.7, // автоматизаційний health score команди
};
💡 Лідерський акцент: на цьому питанні чекають не технічну відповідь, а процесну — як приймаєш рішення, коли тиск релізу і якість тестів конфліктують.
🔧 TypeScript / JS патерни в цьому прикладі Архітектурний кейс. Ключові патерни: risk-based testing — пріоритизація за ризиком; pyramid vs trophy — моделі розподілу тестів; @smoke @critical @regression — tag-based strategy; метрики: AIRA score, pass rate, MTTR.
🗣 Як читати вголос Моя стратегія — не запускати весь regression-набір на кожен PR, а розподілити перевірки за рівнями ризику: на PR — тільки smoke-тести, секунди-хвилини, швидкий фідбек розробнику. На merge в main — critical-флоу, які покривають гроші-критичні шляхи типу checkout. Повний regression на 4000 тестів і 6 паралельних воркерів — нічний запуск, без тиску на швидкість релізу. Це дає 0 критичних production-дефектів при 2-тижневому циклі. Окремо я слідкую не тільки за pass rate, а за трендом flaky-тестів через Allure history, яка персистить у S3 — якщо тест періодично падає без зміни коду, це сигнал розслідувати причину до того, як він почне блокувати релізи. AIRA score 4.7 — це наша внутрішня метрика здоров'я автоматизації, яку я звітую стейкхолдерам, щоб рішення "сповільнити реліз заради стабільності" приймались на даних, а не на відчуттях.
98 Як ти онбордиш нового QA engineer у команду та підтримуєш стандарти коду?
ProductionSenior

Онбординг QA engineer — це не тільки "покажи де лежить код". Треба передати розуміння архітектури тестів, домену (e-commerce), команди і процесів. Хороший онбординг скорочує час до першого корисного PR з місяця до тижня.

Лідерський підхід
// Чеклист PR review для команди QA automation
const prReviewChecklist = [
  'Locator priority: semantic (getByRole/getByLabel) перед CSS/XPath',
  'Page Object: новий метод — чи є вже схожий у POFactory?',
  'Assertions: web-first (expect(locator)) замість assert(await ...)',
  'Test data: SKU з BlockedProductList, не хардкод з прода',
  'Tags: @smoke/@critical/@regression проставлені коректно',
];
💡 Чому питають це senior/lead: технічна експертиза без здатності масштабувати її на команду не дає того ж impact. Інтерв'юер перевіряє, чи ти думаєш про систему, а не тільки про свій код.
🔧 TypeScript / JS патерни в цьому прикладі const config: TestConfig = { ... } — TypeScript interface для конфігурації; interface TestConfig { maxLines: number; requirePatterns: string[] } — оголошення interface; PR checklist як structured object; string[] — масив рядків.
🗣 Як читати вголос Онбординг я будую навколо реальної кодової бази, а не абстрактної документації: новий інженер з першого дня працює з нашим POFactory і дивиться, як вже реалізовані схожі сторінки, перш ніж писати нову. Стандарти коду підтримую через PR review checklist — це не формальність, а конкретні пункти: чи використано семантичні локатори замість крихких CSS-селекторів, чи перевірив автор, що схожий Page Object метод вже не існує перед тим як дублювати логіку, чи стоять web-first assertions замість ручних await-перевірок, і чи тестові дані беруться з governance-списку, а не захардкоджені з продакшну. Я свідомо роблю чеклист коротким і конкретним, тому що довгий список ніхто не виконує — краще п'ять пунктів, які реально перевіряють на кожному PR, ніж двадцять, які ігнорують.