Уяви, що у тебе є фізичний комп'ютер з браузером. Ти відкриваєш 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() — навмисний виняток, де ми свідомо ламаємо ізоляцію заради залежного флоу.
Коли ти пишеш 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.
Playwright як фреймворк постійно робить один і той самий вибір: краще явна помилка зараз, ніж прихована проблема пізніше. Це філософія, а не технічна деталь.
Strict mode: Selenium, якщо знайшов 10 кнопок за твоїм селектором, мовчки бере першу і йде далі. Тест "зелений", але ти клікнула не туди. Playwright замість цього одразу падає з помилкою "знайдено 12 елементів" — змушує тебе бути конкретною про те, який саме елемент ти маєш на увазі.
Теги @smoke/@critical/@regression: замість того щоб магічно вирішувати "які тести запустити зараз", ти явно позначаєш кожен тест і явно кажеш в CI команді --grep @smoke. Нічого неявного.
По суті всі три якорі — це одна думка з різних боків: Playwright будує систему, де кожна річ явна, ізольована і чекає правильного стану, а не покладається на удачу, таймінг чи поведінку "за замовчуванням". Як тільки ти це засвоїла — більшість питань на співбесіді перестають бути окремими фактами і стають очевидними наслідками цієї ідеї.
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 — вбудований запис всіх дій з скріншотами і мережевими запитами для дебагу
protocol — набір правил обміну даними між програмами; HTTP request — одноразовий запит-відповідь; WebSocket — постійне з'єднання (детально в Q2-Q3); headless — браузер без графічного інтерфейсу, стандарт для CI.
Уяви, що 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 вирішить що час його виконувати.
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.
import { chromium } from 'playwright' — імпорт з базової бібліотеки (не з @playwright/test); test('...', async () => {}) — виклик функції що реєструє callback, а не виконує його одразу; сам callback — це той самий патерн callback з попередньої картки, тільки викликає його не браузерна подія, а Test Runner коли настає час.
Node.js — це середовище де може виконуватись JavaScript поза браузером.
Спочатку JavaScript існував тільки в браузері — міг маніпулювати DOM, реагувати на кліки, відправляти запити. Але не міг читати файли з диску, запускати сервер, чи взаємодіяти з операційною системою. Node.js змінив це у 2009 — взяв JavaScript рушій з Chrome (V8) і запустив його окремо як самостійну програму на комп'ютері.
Тепер JavaScript може робити все що раніше робили тільки серверні мови: читати і писати файли, запускати HTTP сервер, підключатись до бази даних, запускати тести.
window, document, DOM, localStorageNode.js — середовище де є файлова система, мережа, процеси ОС, npm пакети
Один і той самий JavaScript — два різних середовища виконання
WebSocket — це постійне двостороннє з'єднання між двома програмами.
Звичайний HTTP запит: клієнт питає → сервер відповідає → з'єднання закривається. Як SMS — відправив, отримав відповідь, все. WebSocket інакший: з'єднання відкривається один раз і лишається відкритим. Обидві сторони можуть надсилати повідомлення одна одній в будь-який момент. Як телефонна розмова — лінія відкрита, говорять обидва.
Як це пов'язано з Playwright: коли ти запускаєш тест — він виконується в Node.js. Playwright відкриває WebSocket з'єднання з браузером один раз на початку і тримає його відкритим весь тест. Через цей канал Node.js надсилає команди браузеру ("клікни тут", "заповни це поле", "виконай цю функцію") і отримує результати назад. Саме тому змінні з Node.js недоступні в браузері — це фізично різні процеси, між ними тільки WebSocket канал.
// ══════════════════════════════════════════════════════════════════
// 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 передає команди
async function — функція яка може "чекати" (await) інші асинхронні операції; async/await — синтаксичний цукор над Promise, робить асинхронний код схожим на синхронний; { page } — деструктуризація об'єкта (object destructuring): витягуємо конкретне поле з об'єкта; стрілочна функція () => {} — скорочений синтаксис функції.
page, функції expect. Браузер — це окремий процес де живе JavaScript сторінки: window, document, React, dataLayer. Це два різних середовища які не бачать одне одного напряму. Playwright з'єднує їх через WebSocket — це постійне двостороннє з'єднання яке відкривається один раз на початку тесту і тримається відкритим до кінця. Через цей канал Node.js надсилає браузеру команди і отримує результати назад. Коли ти передаєш функцію в page.waitForFunction() або page.evaluate() — Playwright бере текст цієї функції, відправляє через WebSocket в браузер, і браузер виконує її у себе. Але змінні з Node.js туди не переїжджають — це як скопіювати текст функції на інший комп'ютер, змінні з першого комп'ютера там не з'являться. Тому closure не працює через цю межу, і змінні треба явно передавати як аргументи — тоді Playwright серіалізує їх в JSON і відправить разом з функцією.
Щоб зрозуміти WebSocket — спочатку треба зрозуміти як працює звичайний HTTP.
HTTP запит — як SMS:
Ти відправляєш повідомлення → сервер відповідає → з'єднання закривається. Щоразу коли треба щось отримати — треба відправити новий запит. Кожен запит це окреме з'єднання яке відкривається і закривається. Ефективно для сайтів де дані рідко змінюються — завантажив сторінку, все, чекати нічого.
WebSocket — як телефонна розмова:
З'єднання відкривається один раз і лишається відкритим. Обидві сторони можуть надсилати повідомлення одна одній в будь-який момент без нового запиту. Ефективно коли потрібен постійний обмін даними в реальному часі — чати, онлайн-ігри, фінансові тікери, і саме Playwright для керування браузером.
Чому Playwright використовує WebSocket: Playwright повинен постійно надсилати команди браузеру і отримувати результати. Якби використовував HTTP — кожна дія (click, fill, evaluate) потребувала б нового з'єднання. WebSocket відкривається один раз і через нього проходять всі команди протягом всього тесту — швидко і ефективно.
// ── HTTP — нове з'єднання на кожен запит ─────────────────────────
//
// Клієнт Сервер
// ─────── ──────
// "GET /page" ──────► з'єднання відкрилось
// ◄────── HTML з'єднання закрилось
//
// "GET /api/data" ───► нове з'єднання
// ◄──── JSON закрилось
//
// Кожен запит = відкрити + закрити з'єднання
// ── WebSocket — одне з'єднання на весь час ───────────────────────
//
// Клієнт Сервер
// ─────── ──────
// "привіт" ────────► з'єднання відкрилось — І ЛИШАЄТЬСЯ ВІДКРИТИМ
// ◄──── "привіт"
// "клікни .btn" ───► (те саме з'єднання)
// ◄──── "ok"
// "заповни #input" ► (те саме з'єднання)
// ◄──── "ok"
// ... з'єднання закривається тільки в кінці
// Ти пишеш звичайний код тесту:
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 говорить з браузером
// Окрема тема: 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');
});
1. WebSocket між Playwright і браузером — внутрішній, завжди є, через нього Playwright надсилає команди. Це Chrome DevTools Protocol (CDP). Ти його не бачиш, він просто існує під капотом.
2. WebSocket на самій сторінці — якщо сайт використовує WebSocket для реалтайм даних (чат, стрімінг цін). Цей можна слухати через
page.on('websocket').
page.on('event', callback) — патерн підписки на події (Observer/EventEmitter): передаємо callback-функцію яка викличеться коли подія відбудеться; ws => — стрілочна функція як callback; ws.on('framereceived', event => {}) — вкладені підписки на події; new DataTransfer() — створення екземпляру класу через new.
page.on('websocket') і перевіряти вміст повідомлень які приходять від сервера.
Щоб зрозуміти 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 — різні пристрої які підключаються до одного пульта. Самому відео пульт не потрібен — воно просто грає.
// Коли ти пишеш звичайний 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 робить це автоматично
// Зазвичай 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,
});
page.context().newCDPSession(page) — ланцюжок методів, повертає Promise з CDP client; client.send('Domain.method', params) — надсилає CDP команду, перший аргумент — рядок у форматі "Domain.method", другий — об'єкт параметрів; деструктуризація const { metrics } = await client.send(...) — беремо поле metrics з результату.
Ми щойно розібрались як 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 — в наступних двох картках.
// Ієрархія, яку будує 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
});
{ page }: { page: Page } — деструктуризація з TypeScript анотацією типу; async ({ page }) => {} — стрілочна async функція з деструктуризацією параметра; // коментарі — документують що Playwright робить "за лаштунками"; const — оголошення константи (значення не можна перепризначити).
Browser — це сам процес браузера, важкий об'єкт, який дорого створювати, тому Playwright переважно перевикористовує один на весь тестовий запуск. Всередині нього BrowserContext — це ізольована сесія, схожа на окреме інкогніто-вікно: у неї свої cookies, localStorage, кеш, дозволи. І вже всередині контексту — Page, конкретна вкладка, з якою ми безпосередньо взаємодіємо через goto, click, fill і так далі. Коли ми пишемо async ({ page }) у тесті, ми не створюємо цю вкладку самі — Playwright робить це автоматично: для кожного тесту піднімає новий BrowserContext, відкриває в ньому одну Page і підставляє її як fixture-аргумент. Анотація типу : Page — це просто TypeScript, який каже компілятору, що це об'єкт класу Page, а не any, щоб автодоповнення й перевірка типів працювали. Саме тому в кожному тесті, кожному Page Object, кожному helper-методі ти бачиш page — це не глобальна змінна, а свіжий ізольований об'єкт, який Playwright інжектить заново щоразу, і завдяки цьому один тест ніколи не впливає на стан іншого, навіть при паралельному запуску.
Фікстура — це готовий ресурс який Playwright сам підготував і передав у тест. Ти просто називаєш що тобі потрібно — і отримуєш це готовим. Після тесту Playwright сам прибирає.
Аналогія з рестораном: ти не ходиш на кухню щоб взяти тарілку — офіціант приносить її сам. Ти просто замовляєш "мені потрібна тарілка" і отримуєш готову. Fixture — це той офіціант.
У коді це виглядає так: коли пишеш async ({ page }) — ти не створюєш page сам. Playwright читає ключ деструктуризації page, розуміє що тобі потрібна вкладка, і підставляє готову. Важливо: Playwright дивиться на ключ об'єкта, а не на ім'я локальної змінної. Тому async ({ page: myPage }) працює так само — fixture key залишається page, просто локальна змінна перейменована на myPage.
Це схоже на Dependency Injection (DI) — патерн з бекенд-розробки. DI означає: "не створюй залежності сам — отримай їх ззовні". Замість того щоб писати new Database() всередині класу — клас отримує вже готовий об'єкт бази даних через конструктор або параметр. Так само тест отримує готову page через параметр функції — не сам її створює.
// 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 у деструктуризації) — і не думаєш про те, звідки вона взялась і хто її помиє.async ({}, use) => {} — стрілочна async функція з двома параметрами, перший — порожня деструктуризація (нам не потрібні інші fixtures); await use(value) — use це callback-функція яку ми викликаємо щоб "передати" ресурс в тест і "призупинити" fixture; все що до use() — setup, після — teardown.
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 }) => {...} і не думає про логін взагалі.
page, browser, context — це фікстури: ти їх не створюєш і не знищуєш, Playwright робить це сам.
// ✅ З фікстурами — ти просто називаєш що потрібно
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)
// одна вкладка = один тест
async ({ page }, use, testInfo) => {} — функція отримує три аргументи: інші fixtures, callback use(), і метадані тесту; testInfo.title — доступ до властивості об'єкта через крапку; Date.now() — виклик статичного методу класу; template literal `текст ${змінна}` — рядок з вбудованим виразом.
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, свій чистий стан.
Ми вже знаємо що таке Node.js, WebSocket, ієрархія браузера і fixtures. Час написати перший реальний тест — і ти побачиш як всі ці концепти з'єднуються разом.
Базовий тест складається з трьох частин: імпорт test і expect з Playwright, оголошення тесту через функцію test() з назвою і async функцією, і деструктуризація fixtures { page } — саме тут ми "просимо" Playwright дати нам готову вкладку.
Зверни увагу на await перед кожною дією — Playwright асинхронний, і без await наступний рядок виконається не чекаючи завершення попереднього.
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');
});
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 — і перевіряємо що він видимий і має потрібний текст.
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 у складному коді — три кроки:
- Знайди виклик функції — відкриваючу дужку
( - Шукай всередині дужок
=>або ключове словоfunction - Все що після
=>до відповідної закриваючої)— це callback і його тіло
() => {} — стрілочна функція як callback (передається як аргумент, не викликається одразу); async () => {} — async стрілочна функція-callback; setTimeout(fn, ms) — вбудована функція яка приймає callback; forEach(callback) — метод масиву з callback; { page } — деструктуризація параметра callback.
page.route() другим аргументом іде стрілочна функція — це callback, Playwright викличе її кожного разу коли браузер зробить запит що відповідає паттерну. У page.on('dialog', ...) — Playwright викличе callback коли з'явиться діалог. У test('назва', async ({page}) => {}) — сама функція тесту це теж callback, Playwright викликає її коли запускає тест. Ключова ознака callback: функція передається як аргумент всередині дужок іншої функції і не викликається одразу через круглі дужки після неї. Найпростіший приклад поза Playwright: setTimeout(() => console.log('привіт'), 3000) — ти передала функцію setTimeout, і він сам викличе її через 3 секунди.
- Об'єкт в JavaScript живе в оперативній пам'яті конкретного процесу як структура з адресами —
{ name → адреса 0x7f4b → 'Iuliia' }. Ці адреси мають сенс тільки всередині цього одного процесу. - Мережа і файли передають байти. Якщо відправити адресу
0x7f4b— на іншому комп'ютері вона вкаже на іншу ділянку пам'яті або нікуди. Об'єкт прив'язаний до конкретної пам'яті процесу. - Серіалізація — перетворення об'єкта з "адрес в пам'яті" в текст де значення записані явно. Текст не залежить від конкретної машини.
- Десеріалізація — зворотній процес: текст назад в об'єкт.
- JSON — найпоширеніший формат серіалізації у вебі. Виглядає майже як JavaScript об'єкт, розуміє будь-яка мова програмування і будь-який сервер.
- У Playwright серіалізація відбувається коли передаємо аргументи в
evaluate()абоwaitForFunction()— Playwright перетворює JavaScript об'єкт в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад в об'єкт.
Аналогія: об'єкт — це будинок у твоєму місті. Ти не можеш "відправити будинок" другу в іншому місті. Але можеш зробити план будинку і відправити його — це і є серіалізація. Друг отримує план і будує такий самий будинок — це десеріалізація.
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 }
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!
);
JSON.stringify(obj) — статичний метод класу JSON, серіалізує об'єкт в рядок; JSON.parse(text) — десеріалізує рядок назад в об'єкт; { name: 'Iuliia', age: 30 } — об'єктний літерал; серіалізація обмежена: функції, undefined, і посилання на DOM елементи серіалізувати не можна.
JSON.stringify() перетворює об'єкт в рядок, JSON.parse() — назад. У Playwright це важливо при передачі аргументів в evaluate() або waitForFunction(): Playwright серіалізує значення в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад. Саме тому closure не працює через межу процесів — функція переїжджає в браузер, але змінні з Node.js залишаються в Node.js, і їх треба явно передавати як аргументи через серіалізацію.
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]
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
// 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 перезаписує наше)
...arr — spread масиву: вставляє елементи; ...obj — spread об'єкта: вставляє поля; { ...obj1, ...obj2 } — злиття об'єктів (merge); { ...obj, key: value } — копіювання об'єкта з додаванням або перезаписом поля; spread не мутує оригінальний об'єкт — створює новий.
[...arr1, ...arr2] — замість масиву всередині масиву отримуємо плоский масив з усіма елементами. З об'єктами: { ...obj1, ...obj2 } — зливаємо два об'єкти в один, якщо є однакові поля — перемагає той що стоїть правіше. В Playwright найчастіше бачимо в конфігурації: { ...devices['iPhone 14'], locale: 'uk-UA' } — розгортаємо готовий профіль девайсу з усіма його полями і додаємо своє. Це набагато коротше ніж копіювати viewport, userAgent і isMobile вручну.
В 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.
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. Простий спосіб визначити: якщо перед методом стоїть назва класу з великої літери — статичний. Якщо змінна — звичайний метод екземпляру.
try/catch — це спосіб обробити помилку не дозволяючи їй зупинити програму. Коли код всередині 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('цей рядок виконається'); // ✅
try {
// код який може кинути помилку
} catch (e) {
// що робити якщо помилка сталась
// e — об'єкт помилки: e.message, e.name, e.stack
} finally {
// виконується ЗАВЖДИ — і при помилці і без
// типово для закриття з'єднань, файлів тощо
}
// 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 тест
try { } catch (e) { } — синтаксис обробки помилок; e.message — рядок з описом помилки; e.name — назва класу помилки (наприклад 'TimeoutError'); finally — блок що виконується завжди незалежно від результату; throw e — перекинути помилку далі (деталі в наступній картці).
try/catch — механізм обробки помилок: код що може кинути помилку кладемо в блок try, якщо помилка сталась — виконання переходить в catch і програма продовжується. Об'єкт помилки e містить message, name і stack. Блок finally виконується завжди — корисний для очищення ресурсів. В Playwright try/catch використовується рідко бо більшість методів вже падають з зрозумілим повідомленням. Потрібен коли хочеш перехопити конкретну помилку і відновитись — наприклад якщо елемент може і не з'явитись і це нормальна ситуація. Детальніше про класи помилок Playwright — в наступній картці.
Локатор — це спосіб знайти елемент на сторінці. Це не сам елемент, а інструкція "знайди мені ось це". Playwright шукає елемент заново кожного разу коли ти виконуєш дію — клік, fill, перевірку.
Playwright рекомендує семантичні локатори — ті що шукають елемент за його змістом і роллю, а не за CSS класом або позицією в DOM. Чому? Бо CSS класи часто змінюються при рефакторингу, а роль кнопки "Submit" — ні.
// ✅ Семантичні — шукають за змістом і роллю, стійкі до змін верстки
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.page.getByRole('button', { name: 'Submit' }) — передача об'єкта як другого аргументу (options object pattern); { name: 'Submit' } — об'єктний літерал (object literal) як конфігурація.
getByRole шукає за ARIA роллю — наприклад 'button' або 'textbox' — і опційним ім'ям. getByLabel шукає input пов'язаний з лейблом — в HTML це зв'язок через атрибут for у лейблі який вказує на id інпута. getByTestId шукає за атрибутом data-testid який розробники додають спеціально для тестів. CSS і XPath допустимі але крихкіші — якщо верстка зміниться локатор впаде.
Коли локатор знаходить більше одного елемента і ти намагаєшся виконати дію — 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.
// ❌ Якщо на сторінці кілька .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();
handle?. — optional chaining: викликаємо метод тільки якщо handle не null/undefined; page.$() повертає ElementHandle | null — union type в TypeScript; const locator = page.locator() — зверни увагу: БЕЗ await, бо locator синхронний; await з'являється тільки на .click().
click чи fill, тест одразу падає з явною помилкою, а не мовчки бере перший елемент, як це робив би Selenium. Це навмисний дизайн-вибір: краще явний фейл на етапі написання тесту, ніж прихований баг, коли тест технічно "зелений", але клікає не на той елемент, який малося на увазі. Щоб обійти strict mode правильно, є кілька варіантів: .first() чи .nth(index), якщо порядок елементів стабільний і важливий; .filter({ hasText: ... }), якщо потрібен конкретний елемент за вмістом, що набагато надійніше за індекс; або .count(), якщо нам взагалі потрібна кількість, а не дія над одним елементом. Головний меседж — strict mode це не перешкода, а сигнал, що локатор недостатньо специфічний для того, що ми хочемо зробити.
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 вузол у браузерний контекст.// ── 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(); // ✅ завжди знаходить актуальний елемент
// ❌ Старий стиль (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(...)" — це теж помилка розуміння
await page.$() або await page.$$() — це legacy API (документація Playwright називає його discouraged, не deprecated), треба рефакторити на page.locator(). Бачиш await page.locator() — людина переплутала: locator() синхронний, await тут зайвий.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 і ще не перебудувалась, або написала старий код який треба рефакторити.
Перед кожною взаємодією з елементом 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, оверлей не зник. Юзер у такій ситуації клікнути б не зміг — а тест каже "все ок".
Виправдані випадки рідкісні: навмисна перевірка кліку по частково перекритому елементу, або обхід відомого бага стороннього віджета. І тоді поруч має бути коментар чому.
// ✅ 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
async ({ page }) => {} — деструктуризація fixtures; { state: 'visible' } — options object pattern; waitForFunction(() => {}) — передача стрілочної функції як аргумент (функція першого класу / first-class function); (window as any) — type assertion в TypeScript: говоримо компілятору "довіряй мені, це any".
waitForURL — явно чекаємо поки URL відповідає патерну, зірочки це wildcard. waitForLoadState('networkidle') — чекаємо поки мережа затихне, тобто немає активних запитів більше 500мс. В конфігу timeout: 60_000 — це глобальний ліміт на весь тест у мілісекундах, expect.timeout — окремий ліміт на кожен expect.
page.waitForTimeout(3000) — це фіксована пауза: тест зупиняється рівно на 3 секунди незалежно від того чи UI вже готовий чи ні. Це антипатерн з трьох причин: якщо UI готовий за 0.5 секунди — ти витрачаєш зайві 2.5 секунди; якщо UI не готовий за 3 секунди — тест впаде; і тести стають повільними і крихкими одночасно.
Playwright має auto-waiting — він сам чекає поки елемент готовий до взаємодії. Тому майже завжди замість waitForTimeout є правильніша альтернатива.
// ❌ Антипатерн: фіксована пауза "на удачу"
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(); // сама ретраїться, без зайвої затримки
await page.waitForTimeout(3000) — фіксована пауза в мілісекундах — АНТИПАТЕРН; await expect(locator).toBeVisible() — правильна альтернатива з auto-waiting; різниця: waitForTimeout завжди чекає 3 секунди, expect чекає рівно стільки скільки потрібно (до таймауту).
waitForTimeout — це фіксована пауза, яка не привʼязана до жодного реального стану сторінки, вона просто блокує виконання на задану кількість мілісекунд і сподівається, що за цей час потрібна подія відбудеться. Проблема в тому, що це число завжди вгадане: якщо мережа повільніша звичайного — двох секунд може не вистачити і тест впаде, а якщо забагато — тест буде штучно повільним на кожному прогоні, навіть коли все відбувається миттєво. Web-first assertion типу expect(locator).toBeVisible() вирішує обидві проблеми одночасно, бо чекає не фіксований час, а саме ту умову, яка нам потрібна, і завершується одразу, як тільки вона виконалась, або падає з чітким таймаутом, якщо не виконалась взагалі. Тому в код-рев'ю waitForTimeout — це майже завжди червоний прапорець, окрім дуже рідкісних кейсів типу навмисного очікування анімації, для якої немає detectable DOM-стану.
waitForLoadState('networkidle') — стан що означає "немає жодного мережевого запиту 500 мілісекунд". Здається зручним способом дочекатись поки "все завантажилось". Але на практиці це частий антипатерн.
Проблема: сучасні сайти мають постійний фоновий трафік — аналітика надсилає дані, WebSocket тримає з'єднання, polling оновлює дані. Стан "тиші 500мс" може ніколи не настати — і тест зависне до таймауту.
// ❌ Здається надійним, але офіційно НЕ рекомендовано для 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(), // дія що тригерить запит
]);
networkidle для сучасних SPA — рекомендується чекати конкретний елемент чи мережевий запит, а не "тишу в мережі" загалом.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. Це і швидше, бо не чекає зайвого, і надійніше, бо привʼязано до того, що нам справді потрібно для тесту.
Більшість людей думають що в 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 }. Різний діагноз → різне рішення.
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 секунд
},
});
// 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 }) => { /* ... */ });
});
// ── Приклад 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 тільки для нього.
timeout: 60_000 — числовий літерал з underscore separator (для читабельності, ідентичний 60000); expect: { timeout: 5_000 } — вкладений об'єкт в конфігурації; { timeout: 10000 } — options object для перевизначення таймауту локально.
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 як першої реакції — це майже завжди неправильно, бо це маскує реальну проблему замість її вирішення.
Звичайна перевірка в JavaScript виконується один раз і одразу: отримала значення — порівняла — результат. Але в браузерних тестах UI змінюється асинхронно: кнопка може ще не з'явитись, текст ще не оновитись. Перевірка "зараз" дасть хибний результат.
Web-first assertions — це expect(locator).toBeVisible() і подібні. Вони не перевіряють одразу — вони ретраяться з невеликим інтервалом поки умова не стане true або не вийде таймаут. Тобто вони самі чекають потрібного стану.
// ❌ Одноразова перевірка: 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
// ── 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() працює зі списком за задумом
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-помилки збираються і виводяться разом в кінці. Детальніше в окремій картці.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-тестів, де причина нестабільності — не логічна помилка, а просто різниця в мілісекундах між тим, коли елемент реально зʼявився, і коли ми його перевірили.
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/);
// Всі помилки виведуться разом
});
expect.soft(locator) — виклик методу на об'єкті expect (не функції); expect.soft повертає той самий expect API — це fluent interface; test.info().errors — доступ до вкладеної властивості через ланцюжок крапок.
expect — звичайний, якщо h1 не знайдено тест одразу зупиняється. Далі expect.soft — викликаємо статичний метод soft на об'єкті expect, передаємо локатор і чейнимо перевірку. Якщо ця перевірка падає — тест продовжує виконуватись далі. Всі накопичені soft-помилки виводяться разом в кінці тесту в одному звіті.
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 синхронні
// 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', ... }]
});
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');
});
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');
});
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' }
);
// Передаємо аргумент з 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
);
// Для простих умов є зручніша альтернатива — 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()
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 запит поки не прийде потрібний статус.
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/document → waitForFunction(). Перевіряєш DOM-елемент → звичайний web-first assertion, нічого з цього не треба.
// Чекаємо поки 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);
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).
Вбудовані 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.
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('€');
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 підказувала наш матчер з автодоповненням.
Shadow DOM — механізм браузера для інкапсуляції компонентів. Елементи всередині shadow root ізольовані від основного документу. Використовується в Web Components і деяких UI бібліотеках.
Добра новина: Playwright автоматично "пронизує" open shadow root при пошуку локаторами. В більшості випадків нічого особливого робити не треба.
// 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'));
{ mode: 'closed' }) Playwright теж не бачить, як і будь-який інший інструмент — це навмисне обмеження браузера, а не Playwright.page.locator('.shadow-host .inner-element') — CSS selector через Shadow DOM; Playwright автоматично пронизує open shadow root; shadowRoot.querySelector() — браузерний API для явного доступу; { force: true } — options object для примусової дії.
.stars, і Playwright сам розуміє, що шукати треба не тільки в основному документі, а й усередині будь-яких shadow roots на сторінці. Це особливо актуально для сучасних e-commerce платформ, які часто будуються на Web Components чи дизайн-системах із shadow DOM для інкапсуляції стилів. Єдиний виняток — closed shadow root, коли компонент явно створений з { mode: 'closed' }: тоді JavaScript у принципі не має доступу до цього shadow root, і це обмеження самого браузера, а не якесь упущення Playwright — жоден інструмент автоматизації не зможе туди зазирнути.
Ми вже розібрали що таке fixtures концептуально і чому page/context/browser так називаються. Тепер подивимось на всі вбудовані fixtures разом — і коли який використовувати.
Fixtures — це механізм dependency injection. Вбудовані: page, browser, context, request.
Scope вбудованих fixtures: page, context, request — test-scoped (створюються заново для кожного тесту). browser — worker-scoped (один на весь worker-процес, не на весь прогін). При паралельному запуску з 4 workers — буде 4 окремих браузерних процеси.
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();
});
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.
Проблема: коли в кожному тесті треба робити одне і те саме — наприклад, відкрити сторінку і залогінитись — ці рядки дублюються скрізь. Кастомна 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.
async ({ page }, use) => {} — стрілочна async функція з деструктуризацією і use як callback; await use(value) — Playwright викличе тест з нашим значенням; ім'я в extend() = ім'я в деструктуризації тесту.
use(), тест всередині, і teardown після. use() — розділювач: Playwright зупиняється на ньому, запускає тест, повертається і виконує teardown навіть якщо тест впав. В use() передаємо що підготували — page, об'єкт, рядок. В тесті пишемо ім'я fixture в деструктуризації — воно збігається з назвою в extend(). Fixture краща за beforeEach коли є teardown або коли треба передати значення в тест. Краща за глобальну змінну бо при паралельному виконанні кожен тест отримує свій власний екземпляр ресурсу.
Кастомні 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); }
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 автоматично
});
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 і тепер тести імпортують його замість стандартного.
Ми вже знаємо як писати 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 — опціональний стиль, але його можна зустріти в чужому коді тому треба розуміти.
// ── З 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);
});
// ❌ З 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 сам може впасти і треба прибрати часткові ресурси
// З 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() — це нормально
async function* () {} — generator function: зірочка після function робить її генератором; yield value — зупиняє генератор і "повертає" значення; після resume (продовження) — виконується код після yield; try/finally в генераторі — teardown завжди виконується навіть при помилці.
yield — це альтернативний синтаксис для того ж самого що робить use(): код до yield це setup, після yield це teardown, і сам yield це точка де виконується тест. Відрізняється тільки синтаксис: замість звичайної async функції яка приймає use як аргумент — функція-генератор з зірочкою async function*. На практиці більшість команд пишуть через use() бо це простіше і не потребує розуміння генераторів. yield стає зручнішим в одному конкретному випадку: коли потрібен try/finally щоб гарантувати cleanup навіть якщо сам тест або частина setup впала з помилкою — з yield цей патерн читається природньо як звичайний try/finally блок, а з use() виглядає трохи незграбно бо треба огортати сам виклик use() в try блок.
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 зберігається.
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);
});
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 і всі тести в ньому отримують той самий екземпляр.
Звичайна фікстура виконується тільки якщо ти явно вказала її в параметрах тесту:
test('мій тест', async ({ page, myFixture }) => { ... })
// ↑ треба явно попросити
auto: true змінює це — фікстура виконується для кожного тесту автоматично, навіть якщо тест про неї нічого не знає і не вказував її в параметрах.
Навіщо це потрібно? Є речі які треба робити перед або після кожного тесту, але не хочеться засмічувати параметри кожного тесту:
- Логувати початок і кінець кожного тесту в зовнішню систему
- Перевіряти що немає JS-помилок на сторінці після кожного тесту
- Встановлювати глобальний мок для аналітики щоб вона не заважала тестам
- Робити скріншот після кожного тесту незалежно від результату
Різниця від beforeEach: auto: true fixture визначається один раз у спільному файлі і автоматично застосовується до всіх тестів які використовують цей test.extend(). beforeEach можна винести в базовий test або shared helper, але auto fixtures масштабуються значно краще — setup і teardown в одному місці, не потрібен окремий afterEach, і типізація через TypeScript працює автоматично.
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: "запускай це завжди, без оголошення в тесті"
});
// 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
});
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..."
});
// 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-помилки — тест впаде з зрозумілим повідомленням
// навіть якщо сам тест не перевіряє помилки
});
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 тестів — набагато швидше
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
});
// ❌ Якщо імпортувати з @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();
});
beforeEach → простий setup в межах одного файлу, не потрібен teardownauto: true fixture → setup + teardown в одному місці, застосовується до всіх тестів які використовують цей test.extend(), не треба повторювати в кожному файлі
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 напряму.
Ми вже знаємо як виконати одну дію в тесті. Але коли тестів багато — кожен з них потребує схожого підготовчого кроку: відкрити певну сторінку, залогінитись, підготувати тестові дані. Писати це в кожному тесті окремо — дублювання. Саме для цього існують хуки.
Хуки — це функції що Playwright викликає автоматично у визначений момент: до тесту, після тесту, до всіх тестів в блоці, після всіх. Вони живуть всередині test.describe() і діють тільки на тести цього блоку.
Важливо про scope: beforeAll/afterAll виконуються один раз на worker, а не на весь прогін. При паралельному запуску з кількома workers — beforeAll виконається в кожному worker окремо. Якщо потрібно щоб код виконався рівно один раз для всього прогону — використовуй globalSetup або project dependencies.
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');
});
});
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 — прибираємо тестові дані після всього блоку.
Коли є один describe — все очевидно: beforeAll → beforeEach → тест → afterEach → afterAll. Але коли describe вкладені — порядок стає менш очевидним і це часто плутають.
Базове правило: хуки виконуються від зовнішнього до внутрішнього при вході і від внутрішнього до зовнішнього при виході. Уяви це як шари цибулі — спочатку знімаєш зовнішній шар, потім внутрішній. При виході — навпаки.
зовнішній beforeAll ← виконується ОДИН РАЗ перед усіма тестами
внутрішній beforeAll ← виконується ОДИН РАЗ перед тестами вкладеного describe
зовнішній beforeEach ← для КОЖНОГО тесту
внутрішній beforeEach ← для КОЖНОГО тесту у вкладеному describe
→ ТЕСТ
внутрішній afterEach ← для КОЖНОГО тесту
зовнішній afterEach ← для КОЖНОГО тесту
внутрішній afterAll ← ОДИН РАЗ після всіх тестів вкладеного describe
зовнішній afterAll ← ОДИН РАЗ після ВСІХ тестів включно з вкладеними
- Проблема 1:
beforeAllзовнішнього describe виконується для всіх тестів включно з вкладеними — навіть якщо setup потрібен тільки для зовнішніх тестів - Проблема 2: якщо зовнішній
beforeEachвстановлює стан, а внутрішній перевизначає — зовнішній все одно виконається першим - Проблема 3:
afterAllзовнішнього describe чекає поки завершаться ВСІ вкладені describe включно з їхafterAll
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 ← зовнішній ОСТАННІЙ
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();
});
});
});
// ❌ Типова помилка: думаємо що зовнішній 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 — один раз на весь describebeforeEach і afterEach — для кожного тесту окремо, але той самий принцип зовнішній→внутрішній
test.beforeAll(() => console.log('1. outer beforeAll')) — стрілочна функція без async (якщо не потрібен await); console.log() — виклик методу глобального об'єкту console; числа в рядках для демонстрації порядку виконання.
beforeEach це означає що зовнішній виконується першим, потім внутрішній, потім тест, потім внутрішній afterEach, і тільки потім зовнішній afterEach. Для beforeAll важлива пастка: він виконується один раз але для ВСІХ тестів в describe включно з вкладеними — тобто якщо ти зробила дорогий setup в зовнішньому beforeAll, він спрацює і перед тестами вкладеного describe, навіть якщо вони цього setup не потребують. Рішення: якщо setup потрібен тільки для певної групи тестів — перенести його у beforeAll того конкретного вкладеного describe, а не тримати у зовнішньому. afterAll зовнішнього describe чекає поки завершаться абсолютно всі вкладені describe включно з їх власними afterAll, тому teardown в зовнішньому afterAll виконується дійсно в самому кінці.
Природний рефлекс: зберегти спільне значення в глобальну змінну. В 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".
// ❌ Очікування: лічильник рахує всі тести по черзі
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 }));
fullyParallel: false (за замовчуванням) — один файл = один worker, тести всередині файлу послідовно, різні файли паралельно в різних workers.fullyParallel: true — кожен тест може отримати свій worker, навіть тести з одного файлу.Worker — це окремий Node.js процес (не потік!), тому змінні між workers не шаряться.
let sharedState = 0 — змінна в module scope; sharedState++ — increment оператор; кожен тест — окремий Node.js worker process: змінна існує тільки в межах свого процесу; workerInfo.workerIndex — доступ до індексу worker через об'єкт testInfo.
let testCounter = 0, в кожному воркері існує як власна, ізольована копія в пам'яті того процесу: зміна цієї змінної в одному файлі ніяк не видна іншому файлу, навіть якщо обидва імпортують той самий модуль. Це не баг, а навмисний дизайн — ізоляція процесів означає, що один тест не може випадково зламати стан іншого, навіть якщо в коді є помилка з мутацією спільних об'єктів. Якщо реально потрібен спільний стан між тестами — наприклад, лічильник чи прапорець "цей ресурс вже створено" — треба використовувати щось зовнішнє відносно Node.js процесу: файл на диску, запис у базі даних, чи API-виклик до сервісу, тому що тільки зовнішнє сховище буде дійсно одним і тим самим для всіх воркерів одночасно.
Для більшості випадків плоска структура з одним 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 яке болить.
// ✅ Один 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');
});
});
// Спільний 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 }) => { /* ... */ });
});
});
// ❌ Без вкладеного 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 }) => { /* тільки тест */ });
});
Є 2+ тести які потребують однакового setup з кількох кроків → вкладений describe
Setup відрізняється для різних груп тестів → окремий вкладений describe для кожної групи
Тільки один тест у групі → не варто, просто напиши все в тесті
Setup з одного кроку → теж зазвичай не варто, напиши прямо в тесті
test.describe('outer', () => { test.describe('inner', () => {}) }) — вкладені виклики функцій; test.beforeEach(async ({ page }) => {}) — hook з async callback; scope вкладеності: внутрішній beforeEach "бачить" зовнішній через closure.
Playwright ізолює кожен тест — новий BrowserContext для кожного. test.describe.serial() — навмисне порушення цього правила. Є E2E сценарії де тести мають виконуватись послідовно і кожен наступний залежить від результату попереднього: реєстрація → логін → замовлення. Якщо перший крок впав — немає сенсу виконувати решту.
// За замовчуванням: тести незалежні, порядок не гарантований,
// фейл одного НЕ впливає на інші
// ✅ 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, не запуститься взагалі
});
});
test.describe.serial('назва', () => {}) — виклик методу на вкладеному об'єкті describe; .serial — property access на test.describe; serial mode: тести виконуються послідовно і якщо один впав — наступні автоматично skip; виняток з правила ізоляції [Якір 1].
test.describe.serial() — це навмисний, явний виняток із цього правила: всередині такого блоку тести виконуються строго по порядку, в одному й тому самому worker, і якщо один тест впаде — всі наступні в цьому ж serial-блоці автоматично позначаються як skipped, а не запускаються взагалі. Це корисно, коли кроки логічно є частинами одного бізнес-флоу, наприклад покроковий checkout, де немає сенсу перевіряти "підтвердження замовлення", якщо саме замовлення не було створено — простіше явно показати залежність через serial, ніж штучно ділити це на один величезний тест. Але це варто використовувати рідко й усвідомлено, бо кожен serial-блок втрачає головні переваги звичайних тестів — паралелізм і незалежність, тому якщо serial зустрічається часто в кодовій базі, це зазвичай сигнал, що тести варто перепроєктувати на менш зв'язані одиниці.
За замовчуванням 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, але цей блок має залежності).
// Без 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)
export default defineConfig({
// Увімкнути паралелізм на рівні окремих тестів (не тільки файлів)
fullyParallel: true,
// Кількість workers не змінюється — тільки як вони використовуються
workers: process.env.CI ? 4 : undefined, // undefined = автоматично (50% CPU cores)
use: {
baseURL: 'https://staging.myshop.com',
},
});
// Можна також вимкнути fullyParallel для конкретного файлу або describe:
// Якщо 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 }) => { /* ... */ });
// Приклад: ~4000 тестів, 6 workers
//
// БЕЗ fullyParallel:
// Якщо є 10 файлів по 400 тестів — кожен файл на 1 worker
// Паралельно виконується max 6 файлів, кожен послідовно
// "Вузьке місце" = найдовший файл × кількість файлів / 6 workers
//
// З fullyParallel: true:
// Всі 4000 тестів розподіляються між 6 workers незалежно від файлів
// Workers ніколи не чекають — завжди беруть наступний тест зі спільної черги
// Теоретичний мінімум: загальний час / 6 (при ідеальному балансуванні)
// На практиці: регресія <3 год → може скоротитись до ~1.5 год
Вмикай якщо: тести добре ізольовані (окремі fixtures, немає спільного стану), файли великі і нерівномірні за розміром, хочеш максимально завантажити всі workers.
Не вмикай якщо: є багато
test.describe.serial() блоків, або тести залежать від порядку виконання — fullyParallel не зламає їх але й не пришвидшить.
fullyParallel: true — булева конфігурація; workers: process.env.CI ? 4 : undefined — тернарний оператор; undefined — означає "використай значення за замовчуванням"; test.describe.configure({ mode: 'serial' }) — виклик на вкладеному об'єкті.
fullyParallel: true прибирає це обмеження: тепер кожен окремий тест незалежно від файлу може піти на вільний worker, як задачі в черзі — workers беруть наступну задачу як тільки звільняються. Кількість workers залишається тою самою — змінюється тільки гранулярність розподілу. Якщо тести правильно ізольовані, як і має бути за Якорем 1, fullyParallel дає відчутне прискорення особливо коли файли нерівномірні за розміром. Але якщо є залежні тести в файлі — для них треба явно вказати test.describe.configure({ mode: 'serial' }), тоді цей конкретний блок буде захищений від паралельного виконання навіть при глобальному fullyParallel.
В 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(). Він не накопичується рекурсивно — він повністю замінює конкретне поле яке ти вказала, решта полів наслідується з глобального конфігу.
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();
});
// В одному файлі тестуємо і адміна і звичайного юзера
// Без 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();
});
});
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('¥');
});
});
// 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 у конфізі → системна різниця для великої групи файлів (наприклад, всі мобільні тести окремо від десктопних), або різні браузери для всього проєкту
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() змінюєш тільки locale — baseURL залишиться глобальним.
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 туди. Сторінка фізично не може існувати поза контекстом.
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();
});
browser.newContext({ storageState, viewport, locale }) — factory method: метод що створює новий об'єкт; options object з кількома полями; await context.close() — явне закриття ресурсу (важливо для уникнення memory leak).
browser — це сам екземпляр браузера. Викликаємо browser.newContext двічі — кожен раз з різним storageState, тобто різними сесіями. З кожного контексту отримуємо newPage(). Потім Promise.all запускає обидва goto паралельно — це рядок що одночасно навігує обидві вкладки. В кінці обов'язково закриваємо контексти через .close(), інакше вони залишаться в пам'яті.
Якщо кожен тест починає з логіну — це повільно і крихко. Логін займає 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 завжди відносно самого файлу, не залежно звідки запускають.
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 });
});
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json', // ← всі тести стартують авторизованими
},
dependencies: ['setup'],
},
]
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, потім вже основні тести зі вже встановленою сесією.
localStorage — це сховище в браузері де сайт зберігає дані між сесіями: токени, налаштування, кошик. У тестах часто треба встановити або перевірити localStorage — наприклад підготувати стан до тесту або перевірити що дані збереглись.
Раніше єдиний спосіб — через page.evaluate(). Playwright 1.61 додав прямий API page.localStorage.
// Встановити значення
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());
// Встановити значення — без 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();
page.localStorage — об'єкт з методами setItem/getItem/removeItem/clear/keys (Playwright 1.61+); page.evaluate(() => localStorage.setItem(...)) — старий спосіб через виконання коду в браузері; обидва підходи асинхронні — потребують await.
page.evaluate() де ми запускаємо код всередині браузера. Playwright 1.61 додав прямий API page.localStorage з тими самими методами що й нативний localStorage: setItem, getItem, removeItem, clear. Тепер не треба писати evaluate — просто await page.localStorage.setItem('key', 'value'). Обидва підходи асинхронні.
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') — "зупинись і дочекайся". Код зупиняється поки нова вкладка не з'явиться, потім продовжується і ти маєш нову вкладку як змінну. Підходить коли треба далі взаємодіяти з нею — клікати, заповнювати форму, перевіряти кілька речей.
// Підпишись і забудь — код не зупиняється, нова вкладка в callback
page.on('popup', async (popup) => {
await expect(popup).toHaveURL(/privacy/); // перевірили і все
});
await page.click('.privacy-link');
// тест продовжується далі не чекаючи нової вкладки
// 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 встановлено ДО кліку — інакше подія може прийти раніше і ти її пропустиш.
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 з усіма методами.
iframe — це HTML елемент який вбудовує одну сторінку всередині іншої. Типовий приклад: платіжна форма від Stripe або PayPal — вона відображається на вашому сайті, але фактично це окрема сторінка від платіжного провайдера всередині iframe.
Проблема: локатори за замовчуванням шукають елементи тільки в основному документі. Елементи всередині iframe — в окремому документі. Тому потрібен спеціальний підхід.
// Через 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();
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 один за одним.
Після того як ми розібрали хуки lifecycle — beforeEach і afterEach — перейдемо до іншого типу подій: нативні діалоги браузера. Alert, confirm і prompt — це спливаючі вікна які браузер показує через JavaScript. Без обробника Playwright заблокує їх автоматично і тест може зависнути.
Ключовий момент: обробник треба зареєструвати до дії яка викличе діалог — бо діалог може з'явитись миттєво під час кліку. Тому пишемо page.on('dialog', ...) першим рядком, і тільки потім клікаємо кнопку.
// Обробник ТРЕБА додати ДО дії що викликає діалог
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();
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. Тільки після того як обробник встановлено — клікаємо кнопку що викличе діалог.
Форми — один з найчастіших сценаріїв в e-commerce тестуванні: реєстрація, логін, checkout, пошук. Playwright має окремі методи для кожного типу поля: текстові inputs, dropdown/select, чекбокси, радіо-кнопки і файлові inputs.
Головне правило: для кожного типу поля є найправильніший спосіб взаємодії. Наприклад, для <select> є selectOption() — не треба клікати і вибирати вручну.
// Форма
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');
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-селектори: звідки перетягнути і куди.
До цього ми вивчали як чекати стан елемента на сторінці — toBeVisible(), toBeEnabled(). Але є клас подій які відбуваються не всередині DOM, а на рівні самого браузера. І для них звичайні assertions не працюють взагалі:
- download — юзер клікнув "Завантажити PDF". Це не елемент на сторінці, це окремий браузерний процес.
- popup — клік відкрив нову вкладку або вікно. Це нова
Pageяка ще не існувала, локатором її не знайдеш. - dialog — зʼявився нативний
alert(),confirm()абоprompt(). Це системний діалог браузера, поза DOM. - filechooser — клік відкрив системний діалог вибору файлу. Теж поза DOM.
Всі ці події — асинхронні і одноразові. Вони відбуваються один раз у відповідь на якусь дію, і якщо ти їх пропустив — назад не повернеш.
Інстинктивно хочеться написати: спочатку клікнути, потім чекати подію. Але клік може спрацювати так швидко що download вже почався і завершився ще до того як ти підписався на
waitForEvent — і Playwright її просто пропустить.Правильний патерн — завжди навпаки: спочатку підписуємось на очікування події (Promise), потім виконуємо дію яка її тригерить. Playwright починає "слухати" ще до кліку і нічого не пропустить.
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'); // може пропустити!
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();
});
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();
});
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 результату, а не навпаки.
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() замість реального вибору файлу в системному діалозі.
У попередньому питанні ми говорили про 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, помилки при ініціалізації, перші мережеві запити.
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);
});
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);
});
}
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') — пасивне спостереження. Бачимо запит, але не можемо його змінити чи заблокувати.page.route() — активне втручання. Можемо перехопити запит і повернути мок-відповідь, змінити headers, або заблокувати запит взагалі.Правило: якщо треба перевірити що запит відбувся →
page.on. Якщо треба замінити відповідь → page.route.
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(), бо події що відбулись до підписки вже не повернути.
У Playwright є три різних способи "чекати" або "слухати" щось. Вони вирішують різні задачі і працюють на різних рівнях.
1. page.on() — підписка на вбудовану Playwright подію поки не буде викликаний page.off() або поки page не закриється. Callback викликається кожного разу коли подія відбувається. Код не зупиняється.
2. page.waitForEvent() — чекає один раз на вбудовану Playwright подію і повертає значення. Код зупиняється поки подія не прийшла. Це обидва способи слухати одне і те саме — вбудовані Playwright події.
3. page.evaluate() + ручна підписка — коли треба слухати щось всередині JavaScript сторінки що Playwright взагалі не знає. dataLayer.push(), кастомні події, виклики методів — Playwright їх не бачить через CDP протокол, тому треба "зайти всередину" браузера через evaluate() і підключитись вручну.
dataLayer.push() — це не Playwright подія і не браузерна подія. Це просто виклик методу на масиві всередині JavaScript. Playwright не знає що він відбувся — бачить тільки те що видно зовні через CDP. Тому page.on() і 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
// 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() і підключити власний апарат прямо до лінії.
new Promise(resolve => {}) — створення Promise вручну: resolve це функція яку викликаємо коли хочемо "зрезолвити" Promise; підміна методу: obj.method = newFn — замінюємо оригінальну функцію своєю; orig(...args) — виклик оригінальної функції з усіма аргументами через spread; args[0]?.event — optional chaining на елементі масиву.
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 повідомляє тест коли прийшла потрібна подія.
Всі класи Playwright можна розділити на п'ять груп: ієрархія браузера, взаємодія зі сторінкою, мережа і запити, низькорівневий ввід, браузерні події. Більшість класів не створюються вручну — вони з'являються або як fixtures, або як результати методів і подій.
// ── 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 // низькорівневий ввід
// ── 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 — автоматичний аудит
// ── 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') // зберегти у вказане місце
// ── 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) // тап за координатами
// ── 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)
async method(): Promise — метод що повертає Promise; string[] — масив рядків; boolean — булевий тип.
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, або як результати методів і подій.
Коли 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() — ти приходиш і переналаштовуєш телевізор ще до того як його увімкнули.
// Напрямок: 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);
// Напрямок: Браузер → 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');
// Напрямок: 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() — ти приходиш і переналаштовуєш браузер ще до того як сторінка увімкнулась. Одноразово, але дуже рано.
await page.evaluate((arg) => { return window.fn(arg) }, nodeValue) — функція з аргументом що серіалізується через межу процесів; await page.exposeFunction('name', async (arg) => {}) — exposeFunction приймає рядок (ім'я) і async функцію; closure не працює через межу процесів.
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 — сторінка завантажується вже з нашими підміненими значеннями і ніколи не бачить "оригінальних".
page.addStyleTag() — додає <style> тег з твоїм CSS або підключає зовнішній CSS файл. Браузер одразу застосовує стилі до поточної сторінки.
page.addScriptTag() — додає <script> тег з твоїм JS або підключає зовнішній файл. Скрипт виконується в браузері після завантаження сторінки.
addInitScript()— виконується ДО завантаження сторінки. Ідеально для підміни глобальних об'єктів (Math.random,dataLayer)addScriptTag()— виконується ПІСЛЯ завантаження. Для динамічного додавання скриптів під час тестуaddStyleTag()— додає CSS в будь-який момент. Браузер одразу перераховує стилі
Найчастіший реальний кейс: addStyleTag для стабілізації скріншотів — приховати курсор, вимкнути CSS анімації і transitions, заховати елементи що мигають або динамічно змінюються.
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' });
// Замість повторювати 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 }],
});
// ⚠️ Важливо: для більшості завдань 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();
});
// ── 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 }' })
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 щоб вимикання анімацій відбувалось автоматично для всіх тестів без повторення в кожному.
В 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 перераховується.
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/);
});
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');
});
// 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({ colorScheme: 'dark' })Тестуєш print-версію сторінки →
emulateMedia({ media: 'print' })Хочеш стабільніші тести без анімацій →
emulateMedia({ reducedMotion: 'reduce' })Глобально для всього проєкту →
use: { colorScheme: 'dark' } в playwright.config.ts
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 для всього проєкту.
Уяви що тестуєш сайт який показує "Доставка доступна у вашому місті" — і визначає місто через 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).
// Сценарій: сайт показує "Доставка доступна у Лондоні" якщо юзер у Лондоні
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();
});
// Без 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 матиме ці дозволи
});
// Сценарій: акція "знижка 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 в Амстердамі і локальна машина дають однаковий результат
await context.setGeolocation({ latitude: 51.5, longitude: -0.1 }) — options object з числовими полями; await context.grantPermissions(['geolocation']) — масив рядків; timezoneId: 'Europe/Kyiv' — IANA timezone string; всі налаштування на рівні context.
BrowserContext дозволяє встановити все це явно при створенні: передаємо geolocation з конкретними координатами, permissions з масивом дозволів які вже "видані", і timezoneId у форматі IANA назви timezone. Важливо розуміти чому саме context, а не page: context — це "профіль браузерного вікна", одне вікно має одну геолокацію і один timezone для всіх своїх вкладок. Якщо треба протестувати два різних міста паралельно — створюємо два різних context, кожен зі своєю page, і вони абсолютно ізольовані одне від одного, бо це Якір 1 — ізоляція через context. На практиці для великих multi-domain e-commerce проєктів зручніше встановити timezoneId і locale в playwright.config.ts на рівні всього проєкту — тоді результати тестів однакові незалежно від того де фізично запущений CI-сервер.
Уяви що тестуєш функцію "сесія закінчується через 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 процес (де живе твій тест) продовжує жити в реальному часі — це не проблема, бо тест сам керує перемоткою.
// Сценарій: сесія закінчується через 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('Ваша сесія закінчилась');
});
// Сценарій: банер "Акція діє ще 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('Акція закінчилась');
});
// Є три способи керувати часом, і вони різні:
// 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 виклики що відбулись під час завантаження сторінки вже пройшли з реальним часом, і мокання буде неповним.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.
page.clock вирішує це елегантно: він повністю замінює годинник всередині браузера на підконтрольний нам. Ми встановлюємо фіксований час через clock.install() до того як заходимо на сторінку, і після цього браузер думає що зараз саме цей час — new Date() повертає його, всі setTimeout і setInterval відраховують від нього. Потім ми викликаємо fastForward('31:00') — і Playwright миттєво просуває час на 31 хвилину, виконуючи всі таймери які мали б спрацювати за цей період, без жодного реального очікування. Є три методи керування: fastForward і tick — обидва просувають час і виконують таймери, різниця лише в форматі аргументу; setSystemTime просто зсуває годинник без виконання таймерів — корисно коли треба лише щоб Date.now() повертав інше значення. Ключовий нюанс — install() завжди до goto(), бо інакше частина ініціалізаційного коду сторінки вже виконалась з реальним часом.
У браузері насправді два незалежні годинники. 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 мс, а третя лишилась близько нуля — маєш саме цей випадок: механічний годинник підмінено, анімаційний живе своїм реальним часом.
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');
Lazy loading — контент завантажується тільки коли потрапляє в зону видимості. Virtualized lists — в DOM рендериться тільки видима частина (наприклад 10 рядків з 1000). Обидва патерни ламають наївний підхід "знайди всі елементи і порахуй" — бо в DOM їх просто немає поки не проскролити.
// 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();
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.
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 функціональність.
export default defineConfig({
use: {
// 'block' — Service Workers не реєструються взагалі
// Всі мережеві запити йдуть напряму, page.route() і page.on('request') працюють нормально
serviceWorkers: 'block',
// 'allow' — за замовчуванням, Service Workers працюють як в реальному браузері
// serviceWorkers: 'allow',
},
});
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();
});
// Якщо треба ТЕСТУВАТИ саму 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()
serviceWorkers: 'block' — рядкове enum значення в конфігурації; page.on('worker', worker => {}) — підписка на появу worker; navigator.serviceWorker.ready.then(() => true) — Promise chain у браузерному коді; context.setOffline(true) — булевий аргумент.
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 коректно відповідає з кешу.
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() — детально в наступній картці.
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();
});
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();
});
context.setOffline(true) — метод на рівні контексту (не page); await page.reload() — перезавантаження сторінки; page.request.get(url) — HTTP запит через Playwright без браузера; manifestUrl! — non-null assertion в TypeScript: кажемо компілятору що значення точно не null.
context.setOffline(true) для симуляції офлайн режиму — завантажуємо сторінку онлайн щоб Service Worker скешував ресурси, потім вимикаємо мережу і перевіряємо що сторінка все одно показує контент. Також можна перевірити наявність і вміст Web App Manifest через звичайний HTTP запит і DOM перевірку наявності link[rel="manifest"].
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() — можна перекривати глобальні роути локальними.
// Замінити відповідь 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());
});
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', ...) — лише спостереження без зміни запиту.
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().
// Реальний сервер не викликається — відповідь повністю вигадана
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');
// Сторінка отримала наш мок — завжди одні й ті самі дані, стабільно
// Запит іде на реальний сервер, але з нашими зміненими 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',
},
});
});
// "Людина посередині": запит пішов на сервер, відповідь прийшла до нас,
// ми її змінили і віддали браузеру
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), // модифіковане тіло
});
});
// Блокуємо всі запити до аналітики — тести не залежать від сторонніх сервісів
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' })
);
// Різні правила для різних 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()
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 який збігається.
Без перехоплення відповідь іде напряму від сервера в браузер — ти її не бачиш і не можеш змінити. Щоб змінити відповідь — треба спочатку "забрати" її собі, а потім явно віддати браузеру.
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 — там показано що запит був перехоплений і яку відповідь отримав браузер.
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), // наше модифіковане тіло
});
});
// Реальний кейс: сервер не завжди повертає тестовий продукт
// Якщо є — пропускаємо без змін. Якщо немає — підміняємо моком.
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().
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 перехопив запит але так і не дав відповідь. Думай про це як "людина посередині": запит пішов на сервер, відповідь прийшла до нас, ми її прочитали і змінили, і тільки потім передали браузеру.
Якщо одна і та сама логіка перехоплення потрібна в багатьох тестах — не треба повторювати її в кожному. Виноситься в auto fixture в базовому файлі. Fixture спрацьовує автоматично для кожного тесту який імпортує test з цього файлу.
Два варіанти: auto: true — для всіх тестів автоматично. Без 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 = для всіх тестів автоматично
});
// ✅ Варіант 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 — логіка потрібна тільки в конкретних тестах або файлах
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 в параметрах тих тестів де він потрібен.
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 де відповідь має специфічну структуру яку важко відтворити вручну.
// Цей тест запускаємо ОДИН РАЗ щоб записати реальні відповіді
// 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 — запит + відповідь
});
// Тепер 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();
});
// За замовчуванням якщо запит не знайдений в 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 дозволяє тестам продовжувати працювати
// 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');
});
1. Запускаєш тест з
update: true один раз — HAR файл записаний2. Комітиш HAR файл у репозиторій разом з тестами
3. В CI тести використовують HAR — жодних реальних запитів до зовнішніх API
4. Коли API змінилось — перезаписуєш HAR: знову запускаєш з
update: trueHAR файл — це "знімок API" який живе в git разом з кодом тестів.
await page.routeFromHAR('file.har', { update: true, url: '**/api/**' }) — options object; { update: true } — булевий прапор; notFound: 'fallback' — рядкове enum; glob pattern **/api/** — wildcard для URL matching.
page.routeFromHAR() має два режими. З update: true — це запис: Playwright робить реальні мережеві запити і паралельно зберігає їх у файл. Цей режим запускається один раз. Без update: true — це відтворення: Playwright перехоплює мережеві запити і замість реальних викликів повертає збережені відповіді з файлу, тобто зовнішнє API взагалі не викликається. Головна перевага перед ручним page.route() з fulfill() в тому що не треба описувати мок-відповідь вручну — HAR автоматично зберігає реальну відповідь з усіма деталями. Особливо цінно для складних API типу платіжних шлюзів де структура відповіді складна і важко відтворити її руками. Workflow простий: записуєш HAR один раз, комітиш у репозиторій, і тепер CI-тести не залежать від зовнішніх сервісів. Коли API змінилось — перезаписуєш HAR знову з update: true.
WebSocket — це постійне двостороннє з'єднання між браузером і сервером. Використовується для real-time даних: чат, live ціни, сповіщення. Є два підходи: спостерігати за реальним WebSocket через page.on('websocket'), або перехоплювати і мокати через page.routeWebSocket() — новий API з Playwright 1.48+.
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');
});
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.
page.on('websocket', ws => {}) — підписка на подію; ws.on('framereceived', event => {}) — вкладена підписка; page.routeWebSocket(url, handler) — перехоплення WebSocket з'єднання (Playwright 1.48+); ws.onMessage() — слухаємо повідомлення від браузера; ws.send() — надсилаємо повідомлення браузеру.
page.on('websocket'): підписуємось на події ДО навігації, отримуємо об'єкт ws і слухаємо framereceived — кадри від сервера. Другий підхід — page.routeWebSocket() доданий в Playwright 1.48: перехоплює WebSocket з'єднання до вказаного URL і дає повний контроль — можна слухати повідомлення від браузера через ws.onMessage() і надсилати мок-відповіді через ws.send(). Реальний сервер не потрібен. Також доступно на рівні context через context.routeWebSocket() — спрацьовує для всіх сторінок в контексті.
Не все треба тестувати через браузер. Якщо потрібно перевірити що 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.
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! },
});
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 є числом будь-якого значення.
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.
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' }] }
});
});
// Використовується коли потрібно перехопити подію ДО того як 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' });
});
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 спрацює.
Тест падає в CI але локально проходить — одна з найнеприємніших ситуацій. Зазвичай причина в різниці середовища: інший viewport, швидкість мережі, headless режим без GPU, або різний порядок при паралельному запуску.
Режими trace — важливо знати всі:
trace: 'on'— записує для кожного тесту (повільно, для дебагу)trace: 'retain-on-failure'— записує для всіх, видаляє для успішнихtrace: 'on-first-retry'— найпоширеніший у production: записує тільки при першому retry. Не навантажує CI при нормальному прогоніtrace: 'off'— вимкнено
// 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: записує твої дії в браузері як код ────────────────
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-снепшоти і фільтри по тегах.
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();
});
PWDEBUG=1 npx playwright test — встановлення environment variable перед командою; --headed / --slowmo — CLI флаги; trace: 'on' — рядкове enum значення в конфіг об'єкті.
trace: 'retain-on-failure' зберігає Playwright trace тільки для тестів, що впали, і його можна відкрити локально командою show-trace — це дає повний timeline, мережеві запити, DOM-снепшот до і після кожної дії, консольні логи, без потреби перезапускати щось у CI. Якщо я підозрюю конкретний момент у тесті, додаю page.pause() — це відкриває Playwright Inspector і дозволяє покроково виконувати дії вручну, дивлячись, що саме відбувається на сторінці. Найчастіші причини розбіжності CI проти локального — це швидкість рендеру, бо CI-контейнер повільніший, інший viewport за замовчуванням, або відсутні env-змінні, які локально підхоплюються з .env, а в CI мають бути явно прописані як secrets.
Playwright вміє автоматично робити скріншоти і записувати відео при падінні тесту — це налаштовується в конфізі. Але іноді потрібно зробити скріншот вручну в певний момент тесту — наприклад щоб прикріпити до звіту або порівняти візуально.
Важливо про відео: файл відео фіналізується після закриття BrowserContext, тому він стає доступним тільки після завершення тесту. Під час виконання тесту відео ще не є готовим файлом.
fullPage: true — робить скріншот всієї сторінки включаючи частини поза viewport (потрібен скрол для перегляду). Без цього параметру знімається тільки видима область. Використовується для довгих сторінок або коли потрібно захопити весь контент.
// Скриншот вручну
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
{ 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.
Flaky тест — тест який то проходить, то падає без змін в коді. Причини: race condition, залежність від зовнішнього сервісу, нестабільне середовище.
Важливо: retries не виправляють flaky тести — вони лише дозволяють їм пройти повторно. Це механізм підвищення стабільності CI, а не спосіб усунення першопричини. Flaky тести необхідно аналізувати і виправляти. Retries використовуються як тимчасове рішення або для нестабільних зовнішніх залежностей.
Ізоляція при retry: перед кожною повторною спробою Playwright створює новий BrowserContext — це гарантує що retry виконується в повністю ізольованому середовищі, без стану від попередньої спроби.
// Глобально в 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 () => { /* */ });
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.
Race condition — це коли результат програми залежить від того в якому порядку виконались дві дії, і цей порядок непередбачуваний.
Простий приклад: два тести одночасно читають і змінюють одну змінну. Обидва прочитали 0 і обидва записали 1. Хто встиг другим — перезаписав результат першого. Очікувалось 2, отримали 1.
У тестах race condition — головна причина flaky тестів. Тест то проходить, то падає — залежно від того наскільки швидко спрацював 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' або не вийде таймаут
// ❌ 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'), // потім клікаємо
]);
Як уникати в Playwright:
— Замість
await locator.textContent() → await expect(locator).toHaveText()— Замість "клік потім слухай" →
Promise.all([слухай, клікай])— Замість
waitForTimeout(1000) → waitForSelector() або web-first assertion
Promise.all([p1, p2]) — запускає два Promise одночасно і чекає обидва; порядок в масиві визначає порядок підписки але не порядок виконання; const [newPage] = await Promise.all([...]) — деструктуризація масиву результатів; race condition — це про непередбачуваний порядок асинхронних операцій.
Коли тест падає — 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 — це не спосіб "врятувати" тест від падіння. Це спосіб сказати "ця конкретна помилка є очікуваною і я знаю як її обробити". Якщо ти ловиш помилку і тест йде далі як ні в чому не бувало — це майже завжди антипатерн.
// 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() викликали занадто рано?
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();
});
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);
});
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; // решту перекидаємо без змін
}
});
// ❌ Антипатерн 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 (e) { if (e instanceof errors.TimeoutError) { ... } else { throw e; } } — try/catch з instanceof перевіркою типу; instanceof — оператор перевірки типу об'єкту; throw e — повторне кидання помилки; new Error(\`message\`) — створення Error з template literal.
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.
Реальний кейс з E-commerce PLP: тест підрахунку товарів на PLP нестабільно падав в CI. Проблема виявилась у virtualized list — DOM рендерить тільки видиму частину, тому locator.count() повертав різні числа залежно від стану скролу. Рішення: перейти від DOM-count до GTM dataLayer як source of truth.
// ❌ Було: рахуємо елементи в 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 фіксує повний список незалежно від рендеру
flaky — нестабільний тест (то проходить то падає); virtualization — рендеринг тільки видимих елементів; dataLayer — масив подій Google Tag Manager; source of truth — єдине надійне джерело даних.
.product-tile елементи напряму, був недетермінованим — результат залежав від того, скільки встигло відрендеритись. Рішення: замість перевірки DOM я перехоплюю dataLayer.push через page.addInitScript ще до завантаження сторінки, підміняючи метод push так, щоб кожна подія додатково передавалась у Playwright через exposeFunction. GTM фіксує бізнес-подію plp_view з повним масивом items одразу при завантаженні, незалежно від того, що зараз у viewport. Це дало стабільний source of truth і прибрало клас flaky-тестів повністю, а не одну симптоматичну дефектну точку.
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-експертів.
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');
});
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) і викликаємо методи.
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 дає якусь ізоляцію чи окремий таймаут.
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 рядків... важко зрозуміти що відбувається
});
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();
});
});
// Бонус: та сама структура у звіті при падінні:
// ✅ Логін
// ✅ Додати товар
// ❌ Оформити замовлення ← одразу видно де впало
await test.step('назва', async () => {}) — test.step приймає рядок і async callback; всі дії всередині callback — це логічний крок; вкладені степи — callbacks всередині callbacks.
test.step() — це про структуру коду всередині тесту. Великий тест з багатьма рядками розбивається на іменовані логічні блоки — одразу видно що відбувається і в якому порядку: спочатку логін, потім додавання товару, потім оформлення. Код виконується однаково, але читати набагато простіше. Бонус: ця структура автоматично відображається у звіті і trace — при падінні одразу видно який крок впав, без читання всього stack trace.
Data-driven тестування — це коли логіка тесту одна, але він запускається з різними вхідними даними. Замість копіювати один тест п'ять разів змінюючи тільки SKU або email — описуємо масив тестових випадків і генеруємо тести автоматично. Додати новий випадок = додати один рядок в масив.
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 }) => { /* ... */ });
}
const testData = [{ ... }, { ... }] — масив об'єктів; testData.forEach(({ sku, name }) => { test(...) }) — forEach з деструктуризацією параметра callback; динамічна генерація тестів: test() викликається всередині forEach — тести створюються під час збору (collection phase).
for...of цикл на TypeScript рівні — не Playwright-специфіка. Всередині циклу викликаємо test() з template literal назвою, куди підставляємо поточне значення з масиву. Playwright зареєструє стільки тестів скільки елементів у масиві — кожен з унікальною назвою. Це простіший підхід ніж параметризовані тести в інших фреймворках.
Test data governance — це система правил яка гарантує що тести використовують тільки безпечні тестові дані і не впливають на production. В e-commerce це критично: тест може випадково списати реальні гроші або зіпсувати реальне замовлення якщо використає неправильний SKU або токен.
// Проблема: тести падали, бо 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 перед використанням у тестах`
);
}
}
const BLOCKED_SKUS = new Set(['sku1', 'sku2']) — Set зі списку рядків; BLOCKED_SKUS.has(sku) — O(1) пошук в Set; if (!BLOCKED_SKUS.has(sku)) { ... } — заперечення boolean; allowlist vs blocklist — патерни дозволу/заборони.
BlockedProductList у зовнішньому PIM: список SKU, зарезервованих за конкретними suite, з полем lockedFields, яке каже, які саме атрибути товару не можна чіпати. Функція assertProductIsGovernanceLocked запускається до того, як тест почне використовувати товар, і явно фейлить з людським повідомленням, якщо SKU не зареєстрований — це перетворює мовчазний flaky-фейл на явну й зрозумілу помилку конфігурації, яку легко виправити.
Не всі тести треба запускати завжди. 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+. Чистіше і правильніше семантично.
// Теги в назві тесту (офіційний підхід з 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 год)
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 — інвертує: запускаємо все крім позначеного.
Коли є велика кодова база тестів, виникають ситуації які не вкладаються в бінарну логіку "тест проходить / тест падає": є відомий баг і тест правильно його описує але ми не хочемо його видаляти; тест нестабільний тільки на певній ОС; тест займає 3 хвилини і Playwright вбиває його раніше часу; тест описує поведінку яка ще не реалізована. Для всіх цих випадків є анотації — це не просто "вимкнути тест", кожна несе конкретну семантику і по-різному відображається у звіті Allure/HTML.
test.skip()— тест пропускається, у звіті позначається як "skipped". Використовується коли тест тимчасово нерелевантний: фіча вимкнена для певного середовища, або тест залежить від чогось що ще не готове. Можна передати умову і причину.test.fixme()— схожий на skip, але семантика інша: "цей тест зламаний і потребує виправлення". У звіті позначається окремо. Це сигнал команді що тут є технічний борг який треба вирішити.test.fail()— найцікавіший і найменш очевидний. Каже Playwright: "я знаю що цей тест зараз падає, і це очікувано". Якщо тест впав — все ок, "expected failure". Але якщо тест раптом пройшов — це теж фейл, бо означає щось змінилось і анотацію треба прибрати.test.slow()— не пропускає і не фейлить тест. Просто каже Playwright що цей тест повільний — і Playwright автоматично множить test timeout на 3. Не треба вручну рахувати скільки часу потрібно.
// Простий 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 — окремий тікет');
// ...
});
// 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');
// ...
});
// 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" — баг пофіксили, прибери анотацію!
// 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()
test.skip(condition, 'reason') — виклик з булевою умовою і рядком; test.fail() — виклик без аргументів; process.env.CI — environment variable; ({ browserName }) => browserName === 'firefox' — стрілочна функція з деструктуризацією що повертає boolean.
test.skip() пропускає тест і позначає його як "skipped" у звіті — він може приймати умову, наприклад isMobile або browserName === 'firefox', що робить його ситуативним а не глобальним. test.fixme() семантично відрізняється: це не "нерелевантно", а "зламано, потребує уваги" — окреме позначення у звіті нагадує команді що є технічний борг. test.fail() — найнетиповіший: він говорить "цей тест очікувано падає", і Playwright навпаки зафейлить його якщо він раптом пройде — це механізм автоматичного сповіщення коли баг пофіксили але анотацію забули прибрати, дуже корисно для документування відомих проблем. test.slow() не змінює результат тесту взагалі — просто множить test timeout на три, щоб Playwright не вбивав повільний але правильний тест раніше часу, без необхідності вручну рахувати скільки саме секунд потрібно.
Ми розібрали основні концепти 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 яка постачається разом з Playwrightchannel: '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 відкриває інтерактивний режим де можна бачити тести і запускати їх вручну.
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' } },
],
});
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 звіт
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 відкриває інтерактивний режим.
Playwright за замовчуванням завантажує власну збірку Chromium — браузерний рушій без Google-специфічних доповнень. Іноді тест потребує саме реального Google Chrome: якщо функціональність залежить від Google-акаунту, DRM або Chrome розширень.
channel: 'chrome' каже Playwright використати Chrome який вже встановлений на машині замість власного Chromium. Різниця: версія, поведінка з деякими API і доступ до Google-сервісів.
// За замовчуванням: 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' } },
]
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.
Playwright запускає тести паралельно. Якщо auth.setup.ts і product.spec.ts запустяться одночасно — продуктовий тест почне виконуватись до того як логін завершився і файл збережений. Тест впаде бо user.json ще не існує.
Рішення — два окремих проєкти в конфізі з явним порядком через dependencies.
Що таке проєкт в конфізі: це група тестів з певними налаштуваннями. Зазвичай це браузер — chromium, firefox, webkit. Але проєкт може бути і для інших цілей — наприклад тільки для запуску setup файлу.
testMatch — говорить "цей проєкт запускає тільки файли що відповідають цьому паттерну".
dependencies — говорить "не починай поки не виконається цей проєкт". Це гарантія порядку.
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 — це замок на дверях ресторану який не відчиняється поки кухня не готова.
dependencies: ['setup'] — масив рядків як список залежностей; testMatch: '**/auth.setup.ts' — glob паттерн: ** означає "в будь-якій папці"; конфігурація через масив об'єктів де кожен об'єкт — окремий проєкт з власними налаштуваннями.
setup — має testMatch що вказує тільки на auth файл, тобто запускає виключно логін. Другий — chromium — має dependencies: ['setup'], що означає "не починай поки setup не завершився", і storageState що вказує на збережений JSON файл. Playwright сам дотримується цього порядку: спочатку повністю виконує setup проєкт, зберігає сесію, і тільки потім запускає основні тести — кожен з яких вже починає залогіненим.
Є два способи виконати авторизацію один раз перед всіма тестами: історично перший — через globalSetup файл, сучасний — через dependencies між проєктами в конфізі. Важливо: globalSetup не deprecated — він досі повністю підтримується і має свої сценарії (наприклад підняти БД чи зовнішній сервіс до старту раннера). Але для авторизації Playwright Team зараз рекомендує project dependencies — вони гнучкіші і мають доступ до всіх можливостей Playwright включно з fixtures, trace і звітами.
// Історичний підхід: 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' });
});
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-файлом.
Коли говоримо "окрема машина" в контексті 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 контейнери на одному сервері — вони все одно ізольовані і не впливають один на одного.
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 #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
$CI_NODE_INDEX і $CI_NODE_TOTAL — bash змінні оточення які GitLab встановлює автоматично; parallel: 4 — YAML конфігурація GitLab CI; кожен runner отримує окремий $CI_NODE_INDEX від 1 до N.
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.
CI/CD pipeline автоматично запускає тести при кожному push або merge request. Для Playwright в GitLab CI треба вирішити кілька питань: де зберігати браузери між запусками (кешування), як запускати тести паралельно (sharding), і як зберігати артефакти при падінні (trace, screenshots).
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/]
extends: .playwright-base — наслідування конфігурації; --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL — template string у bash з env variables; паралелізм через matrix strategy.
stages — масив етапів що виконуються послідовно. variables — глобальні змінні середовища. Джоб install: cache зберігає node_modules і браузери між запусками по ключу гілки. script — список команд. Джоб e2e:smoke: parallel: 4 — GitLab запустить 4 копії цього джобу, кожна отримає змінні CI_NODE_INDEX і CI_NODE_TOTAL. Ці змінні підставляємо в --shard, тоді кожен джоб запускає свою чверть тестів. artifacts.when: always — зберігаємо звіти навіть якщо тести впали.
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 ділять тести між процесами на одній машині. Можна використовувати разом або окремо.
# Запускаємо першу чверть всіх тестів
npx playwright test --shard=1/4
# Запускаємо другу чверть
npx playwright test --shard=2/4
# Кожна команда виконується на своїй машині паралельно
# Playwright сам рівномірно розподіляє тести між shards
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 паралельних тести одночасно
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
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.
--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.
// 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
--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.
Після того як тести виконались — потрібно щось читати. Не лог в терміналі де все змішалось, а структурований звіт де можна побачити що впало, чому, з яким скріншотом і на якому кроці. 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 для стейкхолдерів. Кожен отримує свій формат без додаткових запусків.
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'
},
});
# Встановлення
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
// Щоб 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' }],
],
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 + htmlCI pipeline →
dot + junit (тихо в логах, результати в GitLab test summary)Стейкхолдери →
allure-playwright з S3 history (тренди, графіки, розбивка по suite)Дебаг конкретного падіння →
trace: 'retain-on-failure' + npx playwright show-trace
reporter: [['list'], ['html', { outputFolder }], ['junit', { outputFile }]] — масив масивів: кожен елемент це [назва, опції]; process.env.ENV || 'staging' — logical OR як default value; testInfo.attach('name', { body, contentType }) — options object.
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 звіті прямо поруч з тестом, що сильно прискорює дебаг.
Вбудовані репортери Playwright (html, junit, allure) покривають більшість потреб. Кастомний Reporter потрібен коли треба надсилати результати в специфічну систему — Slack, Jira, власний дашборд — або формувати звіт у нестандартному форматі.
Reporter — це клас який імплементує інтерфейс Reporter з методами-хуками: onBegin, onTestEnd, onEnd. Playwright викликає їх автоматично.
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') }
}] : [],
}),
});
}
}
reporter: [
['html'],
['allure-playwright'],
['./reporters/SlackReporter'],
]
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.
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) завжди відрізнятимуться. Їх треба маскувати або стабілізувати.
// Вбудований 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
},
},
});
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.
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 його не побачить.
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]"
`);
new AxeBuilder({ page }).withTags([...]).analyze() — builder pattern: кожен метод повертає той самий об'єкт для chaining; await тільки перед analyze() — він повертає Promise; { violations } — деструктуризація результату; violations.length — властивість масиву.
@axe-core/playwright. Створюємо екземпляр AxeBuilder передаючи page, потім через builder pattern налаштовуємо: withTags(['wcag2a', 'wcag2aa']) — перевіряємо тільки ці стандарти WCAG. analyze() — запускає перевірку і повертає результат. Перевіряємо що масив violations порожній. toMatchAriaSnapshot — вбудований в Playwright метод: знімає ARIA-дерево елементу і порівнює з template literal, де дефіси це рівні вкладеності ролей.
Playwright дає доступ до браузерних performance метрик через Chrome DevTools Protocol. Можна вимірювати Web Vitals — LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), FID — прямо в тестах і перевіряти що вони не погіршуються між релізами.
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);
});
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 — отримаємо і вже минулі події. Після секунди резолвимо проміс з зібраними метриками і перевіряємо порогові значення через звичайні матчери.
Більшість знають toHaveScreenshot() для візуальних регресій. Але є ще toMatchSnapshot() — для будь-яких даних: JSON, текст, HTML рядки.
Ідея проста: перший запуск — Playwright зберігає "еталон" у файл поруч з тестом. Кожен наступний запуск — порівнює з еталоном. Якщо відрізняється — тест падає і показує diff де саме.
Навіщо для JSON: API повертає складний об'єкт або структуру dataLayer події. Замість двадцяти expect(event.field).toBe(value) — зберігаємо весь об'єкт як еталон і порівнюємо цілком. Якщо хтось випадково змінить структуру — тест одразу покаже що саме змінилось.
toEqual()— ти вручну описуєш очікуване значення прямо в коді тестуtoMatchSnapshot()— очікуване значення зберігається у зовнішньому файлі. Playwright генерує його сам при першому запуску
Оновлення еталону: коли структура навмисно змінилась — запускаєш з --update-snapshots і Playwright перезаписує файл еталону.
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 якщо щось змінилось
});
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
// 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 бачить новий еталон → тест проходить
Корисний: складна структура з багатьма полями (GTM події, API відповіді, мета-теги), HTML шаблони листів або PDF прев'ю, конфігураційні JSON де важлива кожна деталь
Не корисний: прості значення де ліпше toEqual(), дані що часто змінюються навмисно (ціни, дати), дані з id або timestamp які різні кожного разу — спершу треба їх відфільтрувати
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.
Платформа з 45 країновими доменами — це 45 варіантів мови, валюти, формату дат і URL структури. Тестувати кожен домен вручну нереально. Data-driven підхід: описуємо конфігурацію кожного домену в масиві і генеруємо тести автоматично.
// 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);
});
}
const domains: DomainConfig[] = [{ ... }, { ... }] — TypeScript масив з типом; domains.forEach(({ country, locale, currency, domain }) => { test(...) }) — forEach з деструктуризацією вкладеного об'єкту; data-driven pattern: дані окремо від логіки тесту.
lang-атрибута в окремий масив DomainConfig і прогоняю один і той самий сценарій data-driven циклом, де для кожного домену створюється власний BrowserContext з потрібним locale, бо locale впливає на формат дат і чисел, які повертає браузер. У кожній ітерації перевіряю не тільки видимий контент, а й технічні речі, важливі для SEO та a11y: атрибут lang у тегу html має відповідати мові домену, бо це впливає на screen readers і пошукову індексацію. Окремо це покриває валюту — текст ціни має містити правильний currency code чи символ для регіону. За межами цього прикладу команда ще окремо перевіряє hreflang-теги для SEO, коректність geo-IP редиректів при заході з різних країн, і що важливо для нашої GTM-інтеграції — event names в dataLayer завжди лишаються англійською незалежно від домену, а локалізується тільки контент, який бачить користувач, бо аналітика має бути уніфікованою для всіх ринків.
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.
// 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 прокидає виклик через міст
});
@playwright/experimental-ct-react — окремий пакет, не @playwright/test; mount — fixture що повертає Locator на змонтований компонент; JSX прямо в тесті — тому файл має розширення .tsx; onSelect={(size) => selected.push(size)} — колбек визначений у Node.js але викликається з браузера: Playwright серіалізує виклик через свій міст (те саме обмеження серіалізації що й у evaluate).
Реальний кейс: після оновлення Node.js з v20 до v24 CI pipeline почав падати з незрозумілими помилками. Проблема виявилась у несумісності ES modules і CommonJS в helper скриптах, плюс зміна в обробці environment variables. Вирішення — поетапне: спочатку ізолювати причину, потім конвертувати скрипти.
// Проблема 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;
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.
require(), оскільки для разових helper-задач це простіше й надійніше за дебаг ESM top-level await. По-друге, формат маскування GitLab CI variables змінився, і токен, який раніше читався з CI_JOB_TOKEN, почав приходити в неправильному форматі — перейшла на явну protected variable з перевіркою на undefined перед використанням, щоб помилка падала одразу з зрозумілим повідомленням, а не на кроці автентифікації. По-третє, в інтеграції PROJ-1102 — автостворення GitLab issues з синком у Jira — назви лейблів розійшлись між системами, тому я зробила єдиний LABEL_MAP як shared mapping-шар, щоб зміна назви в одному місці не ламала інтеграцію.
Це баланс між швидкістю доставки нових фіч і впевненістю що релізи стабільні. Надто багато тестів уповільнюють pipeline і блокують розробку. Надто мало — пропускають баги в production. Стратегія: тести за рівнями ризику з різними тригерами запуску.
// Рівні захисту замість "запускати все на кожен 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 команди
};
@smoke @critical @regression — tag-based strategy; метрики: AIRA score, pass rate, MTTR.
Онбординг 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 проставлені коректно',
];
const config: TestConfig = { ... } — TypeScript interface для конфігурації; interface TestConfig { maxLines: number; requirePatterns: string[] } — оголошення interface; PR checklist як structured object; string[] — масив рядків.