← Valtive QA Quiz quiz.valtive.io/notes/playwright valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA інженерів які готуються до технічної співбесіди на позицію з Playwright. Підходить якщо ти вже знаєш основи і хочеш систематизувати знання — від базових концептів до архітектурних рішень Senior рівня.
For QA engineers preparing for a technical interview on a Playwright role. It fits if you already know the basics and want to put them in order — from the core concepts up to Senior-level architecture decisions.
Як користуватисьHow to use it
Фільтруй питання по рівню — Junior, Middle або Senior. Відкривай картку, читай концепт і код. Потім закрий код і прочитай вголос з блоку 🗣 Як читати вголос — це тренує відповідь в живій розмові, а не тільки розуміння очима.
Filter the questions by level — Junior, Middle or Senior. Open a card, read the concept and the code. Then hide the code and say the answer out loud from the 🗣 How to say it out loud block — that trains the answer as a live conversation, not just recognition on the page.
Три базові якорі — з них випливає більшість відповідейThree anchors — most of the answers follow from them
ЯКІР 1 · ІзоляціяANCHOR 1 · Isolation
Browser → Context → Page. Кожен тест в своєму контексті зі своїми cookies і станом.
Browser → Context → Page. Every test runs in its own context, with its own cookies and state.
ЯКІР 2 · Auto-waitingANCHOR 2 · Auto-waiting
Locator — це рецепт, не посилання. Дії самі чекають готовності елемента.
A locator is a recipe, not a reference. Actions wait for the element to be ready on their own.
ЯКІР 3 · ЯвністьANCHOR 3 · Explicitness
Strict mode, теги, явний стан. Явне завжди краще за неявне.
Strict mode, tags, explicit state. Explicit always beats implicit.
—
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
TypeScript
e-commerce
📚 10 РОЗДІЛІВ📚 10 SECTIONS
—
Junior
—
Middle
—
Senior
0 / 0 · розгорни картку, щоб зарахувати їїopen a card to count it
▶ 🧭 3 базові якорі — з них випливає все інше🧭 Three anchors — everything else follows from them
Якір 1 — Ізоляція: Browser → Context → Page → WorkerAnchor 1 — Isolation: Browser → Context → Page → Worker

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

Picture a real computer with a browser on it. You open Chrome — that is Browser. Inside Chrome you can open a normal window and a separate incognito window; they cannot see each other's cookies or state. That separate window is BrowserContext. And one tab inside that window is Page.

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

For every test Playwright automatically creates a fresh BrowserContext (its own "incognito window") and a single Page inside it. So test A, logged in as admin, has no effect on test B testing guest mode — they physically have different browser sessions, different cookies, a different localStorage.

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

Worker is a separate layer. When Playwright runs tests in parallel it does not merely parallelise tabs — it starts separate Node.js processes (workers). It is like opening two completely different Chromes on two different computers. So let count = 0 in one file lives only in the memory of its own process and is entirely unreachable from another.

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

Everything follows from this: why fixtures exist (Playwright assembles the isolated structure itself and hands it over ready), why storageState exists (to carry a logged-in session "through" the isolation you have to save it to a file physically), why a global variable is not shared between files (separate Node processes, separate memory), and why describe.serial() is a deliberate exception where we knowingly break isolation for a dependent flow.

Якір 2 — Auto-waiting: Locator це рецепт, не посиланняAnchor 2 — Auto-waiting: a locator is a recipe, not a reference

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

When you write page.locator('.buy-button'), Playwright does not do a DOM lookup at that moment. It remembers the instruction "find the element with class buy-button". The lookup happens only during an operation — when you call an action: .click(), .fill(), .toBeVisible(). And every single time — from scratch.

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

Why does that matter? Because the page is alive: React can re-render a list, the DOM can change between two lines of your test. If Playwright found the element straight away and kept a reference to that particular DOM node (which is what ElementHandle does), the reference would go stale after a re-render and point at a node that is no longer there. A locator does not have that problem, because it finds the element again right before the action.

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

More than that — right before performing the action, Playwright waits for the element to become ready on its own: visible, not covered by another element, not disabled, stable (not animating). That is auto-waiting — you write no wait at all, Playwright simply does not perform the action until the conditions hold.

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

From this it follows: waitForTimeout(2000) is an anti-pattern, because you wait out a duration on luck instead of a specific state. expect(locator).toBeVisible() is a web-first assertion that retries itself, unlike expect(await locator.isVisible()).toBe(true), where you take a snapshot of the state and check a frozen true/false with no retries at all. networkidle is an anti-pattern too: you wait for an abstract "quiet network" instead of a specific element or API response.

Якір 3 — Явність замість магіїAnchor 3 — Explicit instead of magic

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

As a framework Playwright keeps making the same choice over and over: better an explicit error now than a hidden problem later. That is a philosophy, not a technical detail.

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

Strict mode: when Selenium finds 10 buttons for your selector it silently takes the first one and moves on. The test is "green", but you clicked the wrong thing. Playwright instead fails immediately with "found 12 elements" — it forces you to be specific about which element you actually mean.

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

Tags @smoke/@critical/@regression: instead of magically deciding "which tests to run now", you tag every test explicitly and say so explicitly in the CI command: --grep @smoke. Nothing implicit.

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

In essence all three anchors are one thought seen from different sides: Playwright builds a system where every thing is explicit, isolated and waits for the right state, instead of relying on luck, timing or "default" behaviour. Once that has sunk in, most interview questions stop being separate facts and become obvious consequences of that one idea.

▶ 1 9
1 Що таке Playwright і чим він відрізняється від Selenium? What is Playwright and how is it different from Selenium?
ОсновиBasics Junior ▶

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

Playwright is an open-source framework from Microsoft for end-to-end testing of web apps in a browser. It supports Chromium, Firefox and WebKit behind one API. It appeared in 2020 and became a standard fast, because it is simple and it is reliable.

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

The main technical difference is how the browser is driven:

Selenium + WebDriver:

Selenium + WebDriver:

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

WebDriver is a separate middleman program that each browser vendor writes for its own product: ChromeDriver comes from Google, GeckoDriver from Mozilla, SafariDriver is built into macOS by Apple. It is an official W3C standard (World Wide Web Consortium — the international body that writes the standards for the web). ChromeDriver runs as a separate process on your machine and listens for HTTP requests.

Твій тест → HTTP запит → ChromeDriver (окремий процес) → Chrome
                ↑
   кожна дія = окремий запит: відкрити → надіслати → отримати → закрити
   click → запит, fill → запит, screenshot → запит
Your test → HTTP request → ChromeDriver (separate process) → Chrome
                ↑
   each action = separate request: open → send → receive → close
   click → request, fill → request, screenshot → request

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

Playwright + its own protocols:

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

Playwright connects to the browser directly over a persistent connection — with no separate driver in between. For Chromium that is CDP (Chrome DevTools Protocol), the same protocol DevTools itself uses. For Firefox and WebKit Playwright uses its own protocols, implemented in the patched browser builds it ships with. No separate driver at all — npx playwright install and the browsers are there.

Твій тест → WebSocket → Chromium (CDP)
Твій тест → власний протокол → Firefox (модифікована збірка)
Твій тест → власний протокол → WebKit (модифікована збірка)
                ↑
   одне постійне з'єднання на весь тест
   команди передаються без HTTP WebDriver stack — менше накладних витрат
Your test → WebSocket → Chromium (CDP)
Your test → own protocol → Firefox (patched build)
Your test → own protocol → WebKit (patched build)
                ↑
   one persistent connection for the whole test
   commands go without the HTTP WebDriver stack — less overhead

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

CDP gives access to everything the browser can do internally — network interception, clock, geolocation — because it is the same channel DevTools uses. WebDriver only has the commands written down in the W3C (World Wide Web Consortium) standard.

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

So why have WebDriver at all if CDP exists?

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

CDP is an internal Chrome/Chromium protocol. Firefox and Safari do not support it natively. W3C (World Wide Web Consortium) made WebDriver the official standard so that any tool could drive any browser through one API. Playwright solved the same problem its own way — patched browser builds with its own protocols, which gives one identical API across all three browsers.

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

WebDriver BiDi — worth knowing for the interview: Selenium is gradually moving to WebDriver BiDi (Bidirectional), a new W3C standard that allows two-way communication the way CDP does. It brings Selenium closer to what Playwright can do. But it is still work in progress.

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

Who still uses WebDriver today: Selenium (millions of corporate tests written over the years), Appium (mobile testing on Android/iOS), WebdriverIO, and the cloud services BrowserStack and Sauce Labs — their whole infrastructure is built on the WebDriver standard. It is like USB-A: there is a newer USB-C, faster and more capable, but USB-A is still everywhere because billions of devices speak it.

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

Practical advantages of Playwright:

  • Auto-waiting — не треба писати явні очікування, Playwright сам чекає поки елемент готовий
  • Network interception — вбудований перехват HTTP запитів, можна мокати відповіді
  • Ізоляція через BrowserContext — кожен тест в окремому "профілі" без впливу на інші
  • Trace Viewer — вбудований запис всіх дій з скріншотами і мережевими запитами для дебагу
  • Auto-waiting — no explicit waits to write, Playwright waits until the element is ready by itself
  • Network interception — HTTP request interception is built in, responses can be mocked
  • Isolation through BrowserContext — every test runs in its own "profile" with no effect on the others
  • Trace Viewer — a built-in recording of every action with screenshots and network requests for debugging
🔧 TypeScript / JS патерни в цьому прикладі Теоретична картка без коду. Ключові терміни: protocol — набір правил обміну даними між програмами; HTTP request — одноразовий запит-відповідь; WebSocket — постійне з'єднання (детально в Q2-Q3); headless — браузер без графічного інтерфейсу, стандарт для CI.
🔧 TypeScript / JS patterns in this example A theory card with no code. Key terms: protocol — the rules two programs use to exchange data; HTTP request — one round trip, request then response; WebSocket — a persistent connection (in detail in Q2-Q3); headless — a browser with no graphical interface, the default on CI.
🗣 Як читати вголос Playwright — це фреймворк від Microsoft для end-to-end тестування веб-застосунків у браузері, підтримує Chromium, Firefox і WebKit в одному API. Головна технічна відмінність від Selenium — протокол керування браузером і наявність посередника. У Selenium між тестом і браузером стоїть WebDriver — окрема програма яку кожен виробник браузера пише для свого продукту: Google пише ChromeDriver, Mozilla пише GeckoDriver. Кожна команда — це окремий HTTP запит. Playwright підключається до браузера напряму через постійне з'єднання без посередника. Для Chromium це CDP — Chrome DevTools Protocol — той самий протокол що використовують DevTools. Для Firefox і WebKit Playwright використовує власні протоколи реалізовані в модифікованих збірках браузерів які він поставляє разом із собою. Варто знати про WebDriver BiDi — новий стандарт W3C який дозволяє двосторонню комунікацію як у CDP — Selenium поступово на нього переходить. Практично переваги Playwright: авто-очікування, вбудований перехват мережі, ізоляція через BrowserContext і trace viewer для дебагу.
🗣 How to say it out loud Playwright is Microsoft's framework for end-to-end testing in a real browser, and it drives Chromium, Firefox and WebKit through one API. The real difference from Selenium is how it talks to the browser and whether there is a middleman. With Selenium, WebDriver sits between your test and the browser — a separate program each vendor writes for its own product, so Google ships ChromeDriver, Mozilla ships GeckoDriver, and every command is its own HTTP request. Playwright skips that and opens one persistent connection straight to the browser. For Chromium that is CDP, the Chrome DevTools Protocol, literally the same channel DevTools uses, and for Firefox and WebKit it uses its own protocols baked into the patched browser builds it ships with. I would also mention WebDriver BiDi, because Selenium is moving to it and it brings the same two-way communication, though it is still landing. In practice what I get from Playwright is auto-waiting, network interception out of the box, isolation through BrowserContext, and the trace viewer when something fails.
2 Playwright vs Playwright Test — чим бібліотека відрізняється від test runner? Playwright vs Playwright Test — how is the library different from the test runner?
ОсновиBasics Middle ▶

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

Imagine Playwright Test does not exist yet, and there is only the playwright library itself:

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 чи паралельний запуск.

That is not a test — it is just a Node.js program that opens a browser. At this level Playwright is a browser automation library, the way Express is for HTTP or React is for UI. It knows nothing about test(), expect(), beforeEach, retries or parallel runs.

Це і є причина плутанини: у 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 всередині себе.

And this is where the confusion comes from: there really are two separate packages on npm. playwright is the browser automation library (Browser, BrowserContext, Page, Locator). @playwright/test is the test runner built on top of that library: it adds test(), expect(), fixtures, hooks, workers, retries, projects, reporters. When you run npm init playwright@latest you get @playwright/test, which uses the automation API inside itself.

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

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

What actually happens when you type npx playwright test: the first thing that starts is not a browser but the Test Runner — an ordinary Node.js program. It reads playwright.config.ts, finds the test files, loads them to learn which test(...) calls are declared there, splits up the work, and only then creates the workers that run the tests. At that point a browser may not exist at all — it appears only when some test actually asks for browser or page.

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

"Loads" and "registers" are two different steps, and they are easy to mix up:

  • Імпортує — Node.js виконує файл checkout.spec.ts від початку до кінця, як звичайний JS-файл. Усе на верхньому рівні файлу виконується одразу: імпорти, змінні, і кожен виклик test(...).
  • Реєструє — відбувається під час імпорту, коли виконання доходить до рядка test('назва', callback). Сам виклик test() НЕ запускає callback (тіло тесту з goto, click) — він лише додає запис у список Test Runner'а: "є тест з такою назвою, ось його код, запусти коли настане час".
  • Imports — Node.js executes checkout.spec.ts top to bottom like any other JS file. Everything at the top level runs straight away: imports, variables, and every test(...) call.
  • Registers — this happens during that import, the moment execution reaches the line test('name', callback). The test() call itself does NOT run callback (the test body with goto, click) — it only adds an entry to the Test Runner's list: "there is a test with this name, here is its code, run it when the time comes".

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

If a file holds 5 tests, then during the import all 5 test(...) calls run at once (because that is simply the file executing) and all 5 land in the list of registered tests. But not one of their callbacks has started yet.

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

An analogy: importing a file is like reading the table of contents of a recipe book page by page. Each test(...) is a line in that table of contents: "Recipe: Pasta, see p. 12". You already know the recipe exists and where to find it (you registered it), but you have not cooked anything yet (you have not executed it). Only once every file is imported and every test is registered does the Test Runner know the full list of what has to run, and it hands those out to the workers, where the callbacks finally do execute.

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

The key insight: test('...', callback) executes nothing at that moment. It only registers the test with the Test Runner — roughly like runner.addTest(callback). The callback itself runs later, when the Test Runner decides it is time.

Де живе Worker — він не частина Browser API
Where the Worker lives — it is not part of the Browser API
Playwright Test Runner
        │
        ├── Worker 1 (окремий Node.js процес)
        │        └── Browser (запускається лише якщо тест його попросив)
        │              └── BrowserContext (новий для кожного тесту)
        │                    └── Page
        │
        └── Worker 2 (окремий Node.js процес)
                 └── Browser
                       └── BrowserContext
                             └── Page
Playwright Test Runner
        │
        ├── Worker 1 (separate Node.js process)
        │        └── Browser (launched only if the test asks for it)
        │              └── BrowserContext (new for each test)
        │                    └── Page
        │
        └── Worker 2 (separate Node.js process)
                 └── Browser
                       └── BrowserContext
                             └── Page

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

A worker is not part of the browser API; the Test Runner creates it to spread tests across parallel Node.js processes. Inside that worker process, when a test asks for the browser fixture, the fixture launches a browser — and that is exactly why browser is a worker-scoped fixture: one browser process lives inside one worker, not on its own.

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

The summary, in two levels: Level 1 (the Browser Automation API) answers the question "how do I drive a browser" — Browser, BrowserContext, Page, Locator. Level 2 (Playwright Test) answers "how do I run, isolate, scale and coordinate tests" — test(), fixtures, workers, retries, projects. A test does not drive the browser directly — it only describes what has to happen, and the Test Runner takes on all the coordination.

🔧 TypeScript / JS патерни в цьому прикладі import { chromium } from 'playwright' — імпорт з базової бібліотеки (не з @playwright/test); test('...', async () => {}) — виклик функції що реєструє callback, а не виконує його одразу; сам callback — це той самий патерн callback з попередньої картки, тільки викликає його не браузерна подія, а Test Runner коли настає час.
🔧 TypeScript / JS patterns in this example import { chromium } from 'playwright' — an import from the base library (not from @playwright/test); test('...', async () => {}) — a function call that registers a callback instead of running it there and then; that callback is the same callback pattern as in the previous card, except here it is not a browser event that invokes it but the Test Runner, when the time comes.
🗣 Як читати вголос Плутанина між Playwright і Playwright Test — одна з тих речей яка заважає зрозуміти fixtures і workers, поки її не проясниш. Playwright — це бібліотека автоматизації браузера, вона просто вміє відкривати сторінки і клікати по елементах, і нічого не знає про тести. Playwright Test — окремий пакет, test runner, побудований поверх цієї бібліотеки: він додає test, expect, fixtures, workers, retries. Коли запускаєш npx playwright test, спочатку піднімається не браузер, а Test Runner — звичайна Node.js програма, яка читає конфіг, знаходить спек-файли, імпортує їх щоб зареєструвати всі test, і тільки потім створює workers для паралельного виконання. І ось найважливіше: сам виклик test з назвою і колбеком нічого не виконує в момент виклику — він просто каже Test Runner "запам'ятай цей тест", а реальне виконання відкладається на потім. Worker теж не має прямого стосунку до браузерного API — це окремий Node.js процес, який створює Test Runner для паралелізму, і вже всередині цього процесу, коли конкретний тест попросить фікстуру browser, тільки тоді запускається сам браузер. Тому і виходить що тест не керує браузером напряму — він лише описує що потрібно зробити, а вся координація лежить на Test Runner.
🗣 How to say it out loud The confusion between Playwright and Playwright Test is one of those things that quietly blocks you from understanding fixtures and workers until somebody spells it out. Playwright is the browser automation library — it opens pages and clicks things, and it knows nothing about tests. Playwright Test is a separate package, a test runner built on top of it, and that is what gives you test, expect, fixtures, workers and retries. When you run npx playwright test, the first thing that starts is not a browser, it is the runner — a plain Node process that reads the config, finds the spec files, imports them so every test call gets registered, and only then creates workers. And here is the part people miss: calling test with a name and a callback executes nothing at that moment, it just tells the runner to remember this one, and the actual run happens later. A worker is not a browser concept either — it is a separate Node process the runner creates for parallelism, and only inside it, when a test asks for the browser fixture, does a browser actually launch. So the test never drives the browser directly; it describes what should happen and the runner does the coordinating.
3 Що таке Node.js і WebSocket і як вони пов'язані з тим як Playwright керує браузером? What are Node.js and WebSocket, and what do they have to do with how Playwright drives a browser?
ОсновиBasicsJunior▶

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

Node.js is the environment where JavaScript can run outside a browser.

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

At first JavaScript only existed inside the browser — it could manipulate the DOM, react to clicks, send requests. But it could not read files from disk, start a server, or talk to the operating system. Node.js changed that in 2009 — it took the JavaScript engine out of Chrome (V8) and ran it on its own, as a standalone program on your computer.

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

Now JavaScript can do everything that used to belong to server languages: read and write files, start an HTTP server, connect to a database, run tests.

Браузер — середовище де є window, document, DOM, localStorage
Node.js — середовище де є файлова система, мережа, процеси ОС, npm пакети
Один і той самий JavaScript — два різних середовища виконання
The browser — the environment that has window, document, the DOM, localStorage
Node.js — the environment that has the file system, the network, OS processes, npm packages
The same JavaScript, two different runtimes

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

WebSocket is a persistent two-way connection between two programs.

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

A normal HTTP request: the client asks, the server answers, the connection closes. Like a text message — you send it, you get a reply, done. WebSocket is different: the connection opens once and stays open. Either side can send the other a message at any moment. Like a phone call — the line is open and both of you talk.

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

How this ties back to Playwright: when you run a test, it executes in Node.js. Playwright opens one WebSocket connection to the browser at the start and holds it open for the whole test. Over that channel Node.js sends the browser commands ("click here", "fill this field", "run this function") and gets the results back. That is exactly why variables from Node.js are not available inside the browser — they are physically different processes, and all there is between them is the WebSocket channel.

Схема: Node.js і браузер — два окремих світи
Diagram: Node.js and the browser — two separate worlds
// ══════════════════════════════════════════════════════════════════
// 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 передає команди
// ══════════════════════════════════════════════════════════════════
// Node.js process (your test)       Browser (separate process)
// ══════════════════════════════════════════════════════════════════
//
// const expected = 48; ──────────── // expected doesn't exist here!
// const page = ...    ◄──WebSocket──► window.dataLayer = [...]
// await page.click()  ──"click"───► document.querySelector(...)  
// await page.evaluate() ──code─────► runs here
//                      ◄──result──── returned to Node.js
//
// Nothing in Node.js is available in the browser, and vice versa
// The only bridge is the WebSocket Playwright sends commands through
🔧 TypeScript / JS патерни в цьому прикладі async function — функція яка може "чекати" (await) інші асинхронні операції; async/await — синтаксичний цукор над Promise, робить асинхронний код схожим на синхронний; { page } — деструктуризація об'єкта (object destructuring): витягуємо конкретне поле з об'єкта; стрілочна функція () => {} — скорочений синтаксис функції.
🔧 TypeScript / JS patterns in this example async function — a function that can "wait" (await) on other asynchronous operations; async/await — syntactic sugar over Promises that makes asynchronous code read like synchronous code; { page } — object destructuring: pulling one field out of an object; the arrow function () => {} — the short function syntax.
🗣 Як читати вголос Node.js — це середовище де JavaScript може виконуватись поза браузером: читати файли, запускати сервери, підключатись до баз даних. Саме в Node.js виконується твій Playwright тест — там живуть всі змінні, об'єкт page, функції expect. Браузер — це окремий процес де живе JavaScript сторінки: window, document, React, dataLayer. Це два різних середовища які не бачать одне одного напряму. Playwright з'єднує їх через WebSocket — це постійне двостороннє з'єднання яке відкривається один раз на початку тесту і тримається відкритим до кінця. Через цей канал Node.js надсилає браузеру команди і отримує результати назад. Коли ти передаєш функцію в page.waitForFunction() або page.evaluate() — Playwright бере текст цієї функції, відправляє через WebSocket в браузер, і браузер виконує її у себе. Але змінні з Node.js туди не переїжджають — це як скопіювати текст функції на інший комп'ютер, змінні з першого комп'ютера там не з'являться. Тому closure не працює через цю межу, і змінні треба явно передавати як аргументи — тоді Playwright серіалізує їх в JSON і відправить разом з функцією.
🗣 How to say it out loud Node.js is the runtime that lets JavaScript run outside a browser: read files, start servers, talk to a database. That is where your Playwright test actually executes — your variables, the page object, expect, all of it lives in Node. The browser is a separate process with its own JavaScript: window, document, React, dataLayer. The two do not see each other directly. Playwright bridges them over a WebSocket, one connection opened at the start of the test and held open to the end, and every command and every result travels through it. So when you hand a function to page.evaluate() or page.waitForFunction(), Playwright takes the source of that function, ships it across, and the browser runs it on its side. Your Node variables do not come along — it is like copying the text of a function onto another machine, the variables from the first machine are simply not there. That is why a closure does not survive the boundary, and you have to pass values in explicitly as arguments; then Playwright serialises them to JSON and sends them along with the function.
4 Що таке WebSocket і чим він відрізняється від звичайного HTTP запиту? What is a WebSocket and how is it different from a normal HTTP request?
ОсновиBasicsJunior▶

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

To understand WebSocket you first have to understand how ordinary HTTP works.

HTTP запит — як SMS:

An HTTP request is like a text message:

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

You send a message, the server answers, the connection closes. Every time you need something you send a new request. Each request is a separate connection that opens and closes. Efficient for sites where the data rarely changes — you load the page and there is nothing left to wait for.

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

WebSocket is like a phone call:

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

The connection opens once and stays open. Either side can send the other a message at any moment without a new request. Efficient whenever you need a constant exchange of data in real time — chats, online games, financial tickers, and Playwright driving a browser.

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

Why Playwright uses WebSocket: Playwright has to send the browser commands constantly and get results back. If it used HTTP, every action (click, fill, evaluate) would need a new connection. A WebSocket opens once and carries every command for the whole test — fast and cheap.

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

// ── WebSocket — одне з'єднання на весь час ───────────────────────
//
// Клієнт         Сервер
// ───────        ──────
// "привіт" ────────►   з'єднання відкрилось — І ЛИШАЄТЬСЯ ВІДКРИТИМ
// ◄──── "привіт"
// "клікни .btn" ───►   (те саме з'єднання)
// ◄──── "ok"
// "заповни #input" ►   (те саме з'єднання)
// ◄──── "ok"
// ...                  з'єднання закривається тільки в кінці
// ── HTTP — a new connection per request ─────────────────────────
//
// Client         Server
// ───────        ──────
// "GET /page" ──────►  connection opened
// ◄────── HTML         connection closed
//
// "GET /api/data" ───► new connection
// ◄──── JSON           closed
//
// Every request = open + close a connection

// ── WebSocket — one connection the whole time ───────────────────────
//
// Client         Server
// ───────        ──────
// "hello" ─────────►   connection opened — AND STAYS OPEN
// ◄──── "hello"
// "click .btn" ────►   (same connection)
// ◄──── "ok"
// "fill #input" ───►   (same connection)
// ◄──── "ok"
// ...                  connection closes only at the end
Як WebSocket виглядає в Playwright — під капотом
What a WebSocket looks like in Playwright — under the hood
// Ти пишеш звичайний код тесту:
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 говорить з браузером
// You write regular test code:
await page.goto('/products');
await page.locator('.add-to-cart').click();
await page.fill('#promo', 'SAVE10');

// Under the hood Playwright sends commands to the browser over 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: {} }
//
// All these messages go over ONE WebSocket connection
// This is the Chrome DevTools Protocol (CDP) — the language Playwright uses to talk to the browser
TypeScript — WebSocket в тестах: page.on('websocket')
TypeScript — WebSockets in tests: page.on('websocket')
// Окрема тема: WebSocket на самій сторінці
// Якщо сторінка використовує WebSocket для реалтайм даних (чат, тікери) —
// Playwright дозволяє слухати ці з'єднання через page.on('websocket')

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

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

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

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

  await page.goto('/prices');

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

  // Перевіряємо вміст WebSocket повідомлення
  const priceUpdate = JSON.parse(wsMessages[0]);
  expect(priceUpdate).toHaveProperty('price');
});
// Separate topic: WebSocket on the page itself
// If the page uses WebSocket for real-time data (chat, tickers) —
// Playwright lets you listen to those connections via page.on('websocket')

test('WebSocket receives price updates', async ({ page }) => {
  const wsMessages: string[] = [];

  // Subscribe to the page's WebSocket connections
  page.on('websocket', ws => {
    console.log('WS opened:', ws.url());

    // Listen to frames coming from the server
    ws.on('framereceived', event => {
      wsMessages.push(event.payload as string);
    });

    ws.on('close', () => console.log('WS closed'));
  });

  await page.goto('/prices');

  // Wait until a price message arrives
  // ✅ Correct: wsMessages lives in Node.js — check it via expect.poll
  await expect.poll(() => wsMessages.length, { timeout: 10_000 })
    .toBeGreaterThan(0);
  // ❌ WRONG: window.wsMessages doesn't exist in the browser!
  // await page.waitForFunction(() => (window as any).wsMessages?.length > 0);
  // wsMessages is a Node.js variable, not available in the browser context

  // Check the WebSocket message content
  const priceUpdate = JSON.parse(wsMessages[0]);
  expect(priceUpdate).toHaveProperty('price');
});
💡 Два різних WebSocket в контексті Playwright:
1. WebSocket між Playwright і браузером — внутрішній, завжди є, через нього Playwright надсилає команди. Це Chrome DevTools Protocol (CDP). Ти його не бачиш, він просто існує під капотом.
2. WebSocket на самій сторінці — якщо сайт використовує WebSocket для реалтайм даних (чат, стрімінг цін). Цей можна слухати через page.on('websocket').
💡 Two different WebSockets in the Playwright context:
1. The WebSocket between Playwright and the browser — internal, always there, the channel Playwright sends its commands over. This is the Chrome DevTools Protocol (CDP). You never see it, it just exists under the hood.
2. The WebSocket on the page itself — if the site uses WebSockets for real-time data (a chat, a price stream). That one you can listen to with page.on('websocket').
🔧 TypeScript / JS патерни в цьому прикладі page.on('event', callback) — патерн підписки на події (Observer/EventEmitter): передаємо callback-функцію яка викличеться коли подія відбудеться; ws => — стрілочна функція як callback; ws.on('framereceived', event => {}) — вкладені підписки на події; new DataTransfer() — створення екземпляру класу через new.
🔧 TypeScript / JS patterns in this example page.on('event', callback) — the event subscription pattern (Observer/EventEmitter): we hand over a callback function that will be invoked when the event happens; ws => — an arrow function as a callback; ws.on('framereceived', event => {}) — nested event subscriptions; new DataTransfer() — creating a class instance with new.
🗣 Як читати вголос WebSocket — це протокол для постійного двостороннього з'єднання між двома програмами. Відрізняється від звичайного HTTP тим що HTTP це запит-відповідь-закрито, кожна взаємодія це окреме з'єднання — як SMS. WebSocket відкривається один раз і лишається відкритим, обидві сторони можуть надсилати повідомлення в будь-який момент — як телефонна розмова. Playwright використовує WebSocket щоб керувати браузером: на початку тесту відкривається одне з'єднання через яке проходять всі команди — navigate, click, fill, evaluate — і всі відповіді від браузера. Це Chrome DevTools Protocol, CDP — мова на якій Playwright говорить з браузером. Це внутрішнє з'єднання яке ти не бачиш але воно є завжди. Окремо від цього, якщо сама сторінка яку ти тестуєш використовує WebSocket для реалтайм даних — наприклад чат або стрімінг цін — ти можеш слухати ці з'єднання через page.on('websocket') і перевіряти вміст повідомлень які приходять від сервера.
🗣 How to say it out loud A WebSocket is a protocol for a persistent, two-way connection between two programs. The difference from plain HTTP is that HTTP is request, response, close — every interaction is its own connection, like sending a text. A WebSocket opens once and stays open, and either side can send a message whenever it wants, more like a phone call. Playwright uses one to drive the browser: at the start of the test a single connection opens, and every command — navigate, click, fill, evaluate — and every answer from the browser goes through it. That is the Chrome DevTools Protocol, CDP, the language Playwright speaks to the browser. It is internal, you never see it, but it is always there. Separately from that, if the page you are testing uses WebSockets for real-time data, a chat or a price stream, you can listen to those connections with page.on('websocket') and assert on the frames coming back from the server.
5 Що таке CDP (Chrome DevTools Protocol) і як він пов'язаний з Playwright? What is CDP (Chrome DevTools Protocol) and how does it relate to Playwright?
ОсновиBasicsMiddle▶

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

To understand CDP you first have to separate two levels that have nothing to do with each other.

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

Level 1 — the application code (your React/JS site)

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

Ordinary JavaScript running inside the browser. It talks to the browser through the built-in browser APIs — document, window, fetch. There is no CDP here at all.

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

Level 2 — CDP (Chrome DevTools Protocol)

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

A channel for external programs that want to drive the browser from the outside. It is a set of JSON commands flying over a WebSocket between the external program and the browser. CDP is the "socket" Chrome exposes to outside programs. Anyone who knows the protocol can plug into it.

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

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

Who uses CDP:

  • DevTools — коли відкриваєш F12. Це не "вбудована частина Chrome" — це окремий веб-додаток який спілкується з браузером через CDP. Вкладки Elements, Network, Console — все через CDP
  • Playwright — керує браузером через CDP: клік, навігація, перехоплення мережі
  • Puppeteer — бібліотека від Google, попередник Playwright, теж CDP
  • Lighthouse — вимірює performance через CDP
  • VS Code debugger — дебагінг Node.js теж через CDP
  • DevTools — the thing you open with F12. It is not a "built-in part of Chrome" — it is a separate web app that talks to the browser over CDP. The Elements, Network and Console tabs all go through CDP
  • Playwright — drives the browser over CDP: clicks, navigation, network interception
  • Puppeteer — Google's library, Playwright's predecessor, also CDP
  • Lighthouse — measures performance over CDP
  • The VS Code debugger — debugging Node.js goes through CDP too

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

An analogy: the browser is a television. The site's code is the video playing on the screen. CDP is the remote control from outside. DevTools, Playwright and Lighthouse are different devices plugging into the same remote. The video itself has no need for a remote — it just plays.

TypeScript — як виглядають CDP команди під капотом
TypeScript — what CDP commands look like under the hood
// Коли ти пишеш звичайний 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 робить це автоматично
// When you write regular Playwright code:
await page.goto('https://myshop.com');
await page.locator('.add-to-cart').click();

// Under the hood Playwright sends CDP commands over 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": {} }
//
// You don't see this — Playwright does it automatically
TypeScript — прямий доступ до CDP в тесті через newCDPSession
TypeScript — talking to CDP directly in a test with newCDPSession
// Зазвичай CDP не треба викликати напряму — Playwright все робить сам
// Але для просунутих речей є прямий доступ:

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

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

// Емулюємо мобільну мережу через CDP
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  downloadThroughput: 1.5 * 1024 * 1024 / 8, // 1.5 Mbps
  uploadThroughput: 750 * 1024 / 8,
  latency: 40,
});
// Usually you don't call CDP directly — Playwright handles everything
// But for advanced cases there's direct access:

const client = await page.context().newCDPSession(page);
// ↑ open a CDP session for a specific tab

// Enable the Performance domain and get metrics
await client.send('Performance.enable');
const { metrics } = await client.send('Performance.getMetrics');
console.log(metrics); // JSHeapUsedSize, LayoutCount, TaskDuration...

// Emulate a mobile network via CDP
await client.send('Network.emulateNetworkConditions', {
  offline: false,
  downloadThroughput: 1.5 * 1024 * 1024 / 8, // 1.5 Mbps
  uploadThroughput: 750 * 1024 / 8,
  latency: 40,
});
🔧 TypeScript / JS патерни в цьому прикладі page.context().newCDPSession(page) — ланцюжок методів, повертає Promise з CDP client; client.send('Domain.method', params) — надсилає CDP команду, перший аргумент — рядок у форматі "Domain.method", другий — об'єкт параметрів; деструктуризація const { metrics } = await client.send(...) — беремо поле metrics з результату.
🔧 TypeScript / JS patterns in this example page.context().newCDPSession(page) — method chaining, returns a Promise with a CDP client; client.send('Domain.method', params) — sends a CDP command, the first argument is a string shaped "Domain.method", the second an object of parameters; destructuring const { metrics } = await client.send(...) — taking the metrics field out of the result.
🗣 Як читати вголос CDP — Chrome DevTools Protocol — це канал для зовнішніх програм які хочуть керувати браузером ззовні. Важливо розрізняти два рівні: код аплікейшину — звичайний JavaScript всередині браузера, він використовує браузерні API типу document і fetch, CDP тут немає. CDP — це окремий рівень для зовнішніх програм: набір JSON команд які летять через WebSocket від зовнішньої програми до браузера. Найбільший користувач CDP — самі DevTools які ти відкриваєш через F12: це не вбудована частина Chrome, а окремий веб-додаток що спілкується з браузером через CDP. Playwright теж використовує CDP — коли ти пишеш page.goto() або locator.click(), Playwright автоматично надсилає відповідні CDP команди. Для просунутих речей — performance метрики, емуляція мережі — можна відкрити CDP сесію напряму через newCDPSession і надсилати команди самостійно через client.send().
🗣 How to say it out loud CDP, the Chrome DevTools Protocol, is the channel external programs use to drive the browser from outside. The thing worth separating is the two levels. Application code is ordinary JavaScript running inside the browser, using browser APIs like document and fetch — there is no CDP in sight there. CDP is a level above: JSON commands travelling over a WebSocket from an outside program into the browser. The biggest consumer of it is DevTools itself — F12 is not a built-in part of Chrome, it is a separate web app talking over CDP. Playwright uses the same channel, so when you write page.goto or locator.click it turns that into the matching CDP commands for you. And when you need something the API does not expose, like performance metrics or network emulation, you can open a session yourself with newCDPSession and send the commands by hand through client.send.
6 Що таке Page, BrowserContext і Browser? Як вони пов'язані і чому page: Page усюди в тестах? What are Page, BrowserContext and Browser? How do they relate, and why is page: Page everywhere in tests?
ОсновиBasicsJunior▶

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

We have just worked out how Playwright talks to the browser over WebSocket and CDP. Now let us look at the structure of the browser itself — the three levels of hierarchy that explain why tests stay isolated from one another even when they run in parallel.

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

Browser is the browser process itself (Chromium, Firefox or WebKit). It is expensive to start, so Playwright keeps one per worker (execution process). Run in parallel with 4 workers and you get 4 browser processes, one per worker.

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

BrowserContext is an isolated "profile" inside the browser, much like a separate incognito window. It has its own cookies, localStorage and cache. Playwright creates a fresh one for every test.

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

Cookies belong to the BrowserContext — not to the Browser and not to the Page. Each context has its own cookie jar: two different contexts have completely isolated cookies automatically, even when they run inside the same Browser process. That is exactly what test isolation in Playwright is built on — test A, logged in as admin, cannot see test B's session. All the tabs (Pages) inside one context, by contrast, share cookies.

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

Page is a single tab inside a context. It is the thing you actually interact with through goto, click, fill.

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

When you write async ({ page }) in a test you are not creating the tab by hand. Playwright has already prepared a Browser, created a new BrowserContext and opened a Page inside it — and handed it to you ready to use. That is what a fixture is: a resource the framework prepared on your behalf. More on fixtures in the next two cards.

TypeScript — ієрархія Browser → Context → Page
TypeScript — the Browser → Context → Page hierarchy
// Ієрархія, яку будує 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
});
// The hierarchy Playwright builds under the hood:
// Browser  → one browser process (Chromium/Firefox/WebKit)
//   └─ BrowserContext → an isolated "profile" session (cookies, storage, cache)
//        └─ Page → one tab inside that context

test('example', async ({ page }: { page: Page }) => {
  // page is already a ready tab; Playwright itself:
  // 1. launched the Browser (or reused one from the pool)
  // 2. created a new isolated BrowserContext just for this test
  // 3. opened one Page in it and passed it as a fixture

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

// Why this matters for test isolation:
test('test A logs in', async ({ page }) => {
  await page.goto('/login'); // its own BrowserContext, its own cookies
});
test('test B — guest', async ({ page }) => {
  await page.goto('/'); // does NOT see test A's cookies — different BrowserContext
});
💡 Простими словами: Browser — це сам додаток-браузер. BrowserContext — окремий "інкогніто-профіль" всередині нього, зі своїми cookies/localStorage/cache. Page — одна вкладка в цьому профілі. Playwright дає кожному тесту власний BrowserContext (а отже й Page), тому тести ніколи не "бачать" стан один одного, навіть якщо запускаються паралельно в одному Browser-процесі.
💡 In plain words: Browser is the browser application itself. BrowserContext is a separate "incognito profile" inside it, with its own cookies/localStorage/cache. Page is one tab in that profile. Playwright gives every test its own BrowserContext (and therefore its own Page), which is why tests never "see" each other's state, even when they run in parallel inside the same Browser process.
🔧 TypeScript / JS патерни в цьому прикладі { page }: { page: Page } — деструктуризація з TypeScript анотацією типу; async ({ page }) => {} — стрілочна async функція з деструктуризацією параметра; // коментарі — документують що Playwright робить "за лаштунками"; const — оголошення константи (значення не можна перепризначити).
🔧 TypeScript / JS patterns in this example { page }: { page: Page } — destructuring with a TypeScript type annotation; async ({ page }) => {} — an async arrow function destructuring its parameter; the // comments document what Playwright does behind the scenes; const — a constant declaration (the value cannot be reassigned).
🗣 Як читати вголос У Playwright є три рівні: Browser — це сам процес браузера, важкий об'єкт, який дорого створювати, тому Playwright переважно перевикористовує один на весь тестовий запуск. Всередині нього BrowserContext — це ізольована сесія, схожа на окреме інкогніто-вікно: у неї свої cookies, localStorage, кеш, дозволи. І вже всередині контексту — Page, конкретна вкладка, з якою ми безпосередньо взаємодіємо через goto, click, fill і так далі. Коли ми пишемо async ({ page }) у тесті, ми не створюємо цю вкладку самі — Playwright робить це автоматично: для кожного тесту піднімає новий BrowserContext, відкриває в ньому одну Page і підставляє її як fixture-аргумент. Анотація типу : Page — це просто TypeScript, який каже компілятору, що це об'єкт класу Page, а не any, щоб автодоповнення й перевірка типів працювали. Саме тому в кожному тесті, кожному Page Object, кожному helper-методі ти бачиш page — це не глобальна змінна, а свіжий ізольований об'єкт, який Playwright інжектить заново щоразу, і завдяки цьому один тест ніколи не впливає на стан іншого, навіть при паралельному запуску.
🗣 How to say it out loud There are three levels in Playwright. Browser is the browser process itself, a heavy object that is expensive to start, so Playwright mostly reuses one across the whole run. Inside it sits BrowserContext, an isolated session much like a separate incognito window, with its own cookies, localStorage, cache and permissions. And inside the context is Page, the actual tab we work with through goto, click, fill and so on. When we write async ({ page }) in a test we are not creating that tab ourselves — Playwright spins up a fresh BrowserContext for every test, opens one Page in it and injects that as the fixture argument. The : Page annotation is just TypeScript telling the compiler this is a Page object and not any, so autocomplete and type checking work. That is why the page you see in every test, every Page Object, every helper is not a global — it is a fresh isolated object Playwright injects each time, and that is the reason one test can never leak state into another, even running in parallel.
7 Що таке фікстура (fixture) концептуально? Чому page інжектиться, а не створюється вручну? What is a fixture conceptually? Why is page injected instead of created by hand?
ОсновиBasicsJunior▶

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

A fixture is a ready-made resource that Playwright prepared and handed into the test. You simply name what you need and you get it, ready. After the test Playwright cleans up on its own.

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

A restaurant analogy: you do not walk into the kitchen to fetch a plate — the waiter brings it. You just order, "I need a plate", and get one ready. A fixture is that waiter.

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

In code it looks like this: when you write async ({ page }) you are not creating page yourself. Playwright reads the destructuring key page, works out that you need a tab, and passes one in ready. One detail matters: Playwright looks at the object key, not at the name of your local variable. So async ({ page: myPage }) works exactly the same — the fixture key is still page, the local variable is simply renamed to myPage.

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

This is close to Dependency Injection (DI), a pattern from backend development. DI means: "do not create your dependencies yourself — receive them from outside". Instead of writing new Database() inside a class, the class receives a ready database object through its constructor or a parameter. In the same way a test receives a ready page through a function parameter instead of creating it.

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

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

// Вбудовані фікстури з різним життєвим циклом:
// page    → нова для КОЖНОГО тесту
// context → нова для КОЖНОГО тесту (page живе всередині неї)
// browser → одна на ВЕСЬ файл / прогін (дорого перестворювати)
// test() — a function with parameters "by convention", like DI
test('example', async ({ page }) => {
  // "page" is a key in the fixtures object; Playwright recognizes
  // the name and injects the matching ready-made resource
  await page.goto('/login');
});

// You DON'T have to do this by hand:
// const browser = await chromium.launch();
// const context = await browser.newContext();
// const page = await context.newPage();
// ... test ...
// await page.close();
// await context.close();
// Playwright does all of this itself, and cleans up after the test too

// Built-in fixtures with different lifetimes:
// page    → new for EACH test
// context → new for EACH test (page lives inside it)
// browser → one for the WHOLE file / run (expensive to recreate)
💡 Простими словами: уяви, що фікстура — це офіціант, який сам приносить тобі чисту тарілку перед їжею і сам забирає брудну після, без твоєї участі. Ти просто кажеш "мені потрібна тарілка" (називаєш page у деструктуризації) — і не думаєш про те, звідки вона взялась і хто її помиє.
💡 In plain words: think of a fixture as a waiter who brings you a clean plate before the meal and takes the dirty one away after, with no involvement from you. You just say "I need a plate" (you name page in the destructuring) and never think about where it came from or who washes it.
🔧 TypeScript / JS патерни в цьому прикладі async ({}, use) => {} — стрілочна async функція з двома параметрами, перший — порожня деструктуризація (нам не потрібні інші fixtures); await use(value) — use це callback-функція яку ми викликаємо щоб "передати" ресурс в тест і "призупинити" fixture; все що до use() — setup, після — teardown.
🔧 TypeScript / JS patterns in this example async ({}, use) => {} — an async arrow function with two parameters, the first an empty destructuring (we do not need any other fixtures); await use(value) — use is a callback we call to "hand" the resource to the test and "pause" the fixture; everything before use() is setup, everything after it is teardown.
🗣 Як читати вголос Фікстура — це готовий ресурс, який Playwright сам готує і передає в тест перед його запуском, а потім сам прибирає після. Технічно, коли ми пишемо async ({ page }) => {...}, page — це не просто змінна, а ключ в об'єкті fixtures, який Playwright розпізнає по імені і підставляє відповідний ресурс. У Playwright є вбудовані фікстури: page, context, browser, request для API-тестів — кожна зі своїм життєвим циклом, наприклад page створюється заново для кожного тесту, а browser — worker-scoped: один браузерний процес на весь worker. При 4 workers — 4 браузерних процеси. Це той самий принцип, що dependency injection в бекенд-фреймворках: ми кажемо "мені потрібен page" просто назвавши його в деструктуризації — і Playwright за лаштунками вирішує, як саме його зібрати, коли створити і коли знищити. Нам не треба писати new Page() чи закривати її вручну. Сила фікстур ще й у тому, що можна створювати власні — наприклад, кастомна фікстура authenticatedPage бере базову page, логінить юзера через storageState, і вже залогінену сторінку віддає в тест; тест просто пише async ({ authenticatedPage }) => {...} і не думає про логін взагалі.
🗣 How to say it out loud A fixture is a ready-made resource that Playwright builds before the test runs and tears down after. Technically, when we write async ({ page }) => {...}, page is not just a variable — it is a key in the fixtures object that Playwright recognises by name and fills in with the matching resource. There are built-in ones: page, context, browser, request for API tests, each with its own lifetime. page is created fresh for every test, while browser is worker-scoped: one browser process per worker, so four workers means four browser processes. It is the same idea as dependency injection in backend frameworks: we say "I need a page" just by naming it in the destructuring, and Playwright decides behind the scenes how to build it, when to create it and when to destroy it. We never write new Page() or close it by hand. And the real power is that you can write your own — a custom authenticatedPage fixture takes the base page, logs the user in through storageState and hands back a signed-in page, so the test just asks for async ({ authenticatedPage }) => {...} and never thinks about login at all.
8 Чому page, browser і context називаються "фікстурами"? Це просто слово чи технічний термін? Why are page, browser and context called "fixtures"? Is that just a word, or a technical term?
ОсновиBasicsJunior▶
"Fixture" — це офіційний термін у Playwright, не метафора. Слово прийшло з будівництва і спорту — це щось "зафіксоване", підготовлене заздалегідь і готове до використання. У тестуванні fixture — це будь-який ресурс, який фреймворк готує для тебе до запуску тесту і прибирає після. Саме тому page, browser, context — це фікстури: ти їх не створюєш і не знищуєш, Playwright робить це сам.
"Fixture" is the official term in Playwright, not a metaphor. The word comes from construction and from sport — something "fixed", prepared in advance and ready to be used. In testing a fixture is any resource the framework prepares for you before the test runs and clears away after. That is exactly why page, browser and context are fixtures: you neither create nor destroy them, Playwright does it for you.
TypeScript — з фікстурами vs без
TypeScript — with fixtures vs without
// ✅ З фікстурами — ти просто називаєш що потрібно
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)
//            одна вкладка = один тест
// ✅ With fixtures — you just name what you need
test('my test', async ({ page, context, browser }) => {
  // page, context, browser are already set up — you didn't create them
  await page.goto('/shop');
});
// After the test Playwright closes page and context itself. Period.

// ❌ Without fixtures — you'd have to write this by hand EVERY TIME:
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// ... test ...
await page.close();    // and don't forget! what if the test fails midway?
await context.close(); // then close never runs and the browser is left hanging
await browser.close();

// ─────────────────────────────────────────────────────
// Different fixture lifetimes are also part of "setup and teardown":
//
// browser  — one for the WHOLE test run (or file)
//            launching Chrome is expensive (seconds)
//            so Playwright reuses one process
//
// context  — new for EACH test
//            this is isolation: own cookies, localStorage, cache
//            cheap to create (milliseconds)
//
// page     — new for EACH test (lives inside context)
//            one tab = one test
💡 Як це виглядає в документації Playwright: якщо відкрити офіційний сайт і знайти розділ "Fixtures" — там так і написано: "Built-in fixtures: page, browser, context, request". Це не народна назва — це офіційна архітектурна концепція фреймворку, запозичена з pytest (Python), де fixtures теж є центральною ідеєю.
💡 How it looks in the Playwright docs: open the official site, find the "Fixtures" section, and it says exactly that: "Built-in fixtures: page, browser, context, request". It is not a folk name — it is the framework's official architectural concept, borrowed from pytest (Python), where fixtures are a central idea too.
🔧 TypeScript / JS патерни в цьому прикладі async ({ page }, use, testInfo) => {} — функція отримує три аргументи: інші fixtures, callback use(), і метадані тесту; testInfo.title — доступ до властивості об'єкта через крапку; Date.now() — виклик статичного методу класу; template literal `текст ${змінна}` — рядок з вбудованим виразом.
🔧 TypeScript / JS patterns in this example async ({ page }, use, testInfo) => {} — the function receives three arguments: the other fixtures, the use() callback, and the test metadata; testInfo.title — reading an object property with a dot; Date.now() — calling a static method on a class; a template literal `text ${variable}` — a string with an embedded expression.
🗣 Як читати вголос "Fixture" — це офіційний термін у Playwright, не просто зручне слово. Походить від ідеї "зафіксованого, заздалегідь підготовленого ресурсу": фреймворк сам готує його до початку тесту і сам прибирає після — незалежно від того, чи тест пройшов чи впав. Якби page не була фікстурою, тобі б довелось вручну писати chromium.launch(), browser.newContext(), context.newPage() на початку кожного тесту і відповідно page.close(), context.close(), browser.close() наприкінці. І найголовніша проблема — якщо тест впав посередині виконання, твій код прибирання просто не досягнеться, браузерні процеси залишаться висіти в пам'яті. Фікстура вирішує це елегантно: Playwright гарантує teardown fixture навіть якщо тест завершився помилкою. (Виняток: якщо процес примусово завершений — SIGKILL, OOM, killed CI runner — teardown не виконається.) Важливо, що page, context і browser мають різний час життя: браузерний процес дорого запускати, тому browser — worker-scoped: один на весь worker-процес. При паралельному запуску з 4 workers — буде 4 браузерних процеси. А context і page створюються заново для кожного тесту окремо, бо саме через них досягається ізоляція — у кожному тесті свої cookies, свій localStorage, свій чистий стан.
🗣 How to say it out loud Fixture is the official term in Playwright, not just a convenient word. It comes from the idea of a fixed, prepared-in-advance resource: the framework sets it up before the test and tears it down after, whether the test passed or failed. If page were not a fixture you would be writing chromium.launch(), browser.newContext() and context.newPage() at the top of every test, and page.close(), context.close(), browser.close() at the bottom. And the nasty part is that if the test blows up halfway through, your cleanup code never runs and the browser processes just sit there eating memory. The fixture solves that cleanly — Playwright guarantees teardown even when the test fails. The exception is a hard kill: SIGKILL, an OOM, a killed CI runner, and teardown does not happen. It is also worth saying that page, context and browser have different lifetimes. A browser process is expensive to start, so browser is worker-scoped, one per worker process — four workers means four browser processes. context and page are recreated per test, because that is where isolation actually comes from: fresh cookies, fresh localStorage, a clean state every time.
9 Як написати базовий тест на Playwright з TypeScript? How do you write a basic Playwright test in TypeScript?
ОсновиBasicsJunior▶

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

We already know what Node.js is, what a WebSocket is, the browser hierarchy and fixtures. Time to write the first real test — and you will see how all those concepts click together.

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

A basic test has three parts: the import of test and expect from Playwright, the test declaration through the test() function with a name and an async function, and the fixture destructuring { page } — that is where we "ask" Playwright for a ready tab.

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

Notice the await in front of every action — Playwright is asynchronous, and without await the next line runs without waiting for the previous one to finish.

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

test('should display homepage title', async ({ page }) => {
  await page.goto('https://example.com');
  
  await expect(page).toHaveTitle(/Example Domain/);
  
  const heading = page.locator('h1');
  await expect(heading).toBeVisible();
  await expect(heading).toHaveText('Example Domain');
});
🔧 TypeScript / JS патерни в цьому прикладі import { test, expect } from '@playwright/test' — ES modules import з деструктуризацією: беремо конкретні exports з пакету; async ({ page }) => {} — стрілочна async функція; await перед кожною дією — чекаємо завершення асинхронної операції; expect(locator).toBeVisible() — ланцюжок методів (method chaining).
🔧 TypeScript / JS patterns in this example import { test, expect } from '@playwright/test' — an ES modules import with destructuring: we take specific exports out of the package; async ({ page }) => {} — an async arrow function; await in front of every action — waiting for the asynchronous operation to finish; expect(locator).toBeVisible() — method chaining.
🗣 Як читати вголос Імпортуємо test і expect з пакету Playwright. Викликаємо функцію test, передаємо назву тесту рядком і асинхронну стрілочну функцію — вона деструктурує page з fixtures. Всередині: чекаємо навігацію через page.goto, потім перевіряємо заголовок сторінки через expect(page).toHaveTitle з регулярним виразом. Далі оголошуємо константу heading — це локатор на тег h1 — і перевіряємо що він видимий і має потрібний текст.
🗣 How to say it out loud We import test and expect from the Playwright package. Then we call test, pass the test name as a string and an async arrow function that destructures page out of the fixtures. Inside it we await the navigation with page.goto, then check the page title with expect(page).toHaveTitle and a regular expression. After that we declare a heading constant — a locator on the h1 tag — and assert that it is visible and has the text we expect.
▶ 2 10
10 Що таке callback і як його читати в коді Playwright? What is a callback and how do you read one in Playwright code?
JS/TSJunior▶

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

A callback is a function you hand to another function without calling it yourself. Whoever received your function decides when to call it.

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

Let us look at a concrete Playwright example:

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

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

There are two functions here: page.route() is a Playwright method that knows how to watch network requests. And route => { ... } is your function. You wrote it, but you never call it.

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

You give your function to Playwright and say: "here is my function, you know when to call it — call it when a request comes in". Playwright keeps your function. When the browser makes a request to /api/, Playwright calls your function itself and passes the route object into it.

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

So: you write the function and hand it over. Playwright decides when to call it, and calls it.

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

An even simpler example — setTimeout:

setTimeout(() => {
  console.log('привіт');
}, 3000);
// () => { console.log('привіт') } — це callback
// Ти передала її setTimeout
// setTimeout чекає 3 секунди і сам викликає твою функцію
// Ти не викликала її — він викликав
setTimeout(() => {
  console.log('hello');
}, 3000);
// () => { console.log('hello') } — this is a callback
// You passed it to setTimeout
// setTimeout waits 3 seconds and calls your function itself
// You didn't call it — it did

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

Callbacks turn up everywhere in 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 викликає його коли запускає тест
});
// page.on() — Playwright calls your callback when the event happens
page.on('dialog', dialog => {
  dialog.accept(); // ← your callback, Playwright calls it when a dialog appears
});

// forEach — JavaScript calls your callback for each element
[1, 2, 3].forEach(num => {
  console.log(num); // ← your callback, forEach calls it three times
});

// test() — Playwright calls your callback when it runs the test
test('test name', async ({ page }) => {
  // ← this is a callback too! Playwright calls it when it runs the test
});

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

💡 How to spot a callback in code: you see a function passed as an argument (inside another function's parentheses) and you are not calling it right there — that is a callback. An arrow function () => {} or async () => {} as an argument is almost always a callback.

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

Why leaving the () off a callback matters:

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

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

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

// With parentheses () — call it NOW and pass the result
setTimeout(greet(), 1000);
// greet() ran immediately when this line was evaluated
// setTimeout got undefined — because greet returns nothing

// Without parentheses — pass the function itself
setTimeout(greet, 1000);
// setTimeout holds on to greet and calls it after 1000ms

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

An analogy: you ask a friend to "ring mum in an hour". greet is the phone number. Your friend presses dial an hour later. greet() is you dialling the number yourself and handing your friend a live call. Mum is already answering — before the hour is up.

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

How to find the callback in complicated code — three steps:

  1. Знайди виклик функції — відкриваючу дужку (
  2. Шукай всередині дужок => або ключове слово function
  3. Все що після => до відповідної закриваючої ) — це callback і його тіло
  1. Find the function call — the opening parenthesis (
  2. Look inside the parentheses for => or the keyword function
  3. Everything from => to the matching closing ) is the callback and its body
🔧 TypeScript / JS патерни в цьому прикладі () => {} — стрілочна функція як callback (передається як аргумент, не викликається одразу); async () => {} — async стрілочна функція-callback; setTimeout(fn, ms) — вбудована функція яка приймає callback; forEach(callback) — метод масиву з callback; { page } — деструктуризація параметра callback.
🔧 TypeScript / JS patterns in this example () => {} — an arrow function as a callback (passed as an argument, not called right away); async () => {} — an async arrow callback; setTimeout(fn, ms) — a built-in function that takes a callback; forEach(callback) — an array method with a callback; { page } — destructuring the callback's parameter.
🗣 Як читати вголос Callback — це функція яку ти передаєш іншій функції але сама не викликаєш. Той хто отримав твою функцію — вирішує коли її викликати. У Playwright це зустрічається скрізь: у page.route() другим аргументом іде стрілочна функція — це callback, Playwright викличе її кожного разу коли браузер зробить запит що відповідає паттерну. У page.on('dialog', ...) — Playwright викличе callback коли з'явиться діалог. У test('назва', async ({page}) => {}) — сама функція тесту це теж callback, Playwright викликає її коли запускає тест. Ключова ознака callback: функція передається як аргумент всередині дужок іншої функції і не викликається одразу через круглі дужки після неї. Найпростіший приклад поза Playwright: setTimeout(() => console.log('привіт'), 3000) — ти передала функцію setTimeout, і він сам викличе її через 3 секунди.
🗣 How to say it out loud A callback is a function you pass to another function without calling it yourself, and whoever receives it decides when to run it. In Playwright they are everywhere. In page.route() the second argument is an arrow function — that is a callback, and Playwright invokes it every time the browser makes a request matching the pattern. In page.on('dialog', ...) Playwright invokes the callback when a dialog shows up. Even the test itself, test('name', async ({page}) => {}), is a callback the runner invokes when it is this test's turn. The giveaway is always the same: the function sits inside another function's parentheses and there are no parentheses after it, so nothing runs at that point. The simplest example outside Playwright is setTimeout(() => console.log('hi'), 3000) — you hand the function over and setTimeout calls it three seconds later.
11 Чому майже кожен метод Playwright повертає Promise? Why does almost every Playwright method return a Promise?
JS/TS Middle ▶

Твій тест і браузер — це два окремі процеси. Тест виконується в Node.js, браузер (Chromium/Firefox/WebKit) — окрема програма зі своєю пам'яттю. Вони спілкуються через протокол обміну повідомленнями — у Chromium це CDP (Chrome DevTools Protocol) поверх WebSocket, той самий протокол, яким користується сама вкладка DevTools.

Your test and the browser are two separate processes. The test runs in Node.js; the browser (Chromium/Firefox/WebKit) is a separate program with its own memory. They talk over a message protocol — in Chromium that is CDP (Chrome DevTools Protocol) on top of a WebSocket, the same protocol the DevTools tab itself uses.

Кожна команда, яку Playwright шле браузеру — click, goto, textContent — це JSON-повідомлення з унікальним id. CDP побудований на JSON-RPC: "every command sent by the client must have a unique id... responses from Chromium carry the same id". Браузер, відповідаючи, включає той самий id, щоб Playwright міг зіставити відповідь із запитом навіть коли кілька команд летять одночасно.

Every command Playwright sends the browser — click, goto, textContent — is a JSON message with a unique id. CDP is built on JSON-RPC: "every command sent by the client must have a unique id... responses from Chromium carry the same id". When the browser answers it includes that same id, so Playwright can match a response to its request even when several commands are in flight at once.

Звідси й наслідок: жодна дія, що взаємодіє з браузером, не може виконатись миттєво в тому ж рядку коду — вона мусить дочекатись відповіді. Тому майже всі методи, які взаємодіють із браузером, повертають Promise (не буквально всі — page.locator(), getByRole(), browser.contexts() синхронні, бо нічого не питають у браузера в момент виклику).

Which leads straight to this: no action that touches the browser can finish instantly on the same line of code — it has to wait for the answer. That is why almost every method that talks to the browser returns a Promise (not literally every one — page.locator(), getByRole() and browser.contexts() are synchronous, because they ask the browser nothing at the moment you call them).

TypeScript — команда і відповідь, спрощено за структурою CDP
TypeScript — a command and its response, simplified from the CDP structure
// Playwright відправляє (спрощено):
{ id: 42, method: 'Input.dispatchMouseEvent', params: { /* координати кліку */ } }

// Браузер відповідає, коли клік реально стався:
{ id: 42, result: { /* ... */ } }

// id той самий — тому Playwright точно знає:
// "це відповідь саме на ТОЙ клік, а не на щось інше"
// Playwright sends (simplified):
{ id: 42, method: 'Input.dispatchMouseEvent', params: { /* click coordinates */ } }

// The browser replies once the click has actually happened:
{ id: 42, result: { /* ... */ } }

// same id — so Playwright knows for sure:
// "this is the reply to THAT click, not to something else"
🗣 Як читати вголос Тест і браузер — два окремі процеси, які спілкуються через протокол обміну повідомленнями, а не викликають функції одне в одного напряму. У Chromium це відбувається через CDP, Chrome DevTools Protocol, поверх WebSocket — той самий протокол яким користується сама вкладка DevTools у браузері. Кожна команда, яку Playwright шле браузеру, це JSON-повідомлення з унікальним id, і браузер, відповідаючи, повертає той самий id, щоб можна було зіставити яка відповідь до якого запиту належить, навіть коли кілька команд летять одночасно. Саме тому жодна дія, що взаємодіє з браузером, не може виконатись миттєво в тому самому рядку коду — вона мусить дочекатись відповіді. І саме тому майже всі методи, які взаємодіють із браузером, повертають Promise — хоча не буквально всі: page.locator, getByRole, browser.contexts синхронні, бо нічого не питають у браузера в момент виклику.
🗣 How to say it out loud The test and the browser are two separate processes that talk over a message protocol — they do not call each other's functions. In Chromium that happens over CDP, the Chrome DevTools Protocol, on top of a WebSocket, the same protocol the DevTools tab uses. Every command Playwright sends is a JSON message with a unique id, and the browser echoes that id back in its response, so you can match answers to requests even when several are in flight at once. That is the whole reason nothing that touches the browser can complete on the same line of code — it has to wait for a reply to come back. And that is why almost every method that talks to the browser returns a Promise. Not literally every one, though: page.locator, getByRole and browser.contexts are synchronous, because at the moment you call them they ask the browser nothing.
12 Що таке await насправді і як він "розшифровується" в callback? What is await really, and how does it "unfold" into a callback?
JS/TS Middle ▶

await — не окрема команда "почекай". Спрощено це можна уявити як синтаксис, що бере весь код нижче і "прикріплює" його як callback до Promise через .then():

await is not a separate "wait here" command. Simplified, you can picture it as syntax that takes all the code below it and "attaches" it as a callback to the Promise through .then():

TypeScript — те що ти пишеш vs те, у що це ментально перетворюється
TypeScript — what you write vs what it mentally turns into
// Пишеш:
await page.click('button.checkout');
await page.locator('.confirmation').waitFor();

// Ментально можна уявити як:
page.click('button.checkout').then(function callback1() {
  return page.locator('.confirmation').waitFor();
});
// You write:
await page.click('button.checkout');
await page.locator('.confirmation').waitFor();

// Mentally you can picture it as:
page.click('button.checkout').then(function callback1() {
  return page.locator('.confirmation').waitFor();
});

Де саме проходить межа callback: до найближчого наступного await, а якщо його немає — до кінця функції. Не буквально "наступний рядок" — між двома await може бути кілька синхронних рядків (console.log, обчислення), і всі вони потрапляють в один і той самий callback:

Where exactly the callback boundary runs: to the nearest following await, and if there is none — to the end of the function. Not literally "the next line": there can be several synchronous lines between two awaits (console.log, calculations), and all of them end up in one and the same callback:

TypeScript — де саме проходить межа
TypeScript — where the boundary actually runs
await page.click('button.checkout');
// ↑ МЕЖА починається тут

console.log('після кліку');
// ↑ це все ще той самий callback1

await page.locator('.confirmation').waitFor();
// ↑ МЕЖА закінчується тут — бо зустрівся наступний await
await page.click('button.checkout');
// ↑ the BOUNDARY starts here

console.log('after click');
// ↑ this is still the same callback1

await page.locator('.confirmation').waitFor();
// ↑ the BOUNDARY ends here — because the next await is reached

Саме тому в першому прикладі waitFor() опинився всередині callback1: між першим і другим await не було жодного проміжного рядка, тож callback1 склався виключно з виклику waitFor() — межа шукала наступний await, знайшла його одразу, і більше нічого туди не потрапило.

That is why in the first example waitFor() ended up inside callback1: there was not a single line between the first and the second await, so callback1 consisted of nothing but the waitFor() call — the boundary went looking for the next await, found it immediately, and nothing else made it in.

Callback — не окрема пауза "подумати". Це просто "шматок коду, викликаний пізніше". Що саме він робить, коли його викликають — залежить від того, чи є що передавати:

A callback is not a separate pause to "think". It is simply "a chunk of code called later". What it does when it is called depends on whether there is anything to pass along:

  • Є значення для передачі — const price = await locator.textContent(). Callback отримує реальний текст і кладе його в змінну.
  • Нема що передавати — await click(); await waitFor();. click() не повертає нічого змістовного, тому callback1 просто запускає наступну дію, нічого не "отримуючи" з попередньої.
  • There is a value to pass — const price = await locator.textContent(). The callback receives the actual text and puts it into the variable.
  • There is nothing to pass — await click(); await waitFor();. click() returns nothing meaningful, so callback1 simply starts the next action without "receiving" anything from the previous one.
🔧 Ключова фраза, варта запам'ятати дослівно Callback — завжди "шматок коду, викликаний пізніше". Іноді він одразу використовує передане значення — коли є що передавати. Іноді він просто запускає наступну дію без жодного значення — коли передавати нічого. Обидва випадки — той самий механізм, просто з різним вмістом усередині.
🔧 The one line worth memorising word for word A callback is always "a chunk of code called later". Sometimes it uses the value it was handed straight away — when there is a value. Sometimes it just starts the next action with no value at all — when there is nothing to hand over. Both cases are the same mechanism, only the contents differ.
🗣 Як читати вголос Await не окрема команда почекай, це синтаксис який каже: візьми весь код що йде нижче і оберни його в callback, приліплений до Promise через then. Межа цього callback проходить до найближчого наступного await, а якщо його немає — до кінця функції, і це не буквально наступний рядок, бо між двома await може бути кілька синхронних рядків, і всі вони потрапляють в один і той самий callback. Callback сам по собі — це завжди просто шматок коду, викликаний пізніше, і головне тут не плутати дві ситуації. Якщо асинхронна функція повертає значення, callback це значення отримує і використовує. Але якщо повертати нічого змістовного, як у click, callback просто запускає наступну дію, без жодного переданого значення. Це той самий механізм в обох випадках, просто з різним вмістом усередині callback.
🗣 How to say it out loud Await is not a separate "wait here" command, it is syntax that says: take all the code below and wrap it in a callback attached to the Promise through then. The boundary of that callback runs to the nearest following await, or to the end of the function if there is not one, and it is not literally the next line, because there can be several synchronous lines between two awaits and they all end up in the same callback. A callback itself is always just a chunk of code called later, and the thing not to confuse is two situations. If the asynchronous call returns a value, the callback gets that value and uses it. If it returns nothing meaningful, like click, the callback simply kicks off the next action with no value passed at all. Same mechanism in both cases, only the contents inside differ.
13 Що таке event loop і як він працює з call stack та чергою? What is the event loop and how does it work with the call stack and the queue?
JS/TS Middle ▶

(Для простоти поки що вважатимемо, що черга одна. У наступній картці побачимо, що насправді їх дві.)

(For simplicity we will assume for now that there is one queue. In the next card we will see there are actually two.)

Event loop — не частина V8. Це поширена помилка. Event loop у Node.js реалізований бібліотекою libuv (C-бібліотека для асинхронного I/O), не самим JS-рушієм. V8 відповідає за виконання JavaScript, тримає call stack і обробляє microtask queue (продовження після await, .then()). Libuv координує коли передати керування назад у V8, забираючи готові задачі з черги.

The event loop is not part of V8. This is a common mistake. In Node.js the event loop is implemented by libuv (a C library for asynchronous I/O), not by the JS engine. V8 is responsible for executing JavaScript, holds the call stack and handles the microtask queue (continuations after await and .then()). Libuv coordinates when control goes back into V8, taking ready tasks off the queue.

Дійові особи, і де саме вони живуть:

The cast, and where each of them lives:

  • Call Stack — належить V8 (JS-рушію). Node.js використовує цей стек для виконання твого тестового коду.
  • Черга — тут з'являється продовження, коли приходить відповідь від браузера.
  • Браузер — окремий процес зі своїм власним call stack, який тебе як тестувальницю не стосується.
  • Event loop (у libuv) — координує, коли готова задача з черги передається назад у V8 на виконання.
  • Call Stack — belongs to V8 (the JS engine). Node.js uses that stack to run your test code.
  • The queue — where the continuation shows up when the answer from the browser arrives.
  • The browser — a separate process with its own call stack, which is none of your business as a tester.
  • The event loop (in libuv) — coordinates when a ready task from the queue is handed back to V8 to run.

Покроково, на прикладі await click(); await waitFor();:

Step by step, on the example await click(); await waitFor();:

Call Stack / Черга / Браузер — повний цикл двох await
Call Stack / Queue / Browser — the full cycle of two awaits
1. click() виконується                    │ —          │ —
2. відправила запит, звільнилась → СТЕК ПОРОЖНІЙ │ —  │ обробляє клік
3. порожній                                │ —          │ клік завершено, шле "готово"
4. порожній                                │ callback1 у черзі │ —
5. event loop передає callback1 у V8, той кладе на стек │ порожньо │ —
6. callback1 виконується: викликає waitFor()│ —         │ —
7. waitFor() відправила запит, стек порожній│ —         │ шукає елемент
8. порожній                                │ —          │ знайшов, шле "готово"
9. порожній                                │ callback2 у черзі │ —
10. event loop передає callback2 у V8      │ порожньо   │ —
1. click() runs                               │ —                  │ —
2. sent request, freed up → STACK EMPTY       │ —                  │ handling the click
3. empty                                      │ —                  │ click done, sends "done"
4. empty                                      │ callback1 in queue │ —
5. event loop passes callback1 to V8 → stack  │ empty              │ —
6. callback1 runs: calls waitFor()            │ —                  │ —
7. waitFor() sent request, stack empty        │ —                  │ looking for the element
8. empty                                      │ —                  │ found it, sends "done"
9. empty                                      │ callback2 in queue │ —
10. event loop passes callback2 to V8         │ empty              │ —

Чому callback не летить напряму на стек, а спочатку в чергу: JS виконує лише одну функцію за раз. Якби готовий код клався одразу на стек — він міг би влізти посеред того, що вже там виконується, і перервати на середині. Черга — буфер ввічливості: новий код чекає, поки стек не звільниться сам, і тільки тоді event loop його забирає.

Why the callback does not fly straight onto the stack but goes into the queue first: JS runs exactly one function at a time. If ready code were put straight onto the stack it could barge into the middle of whatever is already running there and cut it in half. The queue is a politeness buffer: new code waits until the stack clears on its own, and only then does the event loop pick it up.

🗣 Як читати вголос Event loop не частина V8, і це поширена помилка. У Node.js event loop реалізований бібліотекою libuv, окремою C-бібліотекою для асинхронного I/O. V8 відповідає за виконання JavaScript, тримає call stack і обробляє microtask queue. Libuv координує коли передати керування назад у V8, забираючи готові задачі з черги. Важливо розуміти де що фізично живе: call stack належить V8, Node.js його використовує для виконання твого тестового коду. Браузер має свій власний окремий call stack який нас не стосується. Кожен await в тесті це окремий повний цикл: відправили команду, стек звільнився, браузер обробляє, браузер відповів, callback потрапив у чергу, event loop переніс його назад у V8, код продовжився. І головне питання яке варто собі поставити: чому callback не летить одразу на стек, а спочатку чекає в черзі? Тому що JavaScript виконує лише одну функцію за раз, і якби готовий код клався прямо на стек, він міг би перервати посеред виконання щось, що там вже є. Черга гарантує що новий код завжди чекає, поки стек не звільниться сам природним шляхом.
🗣 How to say it out loud The event loop is not part of V8, and that is a common mistake. In Node.js it is implemented by libuv, a separate C library for asynchronous I/O. V8 executes the JavaScript, owns the call stack and handles the microtask queue; libuv coordinates when a ready task goes back into V8. It helps to be clear about where things physically live: the call stack belongs to V8 and Node uses it to run your test code, while the browser has its own separate stack that has nothing to do with you. Every await in a test is one full cycle: the command goes out, the stack clears, the browser does the work, the answer comes back, the continuation lands in the queue, the event loop hands it back to V8, and your code carries on. And the question worth asking yourself is why the callback waits in a queue instead of going straight onto the stack. Because JavaScript runs one function at a time, and if ready code jumped straight on it could cut into something already running there. The queue guarantees new code always waits until the stack clears on its own.
14 Мікротаски vs макротаски — чому JavaScript взагалі знадобилися дві черги? Microtasks vs macrotasks — why did JavaScript need two queues at all?
JS/TS Middle ▶

У попередній картці ми для простоти уявляли, що черга одна. Насправді їх дві. Питання не в тому, чим вони відрізняються. Питання в тому, чому однієї виявилось недостатньо.

In the previous card we pretended for simplicity that there is one queue. There are actually two. The question is not how they differ. The question is why one turned out not to be enough.

TypeScript — чому Promise випереджає setTimeout(0)
TypeScript — why a Promise beats setTimeout(0)
console.log('1');
setTimeout(() => console.log('2: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('4');

// Виведе: 1, 4, 3, 2
console.log('1');
setTimeout(() => console.log('2: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('4');

// Prints: 1, 4, 3, 2

Перше питання, яке виникає майже в усіх: чому Promise виконався раніше за setTimeout(0)? Адже таймер теж уже готовий.

The first question almost everybody asks: why did the Promise run before setTimeout(0)? The timer was ready too.

💡 JavaScript розділяє задачі не за швидкістю, а за роллю.

💡 JavaScript splits tasks by role, not by speed.

Продовження вже розпочатої роботи. У більшості коду, який ми пишемо (і практично у всьому Playwright), microtask використовується саме для продовження вже розпочатої асинхронної операції:

Continuing work that has already started. In most of the code we write (and in practically all of Playwright) a microtask is exactly that — the continuation of an asynchronous operation that already began:

TypeScript — продовження вже розпочатої роботи
TypeScript — continuing work that has already started
const text = await page.textContent();
// виконається тільки після відповіді браузера
console.log(text);
const text = await page.textContent();
// runs only after the browser responds
console.log(text);

console.log(text) не нова задача, а продовження тієї самої функції після завершення textContent(). Саме тому це microtask.

console.log(text) is not a new task, it is the continuation of the same function after textContent() finished. That is precisely why it is a microtask.

Macrotask — починає нову незалежну роботу. setTimeout(() => console.log('cleanup'), 0) — колбек таймера не продовжує жоден await. Це окреме прохання: "коли буде можливість — виконай ще й це".

A macrotask starts new, independent work. setTimeout(() => console.log('cleanup'), 0) — the timer's callback continues no await. It is a separate request: "when you get a chance, run this as well".

Саме через цю різницю ролей JavaScript завжди спершу закінчує весь поточний ланцюжок виконання (усі microtasks), і лише потім переходить до наступної незалежної задачі (macrotask).

It is this difference in role that makes JavaScript always finish the whole current chain of execution (every microtask) first, and only then move on to the next independent task (a macrotask).

💡 Не варто запам'ятовувати: "microtasks виконуються раніше". Краще запам'ятати причину: JavaScript спочатку завершує вже розпочату роботу, і лише потім починає нову.

💡 Not worth memorising: "microtasks run first". Better to remember the reason: JavaScript finishes the work it has already started before it begins anything new.

А хто всім цим керує? Відповідальність тут теж поділена: V8 відповідає за виконання JavaScript і microtasks, libuv відповідає за event loop і macrotasks.

And who runs all of this? The responsibility is split here too: V8 is responsible for executing JavaScript and microtasks, libuv is responsible for the event loop and macrotasks.

  • Microtask queue обробляє V8. Сюди потрапляє продовження після кожного await і кожного .then(). Оскільки майже всі дії Playwright (click, goto, fill, textContent) завершуються через Promise, їх продовження теж опиняється саме тут.
  • Macrotask queue координує event loop (libuv). Тут живуть setTimeout, setInterval. page.waitForTimeout() зрештою використовує цей самий механізм таймерів.
  • The microtask queue is handled by V8. This is where the continuation after every await and every .then() lands. Since nearly every Playwright action (click, goto, fill, textContent) finishes through a Promise, their continuations end up here as well.
  • The macrotask queue is coordinated by the event loop (libuv). This is where setTimeout and setInterval live. page.waitForTimeout() ends up using that same timer machinery.

(Технічна виноска: є рідкісний випадок queueMicrotask(), який дозволяє явно поставити в microtask queue будь-яку функцію, навіть таку, що нічого не "продовжує" — у 99% Playwright-коду це не зустрічається, тому основне правило вище тримається.)

(A technical footnote: there is the rare case of queueMicrotask(), which lets you put any function into the microtask queue explicitly, even one that "continues" nothing — in 99% of Playwright code you will not meet it, so the rule above holds.)

🗣 Як читати вголос У попередній картці ми для простоти уявляли, що черга одна, але насправді їх дві, і питання не в тому чим вони відрізняються, а в тому чому однієї виявилось недостатньо. JavaScript розділяє задачі не за швидкістю, а за роллю. Microtask використовується для продовження вже розпочатої асинхронної операції — наприклад код після await, який просто продовжує ту саму функцію після того як textContent завершився. Macrotask навпаки починає нову незалежну роботу — колбек таймера не продовжує жоден await, це окреме прохання виконати ще щось коли буде можливість. Саме через цю різницю ролей JavaScript завжди спершу закінчує весь поточний ланцюжок виконання, усі microtasks, і лише потім переходить до наступної незалежної задачі. Не варто запам'ятовувати що мікротаски виконуються раніше як окреме правило — краще запам'ятати причину: JavaScript спочатку завершує вже розпочату роботу, і лише потім починає нову. Відповідальність за це поділена: V8 відповідає за виконання JavaScript і мікротаски, libuv відповідає за event loop і макротаски.
🗣 How to say it out loud In the previous card we pretended there was one queue; there are actually two, and the interesting question is not how they differ but why one was not enough. JavaScript splits tasks by role, not by speed. A microtask is the continuation of asynchronous work that already started — the code after an await that simply carries on the same function once textContent came back. A macrotask starts new, independent work: a timer callback continues no await, it is a separate request to run something else when there is a chance. Because of that difference in role, JavaScript always finishes the current chain, every microtask, before it touches the next independent task. So I would not memorise that microtasks go first as a rule on its own — the reason is the useful part: finish what you started before starting something new. And the ownership is split too, V8 runs the JavaScript and the microtasks, libuv owns the event loop and the macrotasks.
15 Коли можна відправляти кілька запитів одночасно через Promise.all(), а коли не варто? When can you fire several requests at once with Promise.all(), and when should you not?
JS/TS Middle ▶

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

Because calling an async function is always synchronous and instant (it sends the command and frees the stack right away), you can fire several independent requests at once instead of waiting for each one in turn:

TypeScript — паралельне читання vs послідовне
TypeScript — reading in parallel vs in sequence
// ❌ послідовно — чекаємо кожен окремо, довше
const price = await page.locator('.price').textContent();
const stock = await page.locator('.stock').textContent();

// ✅ паралельно — обидва запити летять одночасно
const [price, stock] = await Promise.all([
  page.locator('.price').textContent(),
  page.locator('.stock').textContent(),
]);
// ❌ sequential — wait for each one separately, slower
const price = await page.locator('.price').textContent();
const stock = await page.locator('.stock').textContent();

// ✅ parallel — both requests fly at the same time
const [price, stock] = await Promise.all([
  page.locator('.price').textContent(),
  page.locator('.stock').textContent(),
]);

⚠️ Але НЕ клади кілька дій (не читання, а саме дій) в один Promise.all(). Playwright гарантує стабільність окремої дії через auto-waiting; якщо запустити дві дії одночасно на одній Page, вони можуть почати конкурувати за фокус, DOM або навігацію — це поводиться як кілька людей, що одночасно керують однією клавіатурою.

⚠️ But do NOT put several actions (not reads — actual actions) into one Promise.all(). Playwright guarantees the stability of a single action through auto-waiting; start two actions at once on the same Page and they can end up competing for focus, the DOM or navigation — it behaves like several people driving one keyboard.

TypeScript — правильно (waiter + trigger) vs небезпечно (дія + дія)
TypeScript — right (waiter + trigger) vs dangerous (action + action)
// ✅ Правильний патерн Promise.all: очікувач ПЕРЕД дією-тригером
const [response] = await Promise.all([
  page.waitForResponse('**/api/checkout'), // підписка йде ПЕРШОЮ
  page.locator('button.submit').click(),   // тригер — другим
]);
// Порядок важливий: якщо клік стоятиме першим, відповідь може
// прийти РАНІШЕ ніж waitForResponse встигне підписатись — і тест зависне

// ❌ Небезпечно: дві незалежні ДІЇ в одному Promise.all()
await Promise.all([
  page.locator('input#email').fill('test@test.com'),
  page.locator('input#name').fill('Test User'),
]);
// Обидва fill() б'ють по сторінці "одночасно" — непередбачувано,
// навіть якщо поля різні. Краще просто await по черзі — це швидко.
// ✅ Correct Promise.all pattern: the waiter BEFORE the triggering action
const [response] = await Promise.all([
  page.waitForResponse('**/api/checkout'), // subscription goes FIRST
  page.locator('button.submit').click(),   // trigger goes second
]);
// Order matters: if the click comes first, the response may
// arrive BEFORE waitForResponse subscribes — and the test hangs

// ❌ Risky: two independent ACTIONS in one Promise.all()
await Promise.all([
  page.locator('input#email').fill('test@test.com'),
  page.locator('input#name').fill('Test User'),
]);
// Both fill() calls hit the page "at the same time" — unpredictable,
// even if the fields differ. Just await them in turn — it's fast.
🗣 Як читати вголос Оскільки виклик асинхронної функції завжди синхронний і миттєвий, він відправляє команду і одразу звільняє стек, можна відправити кілька незалежних запитів одночасно замість того щоб чекати кожен по черзі, і саме для цього існує Promise.all. Але тут є важлива межа яку легко переступити: Playwright гарантує стабільність окремої дії через auto-waiting, і якщо запустити дві дії одночасно на одній сторінці, вони можуть почати конкурувати за фокус, DOM або навігацію — це поводиться як кілька людей що одночасно керують однією клавіатурою, непередбачувано, навіть якщо дії стосуються різних полів. Promise.all добре підходить для комбінації очікувач плюс тригер, наприклад чекаємо на мережеву відповідь і одночасно клікаємо кнопку яка цю відповідь викликає, і порядок тут важливий, очікувач має йти першим у масиві, інакше відповідь може прийти раніше ніж підписка встигне спрацювати. Для дій просто пиши await по черзі, це і так швидко.
🗣 How to say it out loud Calling an async function is synchronous and instant — it sends the command and frees the stack straight away — so you can fire several independent reads at once instead of waiting for each in turn, and that is what Promise.all is for. But there is a line here that is easy to cross: Playwright guarantees the stability of one action through auto-waiting, and if you start two actions at the same time on the same page they can start fighting over focus, the DOM or navigation. It behaves like several people on one keyboard, unpredictable, even when the actions touch different fields. Where Promise.all really fits is waiter plus trigger — wait for a network response and click the button that causes it in the same call. Order matters there, the waiter has to come first in the array, otherwise the response can land before the subscription is even set up. For plain actions I would just await them one after another; it is fast enough anyway.
16 Що таке серіалізація і навіщо вона потрібна при передачі даних між Node.js і браузером? What is serialisation and why is it needed to pass data between Node.js and the browser?
JS/TSJunior▶
  1. Об'єкт в JavaScript живе в оперативній пам'яті конкретного процесу як структура з адресами — { name → адреса 0x7f4b → 'Iuliia' }. Ці адреси мають сенс тільки всередині цього одного процесу.
  2. Мережа і файли передають байти. Якщо відправити адресу 0x7f4b — на іншому комп'ютері вона вкаже на іншу ділянку пам'яті або нікуди. Об'єкт прив'язаний до конкретної пам'яті процесу.
  3. Серіалізація — перетворення об'єкта з "адрес в пам'яті" в текст де значення записані явно. Текст не залежить від конкретної машини.
  4. Десеріалізація — зворотній процес: текст назад в об'єкт.
  5. JSON — найпоширеніший формат серіалізації у вебі. Виглядає майже як JavaScript об'єкт, розуміє будь-яка мова програмування і будь-який сервер.
  6. У Playwright серіалізація відбувається коли передаємо аргументи в evaluate() або waitForFunction() — Playwright перетворює JavaScript об'єкт в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад в об'єкт.
  1. A JavaScript object lives in the memory of one specific process as a structure of addresses — { name → address 0x7f4b → 'Iuliia' }. Those addresses only mean anything inside that one process.
  2. Networks and files carry bytes. Send the address 0x7f4b and on another machine it points at a different chunk of memory, or at nothing. The object is tied to one process's memory.
  3. Serialisation is turning an object from "addresses in memory" into text where the values are written out explicitly. Text does not depend on a particular machine.
  4. Deserialisation is the way back: text into an object again.
  5. JSON is the most common serialisation format on the web. It looks almost like a JavaScript object, and every programming language and every server understands it.
  6. In Playwright, serialisation happens when we pass arguments into evaluate() or waitForFunction() — Playwright turns the JavaScript object into JSON, sends it over the WebSocket to the browser, and the browser deserialises it back into an object.

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

An analogy: an object is a house in your town. You cannot "send a house" to a friend in another town. But you can draw its plans and send those — that is serialisation. Your friend receives the plans and builds the same house — that is deserialisation.

TypeScript — серіалізація і десеріалізація
TypeScript — serialisation and deserialisation
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 user = { name: 'Iuliia', age: 30 }; // object in memory

// Serialization — convert to a string (JSON text)
const text = JSON.stringify(user);
// text = '{"name":"Iuliia","age":30}'
// now it can be saved to a file or sent over the network

// Deserialization — convert the string back to an object
const obj = JSON.parse(text);
// obj = { name: 'Iuliia', age: 30 }
TypeScript — серіалізація в Playwright: передача аргументів між Node.js і браузером
TypeScript — serialisation in Playwright: passing arguments between Node.js and the browser
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!
);
const expected = 48; // variable in Node.js

// Playwright serializes 48 to JSON and sends it over WebSocket to the browser
// The browser deserializes it and gets the number 48 as a function argument
await page.waitForFunction(
  (count) => document.querySelectorAll('.product-card').length >= count,
  expected // ← serialized and sent to the browser
);

// Closures DON'T work across the process boundary — the function moves to the browser
// while the expected variable stays in Node.js (different processes, different memory)
await page.waitForFunction(
  () => document.querySelectorAll('.product-card').length >= expected // ❌ undefined!
);
🔧 TypeScript / JS патерни в цьому прикладі JSON.stringify(obj) — статичний метод класу JSON, серіалізує об'єкт в рядок; JSON.parse(text) — десеріалізує рядок назад в об'єкт; { name: 'Iuliia', age: 30 } — об'єктний літерал; серіалізація обмежена: функції, undefined, і посилання на DOM елементи серіалізувати не можна.
🔧 TypeScript / JS patterns in this example JSON.stringify(obj) — a static method on the JSON class, serialises an object into a string; JSON.parse(text) — deserialises a string back into an object; { name: 'Iuliia', age: 30 } — an object literal; serialisation has limits: functions, undefined and references to DOM elements cannot be serialised.
🗣 Як читати вголос Серіалізація — це перетворення об'єкта з оперативної пам'яті в текст для передачі через мережу або запису у файл. Об'єкт не можна передати напряму бо він існує як структура адрес в пам'яті конкретного процесу — ці адреси не мають сенсу в іншому процесі або на іншому комп'ютері. JSON це найпоширеніший формат серіалізації у вебі — JSON.stringify() перетворює об'єкт в рядок, JSON.parse() — назад. У Playwright це важливо при передачі аргументів в evaluate() або waitForFunction(): Playwright серіалізує значення в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад. Саме тому closure не працює через межу процесів — функція переїжджає в браузер, але змінні з Node.js залишаються в Node.js, і їх треба явно передавати як аргументи через серіалізацію.
🗣 How to say it out loud Serialisation is turning an object in memory into text so it can travel over a network or be written to a file. You cannot send the object itself, because it lives as a structure of memory addresses inside one process, and those addresses mean nothing in another process or on another machine. JSON is the common format on the web — JSON.stringify() turns an object into a string, JSON.parse() turns it back. In Playwright this matters when you pass arguments into evaluate() or waitForFunction(): Playwright serialises the value to JSON, sends it over the WebSocket, and the browser deserialises it on the other side. That is also the reason a closure does not survive the process boundary — the function travels to the browser but your Node variables stay in Node, so you have to pass them in explicitly as arguments and let them go through serialisation.
17 Що таке spread оператор ... і як він використовується в Playwright? What is the spread operator ... and how is it used in Playwright?
JS/TSJunior▶

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

The spread operator ... means "unwrap this and lay out the contents". It takes an array or an object and inserts all of its elements or fields somewhere new.

TypeScript — spread з масивами
TypeScript — spread with arrays
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 arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

// Without spread — an array inside an array
const bad = [arr1, arr2];          // [[1,2,3], [4,5,6]]

// With spread — unpack the contents
const good = [...arr1, ...arr2];   // [1, 2, 3, 4, 5, 6]
TypeScript — spread з об'єктами
TypeScript — spread with objects
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
const defaults = { color: 'black', size: 'M', price: 100 };
const override = { price: 200, sale: true };

// Spread unpacks the fields and inserts them
const result = { ...defaults, ...override };
// { color: 'black', size: 'M', price: 200, sale: true }
//                              ↑
// price is overwritten because override comes after defaults
TypeScript — spread в Playwright: ...devices['iPhone 14']
TypeScript — spread in Playwright: ...devices['iPhone 14']
// devices['iPhone 14'] — це готовий об'єкт:
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
// }

// Spread розгортає всі поля в наш об'єкт use:
use: { ...devices['iPhone 14'], locale: 'uk-UA' }
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
//   locale: 'uk-UA',  ← наше додаткове поле
// }
// Замість копіювати всі поля вручну — розгортаємо готовий профіль
// devices['iPhone 14'] is a ready-made object:
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
// }

// Spread unpacks all fields into our use object:
use: { ...devices['iPhone 14'], locale: 'uk-UA' }
// {
//   viewport: { width: 390, height: 844 },
//   userAgent: 'Mozilla/5.0 ...',
//   isMobile: true,
//   hasTouch: true,
//   locale: 'uk-UA',  ← our extra field
// }
// Instead of copying every field by hand — spread a ready-made profile
💡 Порядок важливий: якщо одне і те саме поле є в обох об'єктах — перемагає той що стоїть правіше:
{ ...defaults, price: 200 } → price буде 200 (наше значення перезаписує)
{ price: 200, ...defaults } → price буде зі defaults (defaults перезаписує наше)
💡 Order matters: if the same field is in both objects, the one further to the right wins:
{ ...defaults, price: 200 } → price is 200 (our value overwrites)
{ price: 200, ...defaults } → price comes from defaults (defaults overwrites ours)
🔧 TypeScript / JS патерни в цьому прикладі ...arr — spread масиву: вставляє елементи; ...obj — spread об'єкта: вставляє поля; { ...obj1, ...obj2 } — злиття об'єктів (merge); { ...obj, key: value } — копіювання об'єкта з додаванням або перезаписом поля; spread не мутує оригінальний об'єкт — створює новий.
🔧 TypeScript / JS patterns in this example ...arr — array spread: inserts the elements; ...obj — object spread: inserts the fields; { ...obj1, ...obj2 } — merging objects; { ...obj, key: value } — copying an object while adding or overwriting a field; spread does not mutate the original object, it creates a new one.
🗣 Як читати вголос Spread оператор — три крапки перед змінною — розгортає масив або об'єкт і вставляє весь його вміст в нове місце. З масивами: [...arr1, ...arr2] — замість масиву всередині масиву отримуємо плоский масив з усіма елементами. З об'єктами: { ...obj1, ...obj2 } — зливаємо два об'єкти в один, якщо є однакові поля — перемагає той що стоїть правіше. В Playwright найчастіше бачимо в конфігурації: { ...devices['iPhone 14'], locale: 'uk-UA' } — розгортаємо готовий профіль девайсу з усіма його полями і додаємо своє. Це набагато коротше ніж копіювати viewport, userAgent і isMobile вручну.
🗣 How to say it out loud The spread operator — three dots in front of a variable — unwraps an array or an object and drops all of its contents somewhere new. With arrays, [...arr1, ...arr2] gives you one flat array with every element instead of an array nested inside an array. With objects, { ...obj1, ...obj2 } merges the two into one, and where the same field appears twice the one further right wins. In Playwright you see it most often in config: { ...devices['iPhone 14'], locale: 'uk-UA' } — you spread the ready-made device profile with all its fields and add your own on top. That is a lot shorter than copying viewport, userAgent and isMobile by hand.
18 Що таке статичний метод і чим він відрізняється від звичайного? What is a static method and how is it different from a regular one?
JS/TSJunior▶

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

JavaScript has two kinds of methods — the ones you call on an instance of a class, and the ones you call on the class itself. The second kind are called static.

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

A regular method — first you create an object with new, then you call the method on it:

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

const str = new String('hello');
str.toUpperCase();          // метод екземпляру
const arr = new Array();  // created an instance
arr.push(1);               // call a method on the instance

const str = new String('hello');
str.toUpperCase();          // instance method

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

A static method — called straight on the class, with no instance created:

// Не треба: 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 — теж статичний
// Not needed: const j = new JSON(); j.stringify()
JSON.stringify(obj);     // static — called on the JSON class
JSON.parse(text);        // static

Math.random();           // static — no need for new Math()
Math.max(1, 2, 3);       // static

Array.isArray([]);       // static — checks whether it's an array

Promise.all([p1, p2]);   // static — runs several Promises in parallel

// In Playwright:
expect.poll(async () => {}); // poll — a static method on expect
expect.extend({});          // extend — also static

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

How to define a static method in your own class:

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
class MathHelper {
  // Static method — the static keyword
  static double(n: number) {
    return n * 2;
  }

  // Regular method
  add(n: number) {
    return n + 1;
  }
}

// Static — on the class:
MathHelper.double(5); // ✅ 10

// Regular — on the instance:
const m = new MathHelper();
m.add(5); // ✅ 6

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

An easier way to remember it: if what stands in front of the method is a capitalised class name, it is static: JSON.stringify, Math.random, Promise.all, Array.isArray, expect.poll. If it is a variable, it is an instance method: arr.push, str.toUpperCase.

🔧 TypeScript / JS патерни в цьому прикладі static methodName() {} — ключове слово static робить метод статичним в TypeScript/JS класі; ClassName.method() — виклик статичного методу на класі; instance.method() — виклик звичайного методу на екземплярі; new ClassName() — створення екземпляру.
🔧 TypeScript / JS patterns in this example static methodName() {} — the static keyword makes a method static in a TypeScript/JS class; ClassName.method() — calling a static method on the class; instance.method() — calling a regular method on an instance; new ClassName() — creating an instance.
🗣 Як читати вголос Статичний метод — це метод який викликається прямо на класі без створення екземпляру. Звичайний метод потребує спочатку створити об'єкт через new, потім викликати метод на ньому. Статичний — одразу на класі. Приклади статичних методів які зустрічаються скрізь: JSON.stringify() і JSON.parse() — ніхто не пише new JSON(); Math.random() і Math.max(); Array.isArray(); Promise.all(). В Playwright: expect.poll() і expect.extend() — статичні методи на об'єкті expect. Простий спосіб визначити: якщо перед методом стоїть назва класу з великої літери — статичний. Якщо змінна — звичайний метод екземпляру.
🗣 How to say it out loud A static method is one you call straight on the class, with no instance created. A regular method needs the object first — new something, then call the method on it. Static skips that step. The examples you run into constantly are JSON.stringify() and JSON.parse() — nobody writes new JSON() — plus Math.random(), Math.max(), Array.isArray() and Promise.all(). In Playwright it is expect.poll() and expect.extend(), static methods hanging off expect. The quick way to tell them apart: if what is in front of the dot is a capitalised class name it is static, and if it is a variable it is an instance method.
19 Що таке try/catch і коли його використовувати в тестах? What is try/catch and when should you use it in tests?
JS/TSJunior▶

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

try/catch is a way to handle an error without letting it stop the program. When the code inside try throws, execution moves into catch instead of stopping altogether.

TypeScript — базовий try/catch
TypeScript — basic 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('цей рядок виконається'); // ✅
// Without try/catch — the error stops everything
const data = JSON.parse('not JSON'); // ❌ throws SyntaxError
console.log('this line never runs');

// With try/catch — catch it and keep going
try {
  const data = JSON.parse('not JSON');
} catch (e) {
  console.log('failed to parse:', e.message);
}
console.log('this line runs'); // ✅
TypeScript — три частини: try / catch / finally
TypeScript — three parts: try / catch / finally
try {
  // код який може кинути помилку
} catch (e) {
  // що робити якщо помилка сталась
  // e — об'єкт помилки: e.message, e.name, e.stack
} finally {
  // виконується ЗАВЖДИ — і при помилці і без
  // типово для закриття з'єднань, файлів тощо
}
try {
  // code that may throw an error
} catch (e) {
  // what to do if an error occurred
  // e — the error object: e.message, e.name, e.stack
} finally {
  // ALWAYS runs — with or without an error
  // typically for closing connections, files, etc.
}
TypeScript — try/catch в Playwright тесті
TypeScript — try/catch in a Playwright test
// 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 тест
// Playwright throws TimeoutError if the element doesn't appear
try {
  await page.waitForSelector('.success-banner', { timeout: 3000 });
  console.log('operation succeeded');
} catch (e) {
  // .success-banner didn't appear within 3 seconds — keep going
  console.log('operation did not finish in time');
}

// ⚠️ When you DON'T need try/catch:
// Most Playwright methods already throw clear errors
// and the test fails with a clear message.
// try/catch is only needed when you want to RECOVER from an error
// and continue the test instead of letting it fail.

// ── About .rejects.toThrow() ──────────────────────────────────────
// Almost never needed in e-commerce UI tests.
// Unauthenticated user on /account → the app shows a UI error:
await page.goto('/account');
await expect(page).toHaveURL('/login');           // redirect
await expect(page.getByText('Please sign in')).toBeVisible(); // or a message
// Test what the user sees — no rejects.toThrow()
//
// .rejects.toThrow() — for testing JS functions that THROW exceptions:
await expect(getUser('')).rejects.toThrow('ID is required'); // unit test
🔧 TypeScript / JS патерни в цьому прикладі try { } catch (e) { } — синтаксис обробки помилок; e.message — рядок з описом помилки; e.name — назва класу помилки (наприклад 'TimeoutError'); finally — блок що виконується завжди незалежно від результату; throw e — перекинути помилку далі (деталі в наступній картці).
🔧 TypeScript / JS patterns in this example try { } catch (e) { } — the error-handling syntax; e.message — a string describing the error; e.name — the error class name (for example 'TimeoutError'); finally — a block that always runs whatever the outcome; throw e — rethrowing the error further up (details in the next card).
🗣 Як читати вголос try/catch — механізм обробки помилок: код що може кинути помилку кладемо в блок try, якщо помилка сталась — виконання переходить в catch і програма продовжується. Об'єкт помилки e містить message, name і stack. Блок finally виконується завжди — корисний для очищення ресурсів. В Playwright try/catch використовується рідко бо більшість методів вже падають з зрозумілим повідомленням. Потрібен коли хочеш перехопити конкретну помилку і відновитись — наприклад якщо елемент може і не з'явитись і це нормальна ситуація. Детальніше про класи помилок Playwright — в наступній картці.
🗣 How to say it out loud try/catch is the error-handling mechanism: code that might throw goes into the try block, and if it throws, execution moves into catch and the program carries on. The error object e gives you message, name and stack. The finally block always runs, which makes it handy for cleaning up resources. In Playwright I reach for try/catch rarely, because most methods already fail with a clear message. It earns its place when you want to catch a specific error and recover — say an element may or may not appear and both outcomes are legitimate. The Playwright error classes themselves are covered in the next card.
▶ 3 13
20 Які основні локатори є в Playwright? Що таке семантичні локатори? What are the main locators in Playwright? What are semantic locators?
ЛокаториLocatorsJunior▶

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

A locator is a way to find an element on the page. It is not the element itself, it is the instruction "go find me this one". Playwright looks the element up again every single time you perform an action — a click, a fill, a check.

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

Playwright recommends semantic locators — the ones that find an element by its meaning and role rather than by a CSS class or a position in the DOM. Why? Because CSS classes change all the time during a refactor, and the role of a "Submit" button does not.

TypeScript — типи локаторів з поясненням кожного
TypeScript — the locator types, with what each one does
// ✅ Семантичні — шукають за змістом і роллю, стійкі до змін верстки

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)') // впаде при будь-якому рефакторингу
// ✅ Semantic — find by meaning and role, resilient to markup changes

page.getByRole('button', { name: 'Submit' })
// Finds an element with ARIA role 'button' and text 'Submit'
// ARIA role is the element's semantic meaning for screen readers

page.getByLabel('Email address')
// Finds the <input> associated with the "Email address" label
// In HTML: <label for="email">Email address</label><input id="email">
// The label's for attribute points to the input's id — that link is what getByLabel looks for

page.getByPlaceholder('Enter email')
// Finds an input with placeholder="Enter email"

page.getByText('Welcome back')
// Finds any element containing this text

page.getByTestId('submit-btn')
// Finds an element with data-testid="submit-btn"
// Developers add it specifically for tests
// Defaults to 'data-testid', but you can change it in the config:
// defineConfig({ use: { testIdAttribute: 'data-qa' } })
// Then getByTestId('submit-btn') will look for data-qa="submit-btn"

// ⚠️ Acceptable — CSS and XPath, but more brittle
page.locator('.btn-primary')             // CSS class may change
page.locator('xpath=//button[@type="submit"]') // XPath is harder to read

// ❌ Avoid — tied to the DOM structure
page.locator('.sc-a8f9b2 > div:nth-child(3)') // breaks on any refactor
💡 Порада: getByRole відповідає ARIA accessibility tree — це найстійкіший локатор і він одночасно тестує доступність. Якщо не можеш знайти елемент через getByRole — можливо він не має правильної семантики в HTML.
💡 Tip: getByRole maps onto the ARIA accessibility tree — it is the most resilient locator and it tests accessibility at the same time. If you cannot find an element through getByRole, chances are it does not have the right semantics in the HTML.
🔧 TypeScript / JS патерни в цьому прикладі Немає складних JS конструкцій — це список API методів. Важливий патерн: page.getByRole('button', { name: 'Submit' }) — передача об'єкта як другого аргументу (options object pattern); { name: 'Submit' } — об'єктний літерал (object literal) як конфігурація.
🔧 TypeScript / JS patterns in this example No tricky JS constructs here — it is a list of API methods. The pattern that matters: page.getByRole('button', { name: 'Submit' }) — passing an object as the second argument (the options object pattern); { name: 'Submit' } — an object literal used as configuration.
🗣 Як читати вголос Локатор — це інструкція як знайти елемент на сторінці, а не сам елемент. Playwright рекомендує семантичні локатори бо вони шукають елементи за змістом і роллю а не за CSS класами які часто змінюються. getByRole шукає за ARIA роллю — наприклад 'button' або 'textbox' — і опційним ім'ям. getByLabel шукає input пов'язаний з лейблом — в HTML це зв'язок через атрибут for у лейблі який вказує на id інпута. getByTestId шукає за атрибутом data-testid який розробники додають спеціально для тестів. CSS і XPath допустимі але крихкіші — якщо верстка зміниться локатор впаде.
🗣 How to say it out loud A locator is an instruction for how to find an element on the page, not the element itself. Playwright pushes you toward semantic locators because they match on meaning and role instead of CSS classes, which change constantly. getByRole matches the ARIA role — 'button' or 'textbox', say — plus an optional name. getByLabel finds the input tied to a label; in the HTML that link is the for attribute on the label pointing at the input's id. getByTestId matches data-testid, which developers add specifically for tests. CSS and XPath still work, but they're more brittle — change the markup and the locator breaks.
21 Що таке strict mode в локаторах і чому Playwright падає, якщо знайдено 2+ елементи? What is strict mode in locators, and why does Playwright fail when it finds two or more elements?
ЛокаториLocatorsJunior▶

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

When a locator matches more than one element and you try to act on it, Playwright fails on purpose. That is strict mode.

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

Selenium has findElement — it silently takes the first element it found if there were several. That looks convenient, but it is a source of hidden bugs: the test is "green" while it clicks a different button than the one you meant.

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

Playwright went the other way: an explicit failure now beats a quiet bug later. If a locator matched several elements, that is a signal the locator is not specific enough. Make it specific.

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

Where strict mode actually applies: it is a property of the Locator API. Every action (click, fill) and every web-first assertion on a locator requires it to resolve to exactly one element — otherwise you get a strict mode violation. The exception is the methods that work with a list by design: count(), all(), toHaveCount(). The old ElementHandle API (page.$()) is not covered by strict mode at all — it silently takes the first element, like Selenium. One more reason not to use it.

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

⚠️ About nth() and first(): these are not the universal answer to a strict mode violation, they are often a way to mute the symptom. Use nth() only when the order really is part of the business logic ("the first item in the recommendations", "the third step of checkout"). If you write nth(2) just to stop the test failing, the locator will break on the very next markup change. Find something more stable instead: filter({ hasText }), getByRole with a name, or getByTestId.

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

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

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

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

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

// ✅ Або працюємо з усім списком свідомо (count/all)
const count = await page.locator('.product-card').count();
// ❌ If the page has several .product-card elements — fails with an error
await page.locator('.product-card').click();
// Error: strict mode violation: locator resolved to 12 elements

// ✅ Narrow down to a specific element
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();

// ✅ Modern way — compose locators with filter()
// filter({ hasText }) — a card that CONTAINS this text anywhere inside
await page.getByRole('listitem')
  .filter({ hasText: 'Trench Coat' })
  .getByRole('button', { name: 'Add to Bag' })
  .click();

// filter({ has: locator }) — a card that CONTAINS another element
// "find the card with a Sale badge and click the button in it"
await page.locator('.product-card')
  .filter({ has: page.locator('.sale-badge') })
  .getByRole('button', { name: 'Add to Bag' })
  .click();

// filter({ hasNot }) / filter({ hasNotText }) — the inverse
const inStock = page.locator('.product-card')
  .filter({ hasNotText: 'Out of stock' });

// ✅ Or work with the whole list deliberately (count/all)
const count = await page.locator('.product-card').count();
💡 Чому це важливо: Selenium мовчки брав перший елемент зі списку — тест "проходив" але клікав не туди. Playwright навмисно ламає тест одразу. Strict mode — це не перешкода, а сигнал: "твій локатор знайшов кілька елементів, уточни який саме тобі потрібен".
💡 Why this matters: Selenium silently took the first element off the list — the test "passed" while clicking the wrong thing. Playwright breaks the test immediately, on purpose. Strict mode is not an obstacle, it is a signal: "your locator matched several elements, say which one you actually want".
🔧 TypeScript / JS патерни в цьому прикладі handle?. — optional chaining: викликаємо метод тільки якщо handle не null/undefined; page.$() повертає ElementHandle | null — union type в TypeScript; const locator = page.locator() — зверни увагу: БЕЗ await, бо locator синхронний; await з'являється тільки на .click().
🔧 TypeScript / JS patterns in this example handle?. — optional chaining: call the method only if handle is not null/undefined; page.$() returns ElementHandle | null — a union type in TypeScript; const locator = page.locator() — note there is NO await, because creating a locator is synchronous; the await only shows up on .click().
🗣 Як читати вголос Strict mode — це поведінка за замовчуванням у Playwright: якщо локатор резолвиться у більше ніж один елемент, а ми викликаємо дію типу click чи fill, тест одразу падає з явною помилкою, а не мовчки бере перший елемент, як це робив би Selenium. Це навмисний дизайн-вибір: краще явний фейл на етапі написання тесту, ніж прихований баг, коли тест технічно "зелений", але клікає не на той елемент, який малося на увазі. Щоб обійти strict mode правильно, є кілька варіантів: .first() чи .nth(index), якщо порядок елементів стабільний і важливий; .filter({ hasText: ... }), якщо потрібен конкретний елемент за вмістом, що набагато надійніше за індекс; або .count(), якщо нам взагалі потрібна кількість, а не дія над одним елементом. Головний меседж — strict mode це не перешкода, а сигнал, що локатор недостатньо специфічний для того, що ми хочемо зробити.
🗣 How to say it out loud Strict mode is the default in Playwright: if a locator resolves to more than one element and we call an action like click or fill, the test fails right there with an explicit error instead of quietly grabbing the first match the way Selenium would. It's a deliberate design choice — an explicit failure while you're writing the test beats a hidden bug where the test is technically green but clicking the wrong element. To get past strict mode properly you've got a few options: .first() or .nth(index) if the order is stable and actually meaningful; .filter({ hasText: ... }) if you want a specific element by its content, which is far more reliable than an index; or .count() if what you really need is the number, not an action on one element. The main point is that strict mode isn't an obstacle, it's telling you the locator isn't specific enough for what you're trying to do.
22 Що таке ElementHandle, чи є він у Playwright і чому його не треба використовувати? What is ElementHandle, does Playwright have it, and why should you not use it?
ЛокаториLocatorsJunior▶
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 does exist in Playwright. The wording matters: it is not deprecated — the official Playwright docs call it discouraged, meaning it is a legacy API that is still supported, but the Playwright team recommends the Locator API for almost every scenario. It exists because Playwright started with a Puppeteer-shaped API, where page.$() returned exactly an ElementHandle. Later the Playwright team came up with a better abstraction — Locator — and the old one stayed for backwards compatibility.

There are still cases where an ElementHandle is justified: boundingBox() — get the element's coordinates, ownerFrame() — work out which frame an element is in, contentFrame() — get the Frame for an iframe element, evaluate(fn) — pass a specific DOM node into the browser context.
TypeScript — в чому фундаментальна різниця
TypeScript — where the fundamental difference is
// ── 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(); // ✅ завжди знаходить актуальний елемент
// ── ElementHandle ── a SNAPSHOT of the element, taken right now ──
// page.$() finds the element IMMEDIATELY and returns a reference to a specific
// DOM node — the one that exists AT THIS MILLISECOND in browser memory
const handle = await page.$('.buy-button');

// Imagine: React re-rendered the component between this line and the next.
// The old DOM node is destroyed, a new one created in its place.
// But handle still references the OLD one, which is gone.
await handle?.click(); // ❌ click on a "ghost" — an error or nothing happens

// ── Locator ── an INSTRUCTION for finding the element ──
// page.locator() does NOT look for the element now. It just remembers
// the "recipe": find the element with class .buy-button
const locator = page.locator('.buy-button'); // nothing happened, no await!

// Only here does Playwright actually look for the element — afresh, right now
// and immediately waits until it's visible and clickable (auto-waiting)
await locator.click(); // ✅ always finds the current element
TypeScript — як розпізнати старий стиль у чужому коді
TypeScript — how to spot the old style in someone else's code
// ❌ Старий стиль (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(...)" — це теж помилка розуміння
// ❌ Old style (Puppeteer-era) — legacy API, discouraged:
const btn = await page.$('button.submit');   // $ returns ElementHandle | null
const btns = await page.$$('li.item');         // $$ returns ElementHandle[]
await btn?.click();                              // ?. because it may be null

// ✅ Modern style — always, no exceptions:
const btn = page.locator('button.submit');    // no await — locator is synchronous!
const btns = page.locator('li.item');          // no await here either
await btn.click();                               // await only here, on the action

// Important: page.locator() is synchronous, no await
// await appears only when you call an action: .click(), .fill(), .toBeVisible()
// If someone writes "await page.locator(...)" — that's a misunderstanding too
💡 Просте правило для code review: бачиш await page.$() або await page.$$() — це legacy API (документація Playwright називає його discouraged, не deprecated), треба рефакторити на page.locator(). Бачиш await page.locator() — людина переплутала: locator() синхронний, await тут зайвий.
💡 A simple code review rule: you see await page.$() or await page.$$() — that is the legacy API (the Playwright docs call it discouraged, not deprecated), refactor it to page.locator(). You see await page.locator() — someone got confused: locator() is synchronous, the await there does nothing.
🔧 TypeScript / JS patterns in this example page.$('selector') — a method returning Promise<ElementHandle | null>; handle?.click() — optional chaining for a safe call; page.locator() with NO await — a synchronous function that hands back a Locator object straight away; the rule: methods returning a Promise need await, synchronous ones do not.
🔧 TypeScript / JS патерни в цьому прикладі page.$('selector') — метод що повертає Promise; handle?.click() — optional chaining для безпечного виклику; page.locator() БЕЗ await — синхронна функція яка повертає Locator об'єкт одразу; різниця: методи що повертають Promise потребують await, синхронні — ні.
🗣 Як читати вголос ElementHandle у Playwright є, і тут важливе точне формулювання: він не deprecated — документація називає його discouraged, тобто не рекомендований. Це legacy API яке підтримується, але Playwright Team радить Locator майже для всіх сценаріїв. Він з'явився тому, що Playwright спочатку мав API схожий на Puppeteer: page.$() знаходив елемент прямо зараз і повертав посилання на конкретний DOM-вузол, який існує в цю мілісекунду. Проблема в тому, що сучасні фронтенд-фреймворки — React, Vue — постійно перерендеровують DOM: старі вузли знищуються, нові створюються на їх місці. Якщо між моментом коли ти взяла ElementHandle і моментом коли ти кликаєш відбувся перерендер — handle тримає посилання на вузол, якого вже немає в DOM, і дія або впаде з помилкою, або клікне в нікуди. Locator вирішує це фундаментально: page.locator('.buy-button') взагалі нічого не шукає одразу — і тому не потребує await, він синхронний. Він просто запам'ятовує інструкцію "знайди елемент з таким класом". Тільки коли ти викликаєш дію — click(), fill() — ось тоді Playwright виконує пошук заново, прямо перед дією, і одразу застосовує auto-waiting. Тому в code review await page.$() — це завжди червоний прапорець: або людина прийшла з Puppeteer і ще не перебудувалась, або написала старий код який треба рефакторити.
🗣 How to say it out loud ElementHandle does exist in Playwright, and the precise wording matters here: it isn't deprecated, the docs call it discouraged. It's a legacy API that still works, but the Playwright team steers you to Locator for almost everything. It's there because Playwright originally had a Puppeteer-style API: page.$() looked the element up right then and gave you back a reference to one specific DOM node, the one that exists at that millisecond. The problem is that modern front-end frameworks like React and Vue re-render the DOM constantly — old nodes get thrown away and new ones take their place. If a re-render happens between the moment you grabbed the ElementHandle and the moment you click, the handle is pointing at a node that isn't in the DOM any more, so the action either throws or clicks into nothing. Locator fixes that at the root: page.locator('.buy-button') doesn't search for anything yet — that's why it needs no await, it's synchronous. It just remembers the instruction "find the element with this class". Only when you call an action, click() or fill(), does Playwright do the lookup, right before the action, with auto-waiting applied. So in review, await page.$() is always a red flag: either someone came over from Puppeteer and hasn't retrained, or it's old code that needs refactoring.
23 Що таке auto-waiting в Playwright і як він працює? What is auto-waiting in Playwright and how does it work?
ЛокаториLocatorsJunior▶

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

Before every interaction with an element Playwright runs what it calls actionability checks — a set of checks that the element is ready to be acted on. For most actions that means: the element is in the DOM (attached), visible (visible), not animating (stable), and enabled (enabled). For fill() there is one more — editable (editable); for a click — it can receive events (receives events, i.e. nothing is covering it). The default timeout is 30 seconds. Which means most of the time you write no explicit waits at all — you click, and Playwright sorts it out.

Але є ситуації коли waitFor() потрібен явно:But there are situations where you do need waitFor() explicitly:

  • Чекаємо зміну URL — після кліку відбувається навігація на іншу сторінку
  • Waiting for a URL change — the click navigates to a different page
  • Чекаємо стан завантаження — коли важливо що всі запити завершились
  • Waiting for a load state — when it matters that every request has finished
  • Чекаємо появу або зникнення елемента явно — коли елемент не є ціллю дії, а просто індикатором стану (наприклад toast-повідомлення)
  • Waiting for an element to appear or disappear explicitly — when the element is not the target of the action but a state indicator (a toast message, say)
  • Чекаємо довільну JS умову — через waitForFunction() коли немає DOM-елемента для перевірки
  • Waiting for an arbitrary JS condition — via waitForFunction(), when there is no DOM element to check against

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

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

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

⚠️ { force: true } is almost always an anti-pattern. It turns the actionability checks off: Playwright stops checking whether the element is visible, whether something covers it, whether it is ready at all — it just fires an event at the coordinates. That is not "making the test faster", it is switching the safety net off.

If force: true "fixed" a test, the test now passes despite a real problem: the element is covered by a modal, the button is still disabled, an overlay never went away. A user in that situation could not have clicked — and the test says everything is fine.

The legitimate cases are rare: deliberately testing a click on a partially covered element, or working around a known bug in a third-party widget. And then there should be a comment next to it saying why.

TypeScript — auto-waiting і коли waitFor() потрібен
TypeScript — auto-waiting, and when waitFor() is needed
// ✅ 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
// ✅ Auto-waiting — just click, Playwright waits on its own
await page.getByRole('button', { name: 'Save' }).click();
// Playwright checks: is the button in the DOM? visible? not animating? enabled?
// It clicks only once all conditions are met

// ── When you do need an explicit waitFor() ──────────────────────

// 1. Wait for navigation to another page
await page.waitForURL('**/dashboard');
// After clicking Login, wait until the URL changes to /dashboard

// 2. Wait for a load state
await page.waitForLoadState('domcontentloaded');
// 'domcontentloaded' — HTML is loaded and parsed
// 'networkidle' — no network requests for 500ms (careful, it can hang)

// 3. Wait for an indicator element (to check state, not to act on it)
await page.locator('.toast-success').waitFor({ state: 'visible' });
// Toast appeared — the action succeeded
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
// Spinner gone — loading has finished

// 4. Wait for a JS condition in the browser (see the Middle card on waitForFunction)
await page.waitForFunction(() => (window as any).dataLayer?.length > 0);
// No DOM element to wait on — check directly via JavaScript
🔧 TypeScript / JS патерни в цьому прикладі async ({ page }) => {} — деструктуризація fixtures; { state: 'visible' } — options object pattern; waitForFunction(() => {}) — передача стрілочної функції як аргумент (функція першого класу / first-class function); (window as any) — type assertion в TypeScript: говоримо компілятору "довіряй мені, це any".
🔧 TypeScript / JS patterns in this example async ({ page }) => {} — destructuring fixtures; { state: 'visible' } — the options object pattern; waitForFunction(() => {}) — passing an arrow function as an argument (a first-class function); (window as any) — a type assertion in TypeScript: telling the compiler "trust me, this is any".
🗣 Як читати вголос Перший рядок — просто клікаємо кнопку, Playwright сам чекає поки вона стане видимою і активною, нічого додаткового не треба. Далі waitForURL — явно чекаємо поки URL відповідає патерну, зірочки це wildcard. waitForLoadState('networkidle') — чекаємо поки мережа затихне, тобто немає активних запитів більше 500мс. В конфігу timeout: 60_000 — це глобальний ліміт на весь тест у мілісекундах, expect.timeout — окремий ліміт на кожен expect.
🗣 How to say it out loud The first line just clicks the button — Playwright waits for it to become visible and enabled on its own, nothing extra needed. Then waitForURL waits explicitly until the URL matches the pattern, where the asterisks are wildcards. waitForLoadState('networkidle') waits for the network to go quiet, meaning no active requests for more than 500ms. In the config, timeout: 60_000 is the global limit for the whole test in milliseconds, and expect.timeout is a separate limit on each individual expect.
24 Що не так з page.waitForTimeout()? Чому це антипатерн? What is wrong with page.waitForTimeout()? Why is it an anti-pattern?
ЛокаториLocatorsJunior▶

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

page.waitForTimeout(3000) is a fixed pause: the test stops for exactly 3 seconds whether the UI is ready or not. It is an anti-pattern for three reasons: if the UI is ready in half a second you burn 2.5 seconds for nothing; if it is not ready in 3 seconds the test fails; and the tests get slow and flaky at the same time.

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

Playwright has auto-waiting — it waits for the element to be ready to interact with on its own. So almost every time, there is a better alternative to waitForTimeout.

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

// ✅ Чекаємо саме той стан, який нам потрібен
await page.click('.add-to-cart');
await expect(page.locator('.toast')).toBeVisible(); // сама ретраїться, без зайвої затримки
// ❌ Anti-pattern: a fixed "fingers crossed" pause
await page.click('.add-to-cart');
await page.waitForTimeout(2000); // wait 2s and hope the toast has appeared by then
await expect(page.locator('.toast')).toBeVisible();

// ✅ Wait for exactly the state we need
await page.click('.add-to-cart');
await expect(page.locator('.toast')).toBeVisible(); // retries on its own, no extra delay
💡 Дві проблеми одразу: якщо 2000мс замало — тест все одно flaky; якщо забагато — тест уповільнюється даремно на кожному прогоні. Web-first assertion вирішує обидві: чекає рівно стільки, скільки треба, не більше.
💡 Two problems at once: if 2000ms is not enough, the test is flaky anyway; if it is too much, the test is slowed down for nothing on every single run. A web-first assertion solves both: it waits exactly as long as it has to, and no longer.
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForTimeout(3000) — фіксована пауза в мілісекундах — АНТИПАТЕРН; await expect(locator).toBeVisible() — правильна альтернатива з auto-waiting; різниця: waitForTimeout завжди чекає 3 секунди, expect чекає рівно стільки скільки потрібно (до таймауту).
🔧 TypeScript / JS patterns in this example await page.waitForTimeout(3000) — a fixed pause in milliseconds — ANTI-PATTERN; await expect(locator).toBeVisible() — the right alternative, with auto-waiting; the difference: waitForTimeout always waits the full 3 seconds, expect waits exactly as long as it needs to (up to the timeout).
🗣 Як читати вголос waitForTimeout — це фіксована пауза, яка не привʼязана до жодного реального стану сторінки, вона просто блокує виконання на задану кількість мілісекунд і сподівається, що за цей час потрібна подія відбудеться. Проблема в тому, що це число завжди вгадане: якщо мережа повільніша звичайного — двох секунд може не вистачити і тест впаде, а якщо забагато — тест буде штучно повільним на кожному прогоні, навіть коли все відбувається миттєво. Web-first assertion типу expect(locator).toBeVisible() вирішує обидві проблеми одночасно, бо чекає не фіксований час, а саме ту умову, яка нам потрібна, і завершується одразу, як тільки вона виконалась, або падає з чітким таймаутом, якщо не виконалась взагалі. Тому в код-рев'ю waitForTimeout — це майже завжди червоний прапорець, окрім дуже рідкісних кейсів типу навмисного очікування анімації, для якої немає detectable DOM-стану.
🗣 How to say it out loud waitForTimeout is a fixed pause that isn't tied to any real state of the page — it just blocks for a given number of milliseconds and hopes the thing you're waiting for happened by then. The trouble is that the number is always a guess: if the network is slower than usual two seconds might not be enough and the test fails, and if it's too generous the test is artificially slow on every run, even when everything happens instantly. A web-first assertion like expect(locator).toBeVisible() solves both at once, because it waits on the actual condition rather than on the clock — it finishes the moment the condition holds, or fails with a clear timeout if it never does. So in review waitForTimeout is almost always a red flag, apart from the rare case where you're deliberately waiting out an animation that has no detectable DOM state.
25 Чому waitForLoadState('networkidle') вважається пасткою, а не надійним рішенням? Why is waitForLoadState('networkidle') considered a trap rather than a reliable solution?
ЛокаториLocatorsJunior▶

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

waitForLoadState('networkidle') is a state that means "no network request for 500 milliseconds". It looks like a handy way to wait until "everything has loaded". In practice it is a common anti-pattern.

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

The problem: modern sites have constant background traffic — analytics posting data, a WebSocket holding a connection open, polling refreshing data. That "500ms of quiet" may never arrive — and the test hangs until it times out.

TypeScript — networkidle і SPA з фоновим трафіком
TypeScript — networkidle and an SPA with background traffic
// ❌ Здається надійним, але офіційно НЕ рекомендовано для 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(), // дія що тригерить запит
]);
// ❌ Looks reliable, but officially NOT recommended for SPAs
await page.goto('/product/123');
await page.waitForLoadState('networkidle');
// If the page has polling, analytics beacons, or a chat widget with
// periodic requests, "idle" (0 requests for 500ms) may NEVER happen

// ✅ Wait for a concrete business-level readiness signal
await page.goto('/product/123');
await expect(page.locator('[data-testid="pdp-price"]')).toBeVisible();

// waitForResponse — waits for a specific request, but:
// ⚠️ it does NOT guarantee the UI has rendered the response yet!
// You still need to wait for an element, or use Promise.all
// to subscribe BEFORE the action that triggers the request:
const [response] = await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/product')),
  page.locator('.load-more').click(), // the action that triggers the request
]);
💡 Офіційна позиція Playwright: документація прямо застерігає від networkidle для сучасних SPA — рекомендується чекати конкретний елемент чи мережевий запит, а не "тишу в мережі" загалом.
💡 Playwright's official position: the docs explicitly warn against networkidle for modern SPAs — the recommendation is to wait for a specific element or a specific network request, not for "quiet on the network" in general.
🔧 TypeScript / JS патерни в цьому прикладі waitForLoadState('networkidle') — рядкове enum значення; умова networkidle: жодного мережевого запиту 500мс; проблема: analytics, websocket, polling — постійні запити що не дають "idle" настати; альтернатива: waitForURL() або waitForSelector().
🔧 TypeScript / JS patterns in this example waitForLoadState('networkidle') — a string enum value; the networkidle condition: no network request for 500ms; the problem: analytics, websockets, polling — constant requests that never let "idle" happen; the alternative: waitForURL() or waitForSelector().
🗣 Як читати вголос networkidle означає "немає мережевих запитів останні 500мс" — звучить як надійний сигнал "сторінка повністю завантажилась", але на практиці сучасні SPA рідко справді затихають: аналітика типу GTM шле beacon-запити періодично, чат-віджети тримають long-polling з'єднання, якісь сторонні скрипти роблять фонові виклики нескінченно. Якщо такий фоновий трафік є, умова "0 активних запитів 500мс" може просто ніколи не виконатись, і тест зависне до таймауту замість швидкого фейлу або проходження. Тому правильний підхід — не чекати абстрактну тишу в мережі, а чекати конкретний сигнал, який реально означає готовність: видимість ключового елемента на сторінці через web-first assertion, або явне очікування конкретної мережевої відповіді через waitForResponse, якщо нас цікавить саме дані з певного API endpoint. Це і швидше, бо не чекає зайвого, і надійніше, бо привʼязано до того, що нам справді потрібно для тесту.
🗣 How to say it out loud networkidle means "no network requests in the last 500ms", which sounds like a solid signal that the page has finished loading, but in practice modern SPAs rarely go quiet: analytics like GTM fires beacon requests on a schedule, chat widgets hold long-polling connections open, and some third-party script is making background calls forever. If that background traffic exists, the "zero active requests for 500ms" condition may simply never be met, and the test hangs until the timeout instead of failing fast or passing. So the right move isn't to wait for abstract quiet on the network but to wait for a concrete signal that actually means ready: a key element being visible, through a web-first assertion, or an explicit wait on a specific network response with waitForResponse if what you care about is data from a particular API endpoint. That's faster, because you're not waiting for anything extra, and more reliable, because it's tied to what the test actually needs.
26 Скільки рівнів таймаутів є в Playwright і чому тест падає попри те що ти поставила timeout: 60000? How many levels of timeout does Playwright have, and why does a test still fail when you have set timeout: 60000?
ЛокаториLocatorsSenior▶

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

Most people think Playwright has one timeout. It actually has three, and they are independent of each other. The classic situation: you set timeout: 60000 in the config and the test still fails after 5 seconds — and you cannot see why. The answer: you changed the wrong timeout.

  • Test timeout — скільки часу має весь тест від першого до останнього рядка. Якщо тест не завершився за цей час — Playwright його вбиває. За замовчуванням 30 секунд.
  • Test timeout — how long the whole test gets, from the first line to the last. If the test has not finished by then, Playwright kills it. 30 seconds by default.
  • Action timeout — скільки Playwright чекає поки окрема дія виконається: click(), fill(), hover(). Це той самий auto-waiting. За замовчуванням немає окремого обмеження (наслідує test timeout), але можна встановити явно через use: { actionTimeout }.
  • Action timeout — how long Playwright waits for a single action to go through: click(), fill(), hover(). This is the auto-waiting one. By default there is no separate limit (it inherits the test timeout), but you can set it explicitly through use: { actionTimeout }.
  • Expect timeout — скільки web-first assertion ретраїться перш ніж здатись. expect(locator).toBeVisible() перевіряє умову знову і знову. За замовчуванням 5 секунд.
  • Expect timeout — how long a web-first assertion retries before giving up. expect(locator).toBeVisible() re-checks the condition over and over. 5 seconds by default.
  • Navigation timeout — окремий таймаут для goto(), waitForURL(), waitForNavigation(). Встановлюється через use: { navigationTimeout }. Часто пропускають — а саме він винен коли goto() падає на повільному стейджі.
  • Navigation timeout — a separate timeout for goto(), waitForURL(), waitForNavigation(). Set through use: { navigationTimeout }. People often miss it — and it is exactly the one to blame when goto() fails on a slow staging environment.
  • Global timeout — ліміт на весь прогін цілком, а не на один тест: globalTimeout: 3_600_000 в конфізі. Захищає CI від зависання на години якщо щось пішло зовсім не так.
  • Global timeout — a limit on the entire run, not on one test: globalTimeout: 3_600_000 in the config. It protects CI from hanging for hours when something has gone badly wrong.

Ієрархія перевизначення — від загального до конкретного:The override hierarchy — from the general to the specific:

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

// Чим конкретніше — тим вищий пріоритет. Найточніший виграє.
globalTimeout (entire run)
    ↓
Config: timeout / expect.timeout / use.actionTimeout / use.navigationTimeout
    ↓ overridden by
test.describe.configure({ timeout: 60_000 })     ← describe level
    ↓ overridden by
test('...', { timeout: 60_000 }, async () => {}) ← test level
test.setTimeout(60_000)                          ← inside the test
    ↓ overridden by
await locator.click({ timeout: 10_000 })         ← single action level
await expect(locator).toBeVisible({ timeout: 10_000 })  ← assertion level

// The more specific, the higher the priority. The most specific wins.

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

⚠️ Why telling them apart matters: when a test fails with a TimeoutError, the first question is "which timeout fired?" The classic trap: you raised timeout: 60000 in the config and it still fails after 5 seconds — because that was the expect timeout, which lives separately in expect: { timeout }. A different diagnosis means a different fix.

playwright.config.ts — три таймаути на рівні конфігурації
playwright.config.ts — the three timeouts at config level
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 секунд
  },
});
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // 1. TEST TIMEOUT — the whole test must finish within this time
  timeout: 60_000, // 60 seconds (default 30s)

  // 2. EXPECT TIMEOUT — how long web-first assertions keep retrying
  expect: {
    timeout: 10_000, // 10 seconds (default 5s)
  },

  use: {
    // 3. ACTION TIMEOUT — how long to wait for each individual action
    actionTimeout: 15_000, // 15 seconds (no default, shares the test timeout)

    // 4. NAVIGATION TIMEOUT — separate one for goto(), reload(), waitForNavigation()
    navigationTimeout: 30_000, // 30 seconds
  },
});
TypeScript — override таймаутів для конкретного тесту або дії
TypeScript — overriding timeouts for one test or one action
// 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 }) => { /* ... */ });
});
// Override the test timeout for a single slow test
test('slow e2e scenario', async ({ page }) => {
  test.setTimeout(120_000); // 2 minutes for this test only
  await page.goto('/checkout');
  // ... long scenario ...
});

// Override the action timeout for a specific slow action
await page.locator('.heavy-animation-btn').click({ timeout: 20_000 });
await page.locator('#slow-input').fill('value', { timeout: 10_000 });

// Override the expect timeout for a specific assertion
await expect(page.locator('.lazy-loaded-image'))
  .toBeVisible({ timeout: 15_000 }); // this image loads slowly — give it more time

// Override for a group of tests via describe
test.describe('slow integration tests', () => {
  test.setTimeout(90_000); // every test in this describe gets 90s

  test('test 1', async ({ page }) => { /* ... */ });
  test('test 2', async ({ page }) => { /* ... */ });
});
TypeScript — як читати повідомлення про помилку і розуміти який таймаут спрацював
TypeScript — how to read the error message and tell which timeout fired
// ── Приклад 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
// ── Example 1: expect timeout failed ──
// TimeoutError: expect(locator).toBeVisible() timeout 5000ms exceeded
// → the element did not appear within 5 seconds
// → either the element is missing entirely, or the expect timeout needs raising
await expect(page.locator('.modal')).toBeVisible(); // ← failed here

// ── Example 2: action timeout failed ──
// TimeoutError: page.click: timeout 30000ms exceeded
// → element found, but never became clickable (covered, disabled, animating)
await page.locator('.submit-btn').click(); // ← failed here

// ── Example 3: test timeout failed ──
// Test timeout of 30000ms exceeded
// → the whole test ran over — maybe a hung waitForEvent or an infinite loop
// → check for a missing await, or raise the test timeout
💡 Практичне правило для налаштування:
Не збільшуй test timeout як першу реакцію на падіння — спочатку зрозумій який саме таймаут і чому. Збільшення test timeout маскує проблему замість того щоб вирішити її. Якщо конкретна дія повільна — збільш action timeout тільки для неї. Якщо assertion чекає lazy-loaded контент — збільш expect timeout тільки для нього.
💡 A practical rule for tuning:
Do not raise the test timeout as your first reaction to a failure — work out which timeout it was and why first. Raising the test timeout masks the problem instead of fixing it. If one specific action is slow, raise the action timeout for that action only. If an assertion is waiting on lazy-loaded content, raise the expect timeout for that assertion only.
🔧 TypeScript / JS патерни в цьому прикладі timeout: 60_000 — числовий літерал з underscore separator (для читабельності, ідентичний 60000); expect: { timeout: 5_000 } — вкладений об'єкт в конфігурації; { timeout: 10000 } — options object для перевизначення таймауту локально.
🔧 TypeScript / JS patterns in this example timeout: 60_000 — a numeric literal with an underscore separator (for readability, identical to 60000); expect: { timeout: 5_000 } — a nested object in the config; { timeout: 10000 } — an options object overriding the timeout locally.
🗣 Як читати вголос В Playwright є три незалежних таймаути, і плутанина між ними — одна з найчастіших причин "чому тест все одно падає попри те що я збільшила timeout". Test timeout — це максимальний час на весь тест цілком, налаштовується через timeout в конфізі або test.setTimeout() для конкретного тесту. Action timeout — це скільки auto-waiting чекає поки елемент стане готовим до дії, налаштовується через actionTimeout в use блоці конфігу або прямо в дії через опцію { timeout }. Expect timeout — це скільки web-first assertion ретраїться перевіряючи умову, за замовчуванням 5 секунд, налаштовується через expect.timeout в конфізі або третім аргументом в конкретному assertion. Коли тест падає з TimeoutError, перше що треба зробити — прочитати повне повідомлення помилки: якщо там написано expect(locator).toBeVisible() timeout exceeded — це expect timeout; якщо page.click: timeout exceeded — це action timeout; якщо просто Test timeout of Xms exceeded — це test timeout і треба шукати де тест завис взагалі. Різний діагноз вимагає різного рішення, і збільшення test timeout як першої реакції — це майже завжди неправильно, бо це маскує реальну проблему замість її вирішення.
🗣 How to say it out loud Playwright has three independent timeouts, and mixing them up is one of the most common reasons for "why is my test still failing when I raised the timeout". Test timeout is the maximum time for the whole test, set through timeout in the config or test.setTimeout() for one test. Action timeout is how long auto-waiting waits for an element to become ready, set through actionTimeout in the use block or right on the action with a { timeout } option. Expect timeout is how long a web-first assertion keeps retrying the condition — five seconds by default, set through expect.timeout in the config or as a third argument on a specific assertion. When a test fails with a TimeoutError, the first thing to do is read the full message: if it says expect(locator).toBeVisible() timeout exceeded, that's the expect timeout; page.click: timeout exceeded is the action timeout; a plain Test timeout of Xms exceeded is the test timeout, and then you go looking for where the test hung in the first place. Each diagnosis needs a different fix, and raising the test timeout as a first reflex is nearly always wrong — it hides the real problem instead of solving it.
27 Що таке web-first assertions і чим вони відрізняються від звичайного expect/assert? What are web-first assertions and how do they differ from a plain expect/assert?
ЛокаториLocatorsJunior▶

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

An ordinary check in JavaScript runs once, immediately: take the value, compare it, done. But in browser tests the UI changes asynchronously: the button may not be there yet, the text may not have refreshed yet. A check for "right now" gives you the wrong answer.

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

Web-first assertions are expect(locator).toBeVisible() and friends. They do not check once — they retry at a short interval until the condition is true or the timeout runs out. In other words, they do the waiting for you.

TypeScript — web-first vs одноразова перевірка
TypeScript — web-first vs a one-shot check
// ❌ Одноразова перевірка: 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
// ❌ One-shot check: isVisible() returns the state NOW, at this very millisecond
const visible = await page.locator('.toast-message').isVisible();
expect(visible).toBe(true); // flaky: the toast may not have appeared yet

// ✅ Web-first assertion: retries itself for up to 5 seconds (by default)
await expect(page.locator('.toast-message')).toBeVisible();
// Playwright re-checks the state every few dozen ms until the condition
// becomes true OR the timeout expires — so no manual waits needed
TypeScript — toBeVisible / toBeAttached / toBeHidden / toHaveCount: часто плутають
TypeScript — toBeVisible / toBeAttached / toBeHidden / toHaveCount: commonly confused
// ── 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() працює зі списком за задумом
// ── toBeAttached() — element IS IN THE DOM (visibility irrelevant) ──
await expect(locator).toBeAttached();
// ✅ passes for: <div style="display:none">text</div>
// The element exists in the tree, but the user can't see it

// ── toBeVisible() — element is IN THE DOM AND VISIBLE ───────────
await expect(locator).toBeVisible();
// ❌ fails for: <div style="display:none">
// Visible = has a non-empty bounding box and no visibility:hidden

// ── toBeHidden() — NOT visible: either hidden OR not in the DOM at all
await expect(locator).toBeHidden();
// ✅ passes both for display:none and for a completely missing element
// ⚠️ Main trap: toBeHidden() != not.toBeVisible()? No, they're the same.
// But toBeHidden() != not.toBeAttached() — those are different things!

// ── Check that the element is NOT in the DOM at all ─────────────
await expect(locator).not.toBeAttached();  // precise "removed from DOM" check
await expect(locator).toHaveCount(0);      // same thing, different words

// ── toHaveCount() — the only one that does NOT fail on strict mode ──
await expect(page.locator('.product-card')).toHaveCount(12);
// toBeVisible() on 12 elements → strict mode violation
// toHaveCount() works with lists by design
💡 Як розпізнати web-first assertion: якщо в expect() передається Locator напряму (expect(locator).toBeVisible()) — це web-first, він сам чекає. Якщо передається вже обчислене значення (expect(await locator.isVisible()).toBe(true)) — це звичайний one-shot assert, ретраїв немає.

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

Soft assertions (expect.soft()) — перевірка яка не зупиняє тест при падінні: тест продовжується і всі soft-помилки збираються і виводяться разом в кінці. Детальніше в окремій картці.
💡 How to recognise a web-first assertion: if a Locator goes into expect() directly (expect(locator).toBeVisible()) — that is web-first, it waits by itself. If an already-computed value goes in (expect(await locator.isVisible()).toBe(true)) — that is an ordinary one-shot assert, with no retries at all.

expect.configure() lets you change how expect behaves for one test or one block: expect.configure({ timeout: 10000 }) sets a 10s timeout for every assertion after that line. Handy when you know a particular operation is slow.

Soft assertions (expect.soft()) — a check that does not stop the test when it fails: the test carries on and every soft failure is collected and reported together at the end. There is a separate card on it.
🔧 TypeScript / JS патерни в цьому прикладі expect(locator).toBeVisible() vs expect(await locator.textContent()) — різниця: перший передає Locator об'єкт (lazy), другий передає результат await (значення вже отримане); await locator.textContent() — спочатку виконується await, потім результат передається в expect.
🔧 TypeScript / JS patterns in this example expect(locator).toBeVisible() vs expect(await locator.textContent()) — the difference: the first passes a Locator object (lazy), the second passes the result of an await (the value is already fetched); await locator.textContent() — the await runs first, then its result is handed to expect.
🗣 Як читати вголос Різниця тут не в синтаксисі, а в поведінці в часі. Коли ми пишемо expect(await locator.isVisible()).toBe(true) — спочатку повністю виконується isVisible(), який повертає булеве значення стану елемента саме в цю мілісекунду, а вже потім expect порівнює це застигле значення з очікуваним. Якщо елемент з'явиться на екрані через 200 мс після цієї перевірки — тест все одно впаде, хоча по суті все працює правильно, просто з невеликою затримкою. Web-first assertion, навпаки, виглядає як await expect(locator).toBeVisible() — тут ми передаємо в expect сам Locator, не обчислене значення, і Playwright бере на себе ретраї: він перевіряє умову toBeVisible повторно, з невеликим інтервалом, протягом дефолтного таймауту, поки умова не стане істинною, і тільки тоді, коли час вийде, а умова так і не виконалась, тест падає. Це усуває цілий клас flaky-тестів, де причина нестабільності — не логічна помилка, а просто різниця в мілісекундах між тим, коли елемент реально зʼявився, і коли ми його перевірили.
🗣 How to say it out loud The difference isn't syntax, it's behaviour over time. When you write expect(await locator.isVisible()).toBe(true), isVisible() runs to completion first and gives you a boolean for the state of the element at that exact millisecond, and only then does expect compare that frozen value with what you expected. If the element shows up 200ms after that check, the test fails anyway, even though the app is working fine and is just slightly late. A web-first assertion looks like await expect(locator).toBeVisible() instead — you hand expect the Locator itself rather than a computed value, and Playwright takes over the retrying: it re-evaluates toBeVisible at a short interval for the length of the default timeout, and only fails once the time is up and the condition still isn't met. That kills off a whole class of flaky tests where the instability was never a logic error, just a few milliseconds between when the element actually appeared and when we looked.
28 Що таке soft assertions і коли їх використовувати? What are soft assertions and when should you use them?
ЛокаториLocatorsJunior▶
Soft assertions не зупиняють тест при першому провалі — вони накопичують помилки і репортять всі разом.
Soft assertions do not stop the test on the first failure — they collect the failures and report them all together.
TypeScript
TypeScript
test('product page validations', async ({ page }) => {
  await page.goto('/product/123');

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

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

  // Всі помилки виведуться разом
});
test('product page validations', async ({ page }) => {
  await page.goto('/product/123');

  // Stops the test at the first failure
  await expect(page.locator('h1')).toBeVisible();

  // Soft: keeps going even if these fail
  await expect.soft(page.locator('.price')).toBeVisible();
  await expect.soft(page.locator('.rating')).toBeVisible();
  await expect.soft(page.locator('.stock-badge')).toHaveText(/In Stock/);

  // All failures are reported together
});
🔧 TypeScript / JS патерни в цьому прикладі expect.soft(locator) — виклик методу на об'єкті expect (не функції); expect.soft повертає той самий expect API — це fluent interface; test.info().errors — доступ до вкладеної властивості через ланцюжок крапок.
🔧 TypeScript / JS patterns in this example expect.soft(locator) — calling a method on the expect object (not the function); expect.soft returns the same expect API — a fluent interface; test.info().errors — reaching a nested property through a chain of dots.
🗣 Як читати вголос Перший expect — звичайний, якщо h1 не знайдено тест одразу зупиняється. Далі expect.soft — викликаємо статичний метод soft на об'єкті expect, передаємо локатор і чейнимо перевірку. Якщо ця перевірка падає — тест продовжує виконуватись далі. Всі накопичені soft-помилки виводяться разом в кінці тесту в одному звіті.
🗣 How to say it out loud The first expect is a normal one — if the h1 isn't found the test stops right there. Then expect.soft: we call the static soft method on the expect object, pass it a locator and chain the check. If that one fails, the test keeps running. Every soft failure that piled up gets printed together at the end of the test, in one report.
💡 Коли використовувати: валідація сторінки з кількома незалежними полями, де потрібно побачити всі проблеми одразу.
💡 When to use it: validating a page with several independent fields, where you want to see all the problems at once.
29 Що таке page.waitForFunction() і коли він потрібен якщо є web-first assertions? What is page.waitForFunction() and why do you need it when web-first assertions exist?
ЛокаториLocatorsSenior▶

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

Web-first assertions like expect(locator).toBeVisible() retry by themselves — but only for DOM elements. What if the condition you need to check is not about an element on the page at all?

  • Чекаємо поки window.analyticsReady === true — глобальна змінна яку встановлює скрипт після ініціалізації
  • Waiting until window.analyticsReady === true — a global variable a script sets once it has initialised
  • Чекаємо поки в window.dataLayer з'явиться конкретна подія
  • Waiting until a specific event shows up in window.dataLayer
  • Чекаємо поки кількість елементів у React state досягне певного числа
  • Waiting until the number of items in React state reaches some value
  • Чекаємо поки анімація завершиться по конкретному CSS property, а не по видимості
  • Waiting until an animation finishes, judged by a specific CSS property rather than by visibility

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

None of these has a locator. page.waitForFunction() takes an arbitrary JS function, runs it in the browser and repeats it until it returns a truthy value or the timeout runs out.

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

How it differs from page.evaluate(): evaluate() runs once and returns the result. waitForFunction() runs again and again at an interval until the condition is true — it is evaluate with polling built in.

Як читати код з waitForFunction — по кроках:How to read waitForFunction code, step by step:

await page.waitForFunction(() => {
  return Array.isArray((window as any).dataLayer)
    && (window as any).dataLayer.length > 0;
});
  • await — чекаємо поки waitForFunction завершиться. Без нього тест піде далі не чекаючи.
  • await — wait for waitForFunction to finish. Without it the test carries on without waiting.
  • () => { ... } — стрілочна функція без аргументів. Думай про неї як про "посилку коду яку ми відправляємо в браузер". Вона виконується не в Node.js, а всередині браузера.
  • () => { ... } — an arrow function with no arguments. Think of it as "a parcel of code we ship into the browser". It runs inside the browser, not in Node.js.
  • window — глобальний об'єкт браузера. Доступний тому що код виконується в браузері.
  • window — the browser's global object. Available because this code runs in the browser.
  • (window as any) — TypeScript cast щоб компілятор не скаржився що dataLayer не задекларований на типі Window.
  • (window as any) — a TypeScript cast so the compiler stops complaining that dataLayer is not declared on the Window type.
  • return true/false — якщо повернула truthy → Playwright зупиняється. Якщо falsy → чекає ~100мс і повторює. Якщо за таймаут не стало true → кидає TimeoutError.
  • return true/false — a truthy return stops Playwright. A falsy one waits ~100ms and repeats. If it never became true before the timeout, it throws a TimeoutError.

Чому closure не працює і навіщо другий аргумент:Why a closure does not work, and what the second argument is for:

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
);
const expected = 48; // a Node.js variable

// ❌ DOES NOT WORK — the browser can't see Node.js variables via closure
await page.waitForFunction(() => {
  return document.querySelectorAll('.product-card').length >= expected; // expected → undefined!
});

// ✅ CORRECT — pass it as the second argument
// Playwright serializes 48 to JSON and passes it to the browser as a function parameter
await page.waitForFunction(
  (expectedCount) => {                  // function parameter
    return document.querySelectorAll('.product-card').length >= expectedCount;
  },
  48                                    // ← becomes the value of expectedCount
);

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

// 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 синхронні
// async is needed if you have to fetch inside the browser
await page.waitForFunction(async () => {
  const response = await fetch('/api/status'); // fetch is a browser API
  const data = await response.json();
  return data.status === 'ready';
});
// Most of the time async isn't needed — window/document/CSS are synchronous
TypeScript — Приклад 1: чекаємо ініціалізацію GTM / dataLayer
TypeScript — Example 1: waiting for GTM / dataLayer to initialise
// 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', ... }]
});
// A GTM or analytics script loads asynchronously
// If you check window.dataLayer right away, it may not exist yet
test('GTM initialized', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // ❌ evaluate() — runs ONCE, right now
  // If the script hasn't loaded yet, it returns undefined
  const ready = await page.evaluate(() => (window as any).dataLayer);

  // ✅ waitForFunction() — retries every ~100ms until true
  await page.waitForFunction(() => {
    return Array.isArray((window as any).dataLayer)
      && (window as any).dataLayer.length > 0;
  });

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

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

  // Дістаємо подію і перевіряємо деталі
  const event = await page.evaluate(() =>
    (window as any).dataLayer.find((e: any) => e.event === 'add_to_cart')
  );
  expect(event.ecommerce.items[0].item_id).toBe('SKU-123');
});
test('add_to_cart event is tracked', async ({ page }) => {
  await page.goto('/product/trench-coat');
  await page.locator('button.add-to-cart').click();

  // Wait for this specific event, not just for dataLayer to exist
  await page.waitForFunction(() => {
    const dl = (window as any).dataLayer || [];
    return dl.some((e: any) => e.event === 'add_to_cart');
  });

  // Grab the event and check its details
  const event = await page.evaluate(() =>
    (window as any).dataLayer.find((e: any) => e.event === 'add_to_cart')
  );
  expect(event.ecommerce.items[0].item_id).toBe('SKU-123');
});
TypeScript — Приклад 3: чекаємо завершення CSS анімації
TypeScript — Example 3: waiting for a CSS animation to finish
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('modal fully opened', async ({ page }) => {
  await page.goto('/products');
  await page.locator('.quick-view').click();

  // toBeVisible() → true as soon as opacity > 0
  // but we want to wait until the animation has FINISHED (opacity = 1)
  await page.waitForFunction(() => {
    const modal = document.querySelector('.modal');
    return getComputedStyle(modal!).opacity === '1';
  });

  // Animation done — screenshot without a blurred transition
  await expect(page.locator('.modal')).toHaveScreenshot('modal-open.png');
});
TypeScript — Приклад 4: передаємо аргумент з Node.js і налаштовуємо polling
TypeScript — Example 4: passing an argument from Node.js and tuning the polling
test('список товарів повністю завантажений', async ({ page }) => {
  await page.goto('/women/dresses');

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

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

// polling: 'raf' — перевіряти на кожен requestAnimationFrame
// Ідеально для анімацій — синхронізовано з браузерним render loop
await page.waitForFunction(
  () => getComputedStyle(document.querySelector('.hero')!).opacity === '1',
  undefined,
  { polling: 'raf' }
);
test('product list fully loaded', async ({ page }) => {
  await page.goto('/women/dresses');

  // Pass 48 as an argument from Node.js to the browser
  // Wait for an exact count, not just for the first element to appear
  await page.waitForFunction(
    (expectedCount) => {
      const items = document.querySelectorAll('.product-card');
      return items.length >= expectedCount;
    },
    48,                              // ← argument from Node.js
    { timeout: 10000, polling: 500 } // check every 500ms
  );

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

// polling: 'raf' — check on every requestAnimationFrame
// Ideal for animations — synced with the browser render loop
await page.waitForFunction(
  () => getComputedStyle(document.querySelector('.hero')!).opacity === '1',
  undefined,
  { polling: 'raf' }
);
TypeScript — передаємо аргументи з Node.js і налаштовуємо таймаут
TypeScript — passing arguments from Node.js and setting the timeout
// Передаємо аргумент з 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
);
// Pass an argument from Node.js to the browser — same pattern as evaluate()
const expectedCount = 48;

await page.waitForFunction(
  (count) => {
    // count came from Node.js — available here in the browser
    const items = document.querySelectorAll('.product-card');
    return items.length >= count;
  },
  expectedCount, // ← pass it as the second argument
  { timeout: 10000, polling: 500 } // 10s timeout, check every 500ms
);

// polling: 'raf' — check on every requestAnimationFrame (for animations)
await page.waitForFunction(
  () => {
    const el = document.querySelector('.animated-element');
    return getComputedStyle(el!).opacity === '1';
  },
  undefined, // no argument passed
  { polling: 'raf' } // synced with the browser render loop
);
TypeScript — expect.poll() як альтернатива для простих випадків
TypeScript — expect.poll() as an alternative for the simple cases
// Для простих умов є зручніша альтернатива — 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'
// For simple conditions there's a handier alternative — expect.poll()
// It retries an arbitrary function like a web-first assertion

// ✅ Shorter and more readable than waitForFunction for simple checks
await expect.poll(async () => {
  return await page.evaluate(() => (window as any).analyticsReady);
}, { timeout: 5000, intervals: [100, 250, 500] }).toBe(true);

// Difference between waitForFunction and expect.poll():
// waitForFunction — the function runs IN THE BROWSER, has access to window/DOM
// expect.poll()  — the function runs IN NODE.JS, can do anything: API calls, reading files
await expect.poll(async () => {
  const response = await request.get('/api/status');
  return (await response.json()).status;
}).toBe('ready'); // retries the API call until it gets 'ready'
💡 Коли що використовувати:
Умова про DOM-елемент → expect(locator).toBeVisible() або інший web-first assertion
Умова про стан браузера (window, dataLayer, CSS property) → page.waitForFunction()
Проста умова або умова в Node.js (API, файл) → expect.poll()
💡 What to use when:
A condition about a DOM element → expect(locator).toBeVisible() or another web-first assertion
A condition about browser state (window, dataLayer, a CSS property) → page.waitForFunction()
A simple condition, or one that lives in Node.js (an API, a file) → expect.poll()
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForFunction(() => {}, argValue, { timeout, polling }) — три аргументи: функція, аргумент для передачі в браузер, options; (window as any).dataLayer — type assertion; Array.isArray() — статичний метод; dl.some((e: any) => condition) — Array.some() з предикатом.
🔧 TypeScript / JS patterns in this example await page.waitForFunction(() => {}, argValue, { timeout, polling }) — three arguments: the function, the argument to ship into the browser, the options; (window as any).dataLayer — a type assertion; Array.isArray() — a static method; dl.some((e: any) => condition) — Array.some() with a predicate.
🗣 Як читати вголос 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 запит поки не прийде потрібний статус.
🗣 How to say it out loud You need page.waitForFunction() when the condition you're waiting on can't be expressed as a DOM element and a locator. Web-first assertions like expect(locator).toBeVisible() do retry, but only for elements on the page. If you need to wait until window.analyticsReady turns true, or until a particular event lands in dataLayer, or until a CSS property on an animated element reaches some value, there's no locator for any of that. waitForFunction() takes an arbitrary JS function, runs it in the browser where window and document exist, and repeats it at a short interval until it returns something truthy or the timeout hits. That's fundamentally different from page.evaluate(), which runs exactly once — waitForFunction is evaluate with polling built in. Anything from Node.js goes in as the second parameter, the same pattern as evaluate. For simpler cases there's expect.poll(), where the function runs in Node.js and can do whatever you like — retry an API call until the status comes back right, for instance.
30 Що таке expect.poll() і коли його використовувати? What is expect.poll() and when should you use it?
ЛокаториLocatorsSenior▶

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

expect.poll() is asynchronous polling: it retries an arbitrary async function in Node.js every N ms until the timeout. Ideal for external state (a database, email, an API) where there is no DOM element to check.

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

It is not a separate mechanism. expect.poll() uses the same retry loop as ordinary web-first assertions — instead of resolving a Locator again, it calls your function again and hands the result to the matcher. Which is why the timeouts and the retry behaviour here are identical to expect(locator).toBeVisible().

Три схожі механізми які часто плутають:Three similar mechanisms people often mix up:

  • expect.poll(async () => value).toBe(x) — ретраїть функцію в Node.js, передає її результат у матчер. Для перевірки значення що змінюється з часом: статус в API, кількість листів, запис у БД.
  • expect.poll(async () => value).toBe(x) — retries a function in Node.js and passes its result to the matcher. For checking a value that changes over time: a status in an API, a number of emails, a row in a database.
  • await expect(async () => {...}).toPass() — ретраїть функцію яка сама містить assertions. Не повертає значення — просто повторює блок поки всі assertions всередині не пройдуть. Для ретраю групи перевірок разом.
  • await expect(async () => {...}).toPass() — retries a function that contains assertions itself. It returns nothing — it just repeats the block until every assertion inside passes. For retrying a group of checks together.
  • page.waitForFunction(() => ...) — це інша категорія: виконує код всередині браузера, а не в Node.js. Має доступ до window, document. Не є заміною expect.poll() — вони працюють у різних середовищах.
  • page.waitForFunction(() => ...) — this is a different category: it runs code inside the browser, not in Node.js. It has access to window and document. It is not a replacement for expect.poll() — they work in different environments.

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

A simple rule: checking a value from Node.js (an API, a database) → expect.poll(). Retrying a block of assertions → toPass(). Need access to window/document → waitForFunction(). Checking a DOM element → a plain web-first assertion, none of this is needed.

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

// Чекаємо поки email прийде (через mailhog API)
await expect.poll(
  async () => {
    const res = await page.request.get('http://mailhog:8025/api/v2/messages');
    return (await res.json()).total;
  },
  { timeout: 15_000 }
).toBeGreaterThan(0);
// Wait until the API returns the right status
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');

// Wait until the email arrives (via the MailHog API)
await expect.poll(
  async () => {
    const res = await page.request.get('http://mailhog:8025/api/v2/messages');
    return (await res.json()).total;
  },
  { timeout: 15_000 }
).toBeGreaterThan(0);
🔧 TypeScript / JS патерни в цьому прикладі await expect.poll(async () => { return await ... }, { timeout, intervals }) — expect.poll приймає async функцію і options; intervals: [100, 250, 500] — масив чисел; async () => { const response = await request.get() — async arrow function з await всередині.
🔧 TypeScript / JS patterns in this example await expect.poll(async () => { return await ... }, { timeout, intervals }) — expect.poll takes an async function and an options object; intervals: [100, 250, 500] — an array of numbers; async () => { const response = await request.get() — an async arrow function with an await inside.
🗣 Як читати вголос Викликаємо статичний метод expect.poll — перший аргумент асинхронна функція-геттер що повертає значення для перевірки, другий — об'єкт опцій. message — текст помилки якщо перевірка не пройде за відведений час. timeout — максимальний час в мілісекундах. intervals — масив затримок між спробами в мілісекундах, це реалізація exponential backoff: спочатку швидко, потім рідше. Після poll(...) чейнимо звичайний матчер як .toBe('completed') або .toBeGreaterThan(0).
🗣 How to say it out loud We call the static expect.poll method — the first argument is an async getter that returns the value to check, the second is an options object. message is the error text if the check never passes in the time allowed. timeout is the maximum time in milliseconds. intervals is an array of delays between attempts in milliseconds, which is how you get exponential backoff: fast at first, then less often. After poll(...) we chain a normal matcher like .toBe('completed') or .toBeGreaterThan(0).
31 Як реалізувати Custom Expect Matchers? How do you implement custom expect matchers?
ЛокаториLocatorsSenior▶

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

Playwright's built-in matchers — toBeVisible(), toHaveText() — cover the common cases. You need a custom matcher when the same complicated check repeats across many tests — and even then it is worth looking at the simpler alternative first.

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

// 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);
// helpers/assertions.ts — just a function
function expectValidPrice(price: string) {
  expect(price).toMatch(/^\£\d+\.\d{2}$/);
  expect(price).not.toBe('');
  expect(parseFloat(price.replace('£', ''))).toBeGreaterThan(0);
}

// In the test — same as a custom matcher, but simpler:
expectValidPrice(price);

Єдина реальна перевага custom matcher над helper — повідомлення при падінні:The one real advantage a custom matcher has over a helper is the failure message:

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

// Custom matcher падає так:
// Expected price to be valid product price format
// Received: "abc"
// A helper fails like this:
// Expected: match /^\£\d+\.\d{2}$/
// Received: "abc"

// A custom matcher fails like this:
// Expected price to be valid product price format
// Received: "abc"

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

A simple rule: a helper function is simpler, clearer, and enough for 99% of cases. A custom matcher is worth it only when it matters to have a readable failure message that says exactly what is being checked. If the check appears once or twice, just write it inline.

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

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

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

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

// Використання
await expect(page.locator('.price')).toHaveProductPrice('£');
await expect(page.locator('.price')).toHaveProductPrice('€');
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 types for the matcher
declare module '@playwright/test' {
  interface Matchers<R> {
    toHaveProductPrice(currency: string): Promise<R>;
  }
}

// Usage
await expect(page.locator('.price')).toHaveProductPrice('£');
await expect(page.locator('.price')).toHaveProductPrice('€');
🔧 TypeScript / JS патерни в цьому прикладі expect.extend({ toBeValidProduct(received: any) {} }) — метод extend приймає об'єкт де ключ це назва matcher, значення — функція; return { pass, message: () => string } — функція повертає об'єкт з методом message; () => string — функція що повертає рядок (lazy evaluation).
🔧 TypeScript / JS patterns in this example expect.extend({ toBeValidProduct(received: any) {} }) — extend takes an object whose key is the matcher name and whose value is a function; return { pass, message: () => string } — the function returns an object with a message method; () => string — a function returning a string (lazy evaluation).
🗣 Як читати вголос Викликаємо expect.extend, передаємо об'єкт де ключ — назва матчера, значення — асинхронна функція. Перший аргумент функції — те що передається в expect(), тут це locator. Другий — наш кастомний аргумент currency. Всередині отримуємо текст елементу, перевіряємо регуляркою, повертаємо об'єкт з pass — булевий результат, і message — функція що повертає текст помилки. declare module — TypeScript module augmentation, розширюємо інтерфейс Matchers щоб IDE підказувала наш матчер з автодоповненням.
🗣 How to say it out loud We call expect.extend and pass an object where the key is the matcher name and the value is an async function. The function's first argument is whatever went into expect() — a locator here. The second is our own argument, currency. Inside we grab the element's text, check it against a regex, and return an object with pass, the boolean result, and message, a function returning the error text. declare module is TypeScript module augmentation — we extend the Matchers interface so the IDE offers our matcher with autocomplete.
32 Чи треба щось особливе робити, щоб локатори бачили елементи в Shadow DOM? Do you need to do anything special for locators to see elements inside the Shadow DOM?
ЛокаториLocatorsSenior▶

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

The Shadow DOM is a browser mechanism for encapsulating components. Elements inside a shadow root are isolated from the main document. It is used by Web Components and by some UI libraries.

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

The good news: Playwright pierces an open shadow root automatically when locators look things up. Most of the time there is nothing special to do.

TypeScript — Shadow DOM "з коробки"
TypeScript — the Shadow DOM out of the box
// 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'));
// A Web Component with a shadow root, e.g. a custom <product-rating>
// <product-rating>         ← regular DOM, outside the shadow root
//   #shadow-root           ← Shadow DOM boundary
//     <span class="stars">★★★★☆</span>  ← inside the shadow root
// </product-rating>

// ✅ Playwright locators pierce shadow DOM automatically
// First find the host element, then search inside the shadow root
await expect(
  page.locator('product-rating').locator('.stars')
).toHaveText('★★★★☆');
// or via getByRole, which also pierces shadow DOM:
await expect(
  page.locator('product-rating').getByRole('img', { name: '4 stars' })
).toBeVisible();

// ❌ In Selenium you'd have to enter the shadow root explicitly:
// const shadowHost = driver.findElement(By.css('product-rating'));
// const shadowRoot = shadowHost.getShadowRoot();
// const stars = shadowRoot.findElement(By.css('.stars'));
💡 Виняток: closed shadow root ({ mode: 'closed' }) Playwright теж не бачить, як і будь-який інший інструмент — це навмисне обмеження браузера, а не Playwright.
💡 The exception: a closed shadow root ({ mode: 'closed' }) is invisible to Playwright too, just as it is to every other tool — that is a deliberate browser restriction, not a Playwright limitation.
🔧 TypeScript / JS патерни в цьому прикладі page.locator('.shadow-host .inner-element') — CSS selector через Shadow DOM; Playwright автоматично пронизує open shadow root; shadowRoot.querySelector() — браузерний API для явного доступу; { force: true } — options object для примусової дії.
🔧 TypeScript / JS patterns in this example page.locator('.shadow-host .inner-element') — a CSS selector reaching through the Shadow DOM; Playwright pierces an open shadow root automatically; shadowRoot.querySelector() — the browser API for explicit access; { force: true } — an options object forcing the action.
🗣 Як читати вголос На відміну від Selenium, де для доступу до елементів усередині Shadow DOM потрібно явно знайти shadow host, дістати його shadow root і вже в ньому шукати елемент окремим викликом, у Playwright локатор автоматично "пронизує" відкритий (open) shadow DOM без жодного додаткового коду — ми просто пишемо звичайний CSS-селектор типу .stars, і Playwright сам розуміє, що шукати треба не тільки в основному документі, а й усередині будь-яких shadow roots на сторінці. Це особливо актуально для сучасних e-commerce платформ, які часто будуються на Web Components чи дизайн-системах із shadow DOM для інкапсуляції стилів. Єдиний виняток — closed shadow root, коли компонент явно створений з { mode: 'closed' }: тоді JavaScript у принципі не має доступу до цього shadow root, і це обмеження самого браузера, а не якесь упущення Playwright — жоден інструмент автоматизації не зможе туди зазирнути.
🗣 How to say it out loud In Selenium, to get at anything inside the Shadow DOM you have to find the shadow host explicitly, pull out its shadow root and then search inside that with a separate call. In Playwright the locator pierces an open shadow DOM automatically, with no extra code — you write an ordinary CSS selector like .stars and Playwright works out that it should look inside any shadow roots on the page as well as the main document. That matters a lot on modern e-commerce platforms, which are often built on Web Components or design systems that use the shadow DOM to encapsulate styles. The one exception is a closed shadow root, where the component was deliberately created with { mode: 'closed' }: JavaScript has no access to that shadow root at all, and that's the browser's restriction rather than something Playwright missed — no automation tool can look inside it.
▶ 4 13
33 Що таке fixtures в Playwright? Як використовувати вбудовані? What are fixtures in Playwright, and how do you use the built-in ones?
FixturesJunior▶

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

We have already covered what fixtures are conceptually and why page/context/browser are named the way they are. Now let's look at all the built-in ones together — and when to reach for which.

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

Fixtures are a dependency injection mechanism. The built-in ones: page, browser, context, request.

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

Scope of the built-in fixtures: page, context, request are test-scoped (recreated from scratch for every test). browser is worker-scoped (one per worker process, not one per run). Run in parallel with 4 workers and you get 4 separate browser processes.

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

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

  // request — API calls без браузера
  const res = await request.get('/api/users/me');
  await expect(res).toBeOK();
});
test('login flow', async ({ page, context, request }) => {
  // page — a new tab in an isolated context
  await page.goto('/');

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

  // request — API calls without a browser
  const res = await request.get('/api/users/me');
  await expect(res).toBeOK();
});
🔧 TypeScript / JS патерни в цьому прикладі async ({ page, context, request }) => {} — деструктуризація кількох fixtures одночасно; [{ name: 'session', value: 'abc', url: '/' }] — масив об'єктів (array of objects); await expect(res).toBeOK() — ланцюжок: спочатку отримуємо Response, потім перевіряємо.
🔧 TypeScript / JS patterns in this example async ({ page, context, request }) => {} — destructuring several fixtures at once; [{ name: 'session', value: 'abc', url: '/' }] — an array of objects; await expect(res).toBeOK() — a chain: you get the Response first, then you assert on it.
🗣 Як читати вголос Функція test отримує об'єкт fixtures через деструктуризацію у фігурних дужках. page — це новий tab у ізольованому контексті, він живе тільки на час цього тесту. context — це BrowserContext, через нього можна керувати cookies і storage. context.addCookies — додаємо масив об'єктів-кукі: ім'я, значення, URL. request — окремий об'єкт для HTTP-запитів без браузера, request.get робить GET-запит і toBeOK перевіряє що статус 2xx.
🗣 How to say it out loud The test function receives the fixtures object through destructuring in curly braces. page is a fresh tab in an isolated context, and it only lives for this one test. context is the BrowserContext — that's where you get at cookies and storage. context.addCookies takes an array of cookie objects: name, value, URL. request is a separate object for HTTP calls with no browser involved — request.get fires a GET, and toBeOK checks the status came back 2xx.
34 Навіщо кастомні fixtures якщо є beforeEach? Як вибрати між ними? Why custom fixtures if beforeEach exists? How do you pick between them?
FixturesMiddle▶

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

The problem: when every test has to do the same thing — open a page and log in, say — those lines get duplicated everywhere. A custom fixture removes the duplication: you describe the preparation once and just ask for it by name in the test.

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

A fixture has three parts:

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

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

  // 3. TEARDOWN — виконується ПІСЛЯ тесту (навіть якщо тест впав)
  await page.goto('/');
}
myFixture: async ({ page }, use) => {
  // 1. SETUP — runs BEFORE the test
  await page.goto('/cart');

  await use(page); // 2. use() — Playwright pauses here and runs the test
                   //    when the test finishes, Playwright comes back here

  // 3. TEARDOWN — runs AFTER the test (even if the test failed)
  await page.goto('/');
}

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

use() is the divider between setup and teardown. Without it Playwright would have no idea when to run the test. Into use() you pass whatever you prepared — a page, an object, a string or undefined. In the test you write the fixture name in the destructuring, and it has to match the name in extend().

Fixture vs beforeEach:

Fixture vs beforeEach:

  • beforeEach достатньо — коли треба просто відкрити сторінку і нічого більше
  • Fixture краща — коли є teardown (в одному місці), коли треба передати значення в тест (beforeEach не може), або коли той самий setup потрібен у кількох файлах
  • beforeEach is enough — when you just need to open a page and nothing else
  • A fixture is better — when there is teardown (kept in one place), when you need to hand a value to the test (beforeEach can't), or when the same setup is needed in several files

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

Fixture vs a global variable (let db: any):

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

A global variable falls apart under parallel execution — several tests write to the same variable at once. A fixture gives every test its own instance of the resource. And if the setup fails, the fixture throws an error you can read, whereas the global variable just quietly stays undefined.

🔧 TypeScript / JS патерни в цьому прикладі async ({ page }, use) => {} — стрілочна async функція з деструктуризацією і use як callback; await use(value) — Playwright викличе тест з нашим значенням; ім'я в extend() = ім'я в деструктуризації тесту.
🔧 TypeScript / JS patterns in this example async ({ page }, use) => {} — an async arrow function with destructuring and use as a callback; await use(value) — Playwright will call the test with our value; the name in extend() = the name in the test's destructuring.
🗣 Як читати вголос Кастомна fixture вирішує проблему дублювання setup. Fixture має три частини: setup до use(), тест всередині, і teardown після. use() — розділювач: Playwright зупиняється на ньому, запускає тест, повертається і виконує teardown навіть якщо тест впав. В use() передаємо що підготували — page, об'єкт, рядок. В тесті пишемо ім'я fixture в деструктуризації — воно збігається з назвою в extend(). Fixture краща за beforeEach коли є teardown або коли треба передати значення в тест. Краща за глобальну змінну бо при паралельному виконанні кожен тест отримує свій власний екземпляр ресурсу.
🗣 How to say it out loud A custom fixture solves the setup duplication problem. It has three parts: the setup before use(), the test in the middle, and the teardown after. use() is the divider — Playwright stops there, runs the test, comes back and runs the teardown even if the test failed. Whatever you prepared goes into use() — a page, an object, a string. In the test you write the fixture name in the destructuring, and it matches the name you gave it in extend(). A fixture beats beforeEach when there is teardown, or when you need to hand a value to the test. And it beats a global variable because under parallel execution every test gets its own instance of the resource.
35 Що таке кастомні fixtures і як їх створювати? What are custom fixtures and how do you create them?
FixturesMiddle▶

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

Custom fixtures extend the built-in ones — they add authentication, page objects or test data.

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

Why {} as the first argument: the first parameter of a fixture is the object where you ask for other fixtures. Need page? You write { page }. Need nothing at all? You still have to put something in that first slot, because the function takes two arguments: (fixtures, use). So you write {} — an empty destructuring, meaning "there is a first argument but I want nothing out of it".

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

The key point: Playwright does not run every fixture in the project before each test. It looks only at what the test actually asked for in its signature — async ({ page }) versus async ({ authenticatedPage }) — and builds just the dependency subgraph that this particular set needs. If authenticatedPage internally asks for { page }, Playwright creates page first and never touches the rest of the project's fixtures at all. A fixture doesn't really say "run this" — it says "here is what I need in order to work", and Playwright works out what to create, and in what order, from those requests.

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

// Нічого не потрібно — порожня деструктуризація
dbConnection: async ({}, use) => { const db = await connectDB(); await use(db); }
// Need page — take it
loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }

// Need nothing — empty destructuring
dbConnection: async ({}, use) => { const db = await connectDB(); await use(db); }
fixtures/index.ts
fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductPage } from '../pages/ProductPage';

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

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

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

export { expect };
import { test 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: log in via the API (faster than via the UI)
    await page.request.post('/api/login', {
      data: { email: 'test@test.com', password: 'pass' }
    });
    await use(page);  // <-- the test runs here
    // Teardown: after use()
    await page.request.post('/api/logout');
  },
});

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

test('already authenticated', async ({ authenticatedPage, loginPage }) => {
  await authenticatedPage.goto('/profile');
  // вже залогінений, fixture відпрацьовує setup автоматично
});
import { test, expect } from '../fixtures';

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

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

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

Не плутати з pytest: у Playwright fixture завжди пише await use(db). yield db у фікстурі — це синтаксис pytest (Python); генераторних фікстур у Playwright для TypeScript немає (див. картку 36).
🔧 TypeScript / JS patterns in this example base.extend({ fixtureName: async ({}, use) => {} }) — extend takes an object whose keys are fixture names and whose values are async functions; await use(resource) — use is a callback Playwright will call with our value.

Creation order when fixtures depend on each other: if fixture A depends on fixture B, Playwright guarantees the order: B setup → A setup → test → A teardown → B teardown. Teardown unwinds in reverse — like a stack. This one comes up in Senior interviews.

box: true — a fixture option (Playwright 1.46+): { scope: 'test', box: true }. A boxed fixture does not appear in Trace Viewer as a step of its own — everything it does shows up in the test's context instead. Useful for "transparent" utility fixtures.

Don't confuse it with pytest: a Playwright fixture always calls await use(db). yield db inside a fixture is pytest (Python) syntax; Playwright for TypeScript has no generator fixtures (see card 36).
🗣 Як читати вголос Імпортуємо test as base — перейменовуємо щоб не конфліктувати з нашим новим test. Оголошуємо TypeScript тип MyFixtures з іменами наших fixture-ів. Потім base.extend<MyFixtures>(...) — передаємо generic з нашим типом і об'єкт де кожен ключ — назва fixture. Кожна fixture — асинхронна функція з двома аргументами: деструктурованими вбудованими fixtures і функцією use. Все що до await use(...) — це setup, саме в use передаємо готове значення для тесту, все після — teardown. Експортуємо наш test і тепер тести імпортують його замість стандартного.
🗣 How to say it out loud We import test as base — renamed so it doesn't clash with the new test we're about to export. Then we declare a TypeScript type MyFixtures listing our fixture names. Then base.extend<MyFixtures>(...) — we pass our type as the generic and an object where every key is a fixture name. Each fixture is an async function with two arguments: the built-in fixtures destructured, and the use function. Everything before await use(...) is setup, use is where we hand the ready value to the test, and everything after it is teardown. We export our test, and from then on the specs import that one instead of the standard one.
36 Як гарантувати teardown у fixture, навіть коли тест падає? How do you guarantee a fixture's teardown even when the test fails?
FixturesMiddle▶

У @playwright/test fixture — це функція async ({ …deps }, use) => { … }. Код до await use(value) — setup, сам виклик use() — це час, коли виконується тест, код після нього — teardown. Інших форм немає: fixture завжди отримує use другим аргументом.

In @playwright/test a fixture is an async ({ …deps }, use) => { … } function. The code before await use(value) is setup, the use() call itself is when the test runs, and the code after it is teardown. There is no other form: a fixture always receives use as its second argument.

Коли тест падає, await use() все одно повертається, і Playwright виконує код після нього. Тож звичайний teardown після use() вже спрацює при впалому тесті. try/finally потрібен в іншому випадку: коли падає сам setup на півдорозі, або коли ресурсів кілька і кожен треба прибрати, навіть якщо прибирання попереднього кинуло помилку.

When the test fails, await use() still returns and Playwright runs the code after it. So a plain teardown after use() already runs for a failed test. You need try/finally for a different case: when the setup itself fails halfway, or when there are several resources and each must be released even if releasing the previous one threw.

Пастка на співбесіді: «yield-фікстури» — це pytest (Python): yield driver у функції з @pytest.fixture. У Playwright для TypeScript/JavaScript генераторних фікстур немає; async function* у test.extend() просто не спрацює.

Interview trap: "yield fixtures" are pytest (Python): yield driver inside an @pytest.fixture function. Playwright for TypeScript/JavaScript has no generator fixtures; an async function* inside test.extend() simply won't work.

TypeScript — teardown після use() виконується і для впалого тесту
TypeScript — teardown after use() runs for a failed test too
import { test as base, expect } from '@playwright/test';

export const test = base.extend<{ tempUser: User }>({
  tempUser: async ({ request }, use) => {
    const user = await createUser(request);   // setup
    await use(user);                            // тут виконується тест
    await deleteUser(request, user.id);     // teardown: виконається, навіть якщо тест упав
  },
});

test('профіль показує email', async ({ page, tempUser }) => {
  await page.goto(`/users/${tempUser.id}`);
  await expect(page.getByTestId('email')).toHaveText(tempUser.email);
  // навіть якщо expect тут впаде, deleteUser вище все одно спрацює
});
import { test as base, expect } from '@playwright/test';

export const test = base.extend<{ tempUser: User }>({
  tempUser: async ({ request }, use) => {
    const user = await createUser(request);   // setup
    await use(user);                            // the test runs here
    await deleteUser(request, user.id);     // teardown: runs even if the test failed
  },
});

test('profile shows the email', async ({ page, tempUser }) => {
  await page.goto(`/users/${tempUser.id}`);
  await expect(page.getByTestId('email')).toHaveText(tempUser.email);
  // even if this expect fails, deleteUser above still runs
});
TypeScript — try/finally, коли може впасти сам setup або прибирання одного з ресурсів
TypeScript — try/finally when the setup itself, or one resource's cleanup, can fail
testEnv: async ({}, use) => {
  const db = await connectToDatabase();
  try {
    const server = await startMockServer();   // якщо це впаде, db все одно закриється
    try {
      await use({ db, server });
    } finally {
      await server.stop();                    // спершу сервер
    }
  } finally {
    await db.disconnect();                     // останньою БД — навіть якщо stop() кинув помилку
  }
},
testEnv: async ({}, use) => {
  const db = await connectToDatabase();
  try {
    const server = await startMockServer();   // if this throws, db is still closed
    try {
      await use({ db, server });
    } finally {
      await server.stop();                    // the server first
    }
  } finally {
    await db.disconnect();                     // the database last, even if stop() threw
  }
},
Python — для порівняння: так виглядає yield-фікстура в pytest
Python — for comparison: this is what a yield fixture looks like in pytest
import pytest

@pytest.fixture
def driver():
    d = start_browser()   # setup
    yield d               # тут виконується тест
    d.quit()              # teardown
import pytest

@pytest.fixture
def driver():
    d = start_browser()   # setup
    yield d               # the test runs here
    d.quit()              # teardown
💡 Коротко: у Playwright teardown — це код після await use(), і він виконується навіть для впалого тесту. try/finally додавай, коли може впасти сам setup або коли ресурсів кілька. yield у фікстурах — це pytest, не Playwright.
💡 In short: in Playwright the teardown is the code after await use(), and it runs even for a failed test. Add try/finally when the setup itself can fail or when there are several resources. yield in fixtures is pytest, not Playwright.
🔧 TypeScript / JS патерни в цьому прикладі base.extend<T>() — типізоване розширення test новими фікстурами; try/finally — блок finally виконується і після return, і після throw; вкладені try/finally прибирають ресурси у зворотному порядку створення.
🔧 TypeScript / JS patterns in this example base.extend<T>() — a typed extension of test with new fixtures; try/finally — the finally block runs after both return and throw; nested try/finally blocks release resources in reverse order of creation.
🗣 Як читати вголос У Playwright fixture — це async-функція, яка другим аргументом отримує use. Усе до await use(value) — setup, сам use — це момент, коли виконується тест, усе після нього — teardown. Важливо, що use повертається й тоді, коли тест упав, тож прибирання після нього спрацює в будь-якому разі. try/finally я додаю в двох випадках: коли може впасти сам setup і треба прибрати вже створене, і коли ресурсів кілька — тоді вкладені finally закривають їх у зворотному порядку, навіть якщо закриття одного кинуло помилку. І якщо на співбесіді питають про yield-фікстури, варто одразу уточнити, що це pytest: у Playwright для TypeScript такого синтаксису немає.
🗣 How to say it out loud In Playwright a fixture is an async function that gets use as its second argument. Everything before await use(value) is setup, the use call is when the test runs, and everything after it is teardown. The key point is that use returns even when the test failed, so the cleanup after it runs either way. I add try/finally in two cases: when the setup itself can fail and I need to release what was already created, and when there are several resources — nested finally blocks then close them in reverse order even if closing one of them threw. And if an interviewer asks about yield fixtures, I'd point out straight away that that's pytest: Playwright for TypeScript has no such syntax.
37 Що таке worker-scoped fixtures і в чому відмінність від test-scoped? What are worker-scoped fixtures and how do they differ from test-scoped ones?
FixturesMiddle▶

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

A worker is a separate Node.js process that Playwright starts to run tests in. If your config says workers: 4, Playwright starts 4 separate processes, and each one runs its share of the tests.

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

Test-scoped (the default) — the fixture is created from scratch for every test and destroyed after it. Every test gets a clean, isolated copy:

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

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

Worker-scoped — the fixture is created once when the worker starts and destroyed when the worker finishes. Every test in that worker uses the same copy:

Worker 1:
  → створити fixture (один раз)
  Тест 1 → використовує fixture
  Тест 2 → використовує ту саму fixture
  Тест 3 → використовує ту саму fixture
  → знищити fixture (один раз)
Worker 1:
  → create fixture (once)
  Test 1 → uses the fixture
  Test 2 → uses the same fixture
  Test 3 → uses the same fixture
  → destroy fixture (once)

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

Why worker-scoped? When the fixture is expensive to create — a database connection that takes 2 seconds, say. Test-scoped means connecting once per test. Worker-scoped means connecting once, and every test in that worker reuses it.

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

Important: worker-scoped fixtures are not shared between workers. Worker 1 and Worker 2 have their own separate instances — the isolation between workers stays intact.

TypeScript
TypeScript
type WorkerFixtures = { dbConnection: Database };

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

// Тепер всі тести в файлі використовують одне DB з'єднання
test('query users', async ({ dbConnection }) => {
  const users = await dbConnection.query('SELECT * FROM users');
  expect(users).toHaveLength(10);
});
type WorkerFixtures = { dbConnection: Database };

export const test = base.extend<{}, WorkerFixtures>({
  // Second parameter of extend<TestFixtures, WorkerFixtures>
  dbConnection: [async ({}, use) => {
    const db = await Database.connect(process.env.DB_URL!);
    await use(db);            // ← one connection per worker
    await db.disconnect();   // teardown after all of the worker's tests
  }, { scope: 'worker' }],   // ← the key parameter
});

// Now every test in the file shares one DB connection
test('query users', async ({ dbConnection }) => {
  const users = await dbConnection.query('SELECT * FROM users');
  expect(users).toHaveLength(10);
});
🔧 TypeScript / JS патерни в цьому прикладі scope: 'worker' — рядкове значення enum; fixture з scope — один екземпляр на весь worker-процес замість кожного тесту; async ({}, use) => {} — порожня деструктуризація коли інші fixtures не потрібні; await db.close() — cleanup після yield/use.
🔧 TypeScript / JS patterns in this example scope: 'worker' — a string enum value; a fixture with a scope — one instance per worker process instead of one per test; async ({}, use) => {} — an empty destructuring when no other fixtures are needed; await db.close() — cleanup after yield/use.
🗣 Як читати вголос base.extend приймає два generic параметри у кутових дужках: перший — test-scoped fixtures, другий — worker-scoped. Для worker-scoped fixture значення — не просто функція, а масив з двох елементів: функція і об'єкт з { scope: 'worker' }. Це ключова відмінність синтаксису. Все інше так само: use(db) передає з'єднання в тест, після use — teardown. Але тепер db.connect викликається один раз на весь worker і всі тести в ньому отримують той самий екземпляр.
🗣 How to say it out loud base.extend takes two generic parameters in angle brackets: the first is test-scoped fixtures, the second is worker-scoped. For a worker-scoped fixture the value isn't just a function — it's a two-element array: the function, and an object with { scope: 'worker' }. That's the key difference in syntax. Everything else is the same: use(db) hands the connection to the test, and the code after use is teardown. Only now db.connect is called once for the whole worker, and every test in it gets the same instance.
38 Що таке auto: true у fixtures і коли це краще ніж beforeEach? What is auto: true in a fixture, and when is it better than beforeEach?
FixturesSenior▶

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

An ordinary fixture only runs if you explicitly name it in the test's parameters:

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

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

auto: true changes that — the fixture runs for every test automatically, even if the test knows nothing about it and never listed it in its parameters.

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

Why would you want that? There are things you have to do before or after every test, and you would rather not clutter every test's parameter list with them:

  • Логувати початок і кінець кожного тесту в зовнішню систему
  • Перевіряти що немає JS-помилок на сторінці після кожного тесту
  • Встановлювати глобальний мок для аналітики щоб вона не заважала тестам
  • Робити скріншот після кожного тесту незалежно від результату
  • Log the start and the end of every test to an external system
  • Check there were no JS errors on the page after every test
  • Install a global mock for analytics so it stops interfering with the tests
  • Take a screenshot after every test, whatever the outcome

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

How it differs from beforeEach: an auto: true fixture is defined once in a shared file and applies automatically to every test that uses that test.extend(). beforeEach can be lifted into a base test or a shared helper, but auto fixtures scale noticeably better — setup and teardown live in one place, you need no separate afterEach, and the TypeScript typing comes along for free.

Як читати auto fixture по кроках
Reading an auto fixture step by step
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: "запускай це завжди, без оголошення в тесті"
});
export const test = base.extend({
// ↑ take the standard Playwright test and extend it with a new fixture

  autoLogger: [async ({ page }, use, testInfo) => {
// ↑ fixture name. We never write { autoLogger } in a test — it runs on its own
// { page } — other fixtures we need inside
// use    — the function to call to "hand over" to the test
// testInfo — metadata: test title, status, file path

    console.log(`▶ Starting: ${testInfo.title}`);
    // ↑ everything BEFORE use() runs BEFORE the test (setup)

    await use(undefined);
    // ↑ THE most important line. This is where the test itself runs.
    // undefined because we pass nothing to the test
    // Everything BEFORE this is setup. Everything AFTER is teardown.

    console.log(`✓ Done: ${testInfo.title}`);
    // ↑ everything AFTER use() runs AFTER the test (teardown)

  }, { auto: true }],
// ↑ { auto: true } — the second array element
// Tells Playwright: "always run this, without declaring it in the test"
});
TypeScript — базовий auto fixture: логування кожного тесту
TypeScript — a basic auto fixture: logging every test
// 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
});
// fixtures/base.ts — define the auto fixture once
import { test as base } from '@playwright/test';

export const test = base.extend({
  // auto: true — runs for EVERY test automatically
  // { auto: true } instead of a plain value
  autoLogger: [async ({ page }, use, testInfo) => {
    // Setup — runs BEFORE the test
    console.log(`▶ Starting: ${testInfo.title}`);
    const startTime = Date.now();

    // use() — the test itself runs here
    await use(undefined); // undefined because we pass nothing to the test

    // Teardown — runs AFTER the test
    const duration = Date.now() - startTime;
    console.log(`✓ Done: ${testInfo.title} (${duration}ms)`);
  }, { auto: true }], // ← here's the auto: true
});
Як читати — приклад 2: перевірка JS-помилок по кроках
Reading it — example 2: checking for JS errors, step by step
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..."
});
checkNoJsErrors: [async ({ page }, use) => {
  const errors: Error[] = [];

  page.on('pageerror', error => errors.push(error));
  // ↑ subscribe BEFORE use() — so the listener is active from the test's first second
  // subscribing AFTER use() would be too late — the test is already over

  await use(undefined);
  // ↑ the test runs. errors[] collects any errors

  expect(errors, `JS errors:
${errors.map(e => e.message).join('
')}`).toHaveLength(0);
  // ↑ AFTER the test — check that errors[] is empty
  // expect()'s second argument — custom message on failure
  // if there were no errors — the line passes, the test finishes normally
}, { auto: true }],

// The test knows NOTHING about checkNoJsErrors:
test('opens the PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');
  await expect(page.locator('h1')).toBeVisible();
  // If the page has a JS error — the test fails AFTER this line
  // with the message "JS errors: TypeError: cannot read..."
});
TypeScript — auto fixture для перевірки JS-помилок після кожного тесту
TypeScript — an auto fixture that checks for JS errors after every test
// 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-помилки — тест впаде з зрозумілим повідомленням
  // навіть якщо сам тест не перевіряє помилки
});
// fixtures/base.ts
export const test = base.extend({
  // Check for JS errors in EVERY test — the test doesn't know about it
  checkNoJsErrors: [async ({ page }, use) => {
    const errors: Error[] = [];

    // Subscribe before the test
    page.on('pageerror', error => errors.push(error));

    // The test runs
    await use(undefined);

    // After the test — check there were no JS errors
    expect(
      errors,
      `JS errors on the page:\n${errors.map(e => e.message).join('\n')}`
    ).toHaveLength(0);
  }, { auto: true }],
});

// tests/product.spec.ts — the test doesn't know about checkNoJsErrors
// but it still runs after every test
test('opens the PDP', async ({ page }) => {
  await page.goto('/product/trench-coat');
  // If the page has JS errors — the test fails with a clear message
  // even if the test itself doesn't check for errors
});
Як читати — scope: 'worker' по кроках
Reading it — scope: 'worker' step by step
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 тестів — набагато швидше
expensiveSetup: [async ({}, use) => {
  const db = await connectToTestDatabase();
  // ↑ connected to the DB ONCE — before all tests in this worker

  await use(undefined);
  // ↑ all tests in this worker run (could be 20-30 tests)

  await db.disconnect();
  // ↑ disconnected ONCE — after all tests in this worker

}, { auto: true, scope: 'worker' }],
// ↑ scope: 'worker' — the fixture lives as long as the worker process
// Without scope: 'worker' (i.e. the default scope: 'test'):
// the DB connection would be opened and closed for EVERY test separately
// With scope: 'worker': one connection for all 20-30 tests — much faster
TypeScript — auto fixture для блокування аналітики в усіх тестах
TypeScript — an auto fixture that blocks analytics in every test
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
});
export const test = base.extend({
  // Block GTM and analytics for ALL tests automatically
  // Tests are faster and don't depend on external services
  blockAnalytics: [async ({ page }, use) => {
    await page.route(
      '**/{gtm,analytics,hotjar,intercom}*',
      route => route.abort()
    );
    await use(undefined); // the test runs without analytics
    // no teardown needed — Playwright handles route.unroute() itself
  }, { auto: true }],

  // Scope: 'test' by default — a fresh one for every test
  // Scope: 'worker' — one per worker process (for expensive resources)
  expensiveSetup: [async ({}, use) => {
    const db = await connectToTestDatabase();
    await use(undefined);
    await db.disconnect();
  }, { auto: true, scope: 'worker' }], // DB connection once per worker
});
TypeScript — як використовувати кастомний test у файлах тестів
TypeScript — how to use the custom test inside your spec files
// ❌ Якщо імпортувати з @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();
});
// ❌ Import from @playwright/test and the auto fixtures won't run
// import { test, expect } from '@playwright/test';

// ✅ Import the custom test where our auto fixtures are defined
import { test, expect } from '../fixtures/base';

// Now every test in this file automatically gets:
// - logging via autoLogger
// - JS error checks via checkNoJsErrors
// - analytics blocked via blockAnalytics
// The test knows nothing about this and declares nothing
test('simple test', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('h1')).toBeVisible();
});
💡 auto fixture vs beforeEach — коли що:
beforeEach → простий setup в межах одного файлу, не потрібен teardown
auto: true fixture → setup + teardown в одному місці, застосовується до всіх тестів які використовують цей test.extend(), не треба повторювати в кожному файлі
💡 auto fixture vs beforeEach — which one when:
beforeEach → simple setup inside a single file, no teardown needed
auto: true fixture → setup + teardown in one place, applied to every test that uses this test.extend(), no need to repeat it in each file
🔧 TypeScript / JS патерни в цьому прикладі base.extend({ autoLogger: [async ({ page }, use, testInfo) => {}, { auto: true }] }) — масив де перший елемент — async функція, другий — конфігураційний об'єкт; { auto: true } — об'єктний літерал як конфігурація; page.on('load', async () => {}) — async callback для події.
🔧 TypeScript / JS patterns in this example base.extend({ autoLogger: [async ({ page }, use, testInfo) => {}, { auto: true }] }) — an array whose first element is an async function and whose second is a config object; { auto: true } — an object literal used as configuration; page.on('load', async () => {}) — an async callback for an event.
🗣 Як читати вголос Звичайна фікстура виконується тільки якщо явно вказати її в параметрах тесту. 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 напряму.
🗣 How to say it out loud An ordinary fixture only runs if you name it in the test's parameters. auto: true drops that requirement — the fixture runs for every test automatically, and the test knows nothing about it and declares nothing. That's handy for cross-cutting concerns, the things you want for all tests: logging how long a test took, checking there were no JS errors afterwards, blocking analytics so the tests don't depend on third-party services. Technically an auto fixture is written like any other custom fixture through test.extend(), except that instead of a plain value you pass an array whose second element is an object with { auto: true }. Inside, it still has the three parts: everything before use() is setup, await use(undefined) is where the test itself runs, and everything after use() is teardown. That is exactly what makes it better than beforeEach: setup and teardown sit together with no separate afterEach, and it's defined once for every test that imports the custom test instead of in each file separately. One gotcha worth mentioning out loud: for auto fixtures to fire at all, the tests have to import test from the file where they're defined, not straight from @playwright/test.
39 Що таке test.beforeEach / afterEach / beforeAll / afterAll? What are test.beforeEach / afterEach / beforeAll / afterAll?
FixturesJunior▶

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

We already know how to do one thing inside a test. But once there are a lot of tests, each of them needs a similar preparation step: open a particular page, log in, seed some test data. Writing that out in every test is duplication. Hooks exist for exactly that.

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

Hooks are functions Playwright calls automatically at a defined moment: before a test, after a test, before all the tests in a block, after all of them. They live inside test.describe() and only affect the tests of that block.

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

Important about scope: beforeAll/afterAll run once per worker, not once per run. Run in parallel with several workers and beforeAll runs in each worker separately. If you need code to run exactly once for the whole run, use globalSetup or project dependencies.

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

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

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

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

  test.afterAll(async ({ request }) => {
    // Після всіх тестів блоку
    await request.delete('/api/test-data');
  });
});
import { test, expect } from '@playwright/test';

test.describe('Shopping Cart', () => {
  test.beforeAll(async ({ request }) => {
    // Runs ONCE before all tests in the block
    // Scope: worker (shared across tests in one worker)
    await request.post('/api/seed-products');
  });

  test.beforeEach(async ({ page }) => {
    // Before EVERY test
    await page.goto('/cart');
  });

  test.afterEach(async ({ page }, testInfo) => {
    // After each one — useful for cleanup
    if (testInfo.status !== testInfo.expectedStatus) {
      await page.screenshot({ path: `failure-${testInfo.title}.png` });
    }
  });

  test.afterAll(async ({ request }) => {
    // After all tests in the block
    await request.delete('/api/test-data');
  });
});
🔧 TypeScript / JS патерни в цьому прикладі test.beforeEach(async ({ page }) => {}) — hook: функція що виконується перед кожним тестом; test.beforeAll(async () => {}) — hook без деструктуризації (нам не потрібна page); хуки — це callbacks які Playwright викликає автоматично у визначений момент.
🔧 TypeScript / JS patterns in this example test.beforeEach(async ({ page }) => {}) — a hook: a function that runs before every test; test.beforeAll(async () => {}) — a hook without destructuring (we don't need page here); hooks are callbacks Playwright calls automatically at a defined moment.
🗣 Як читати вголос test.describe — це блок групування, першим аргументом назва групи, другим стрілочна функція з тестами. Всередині: test.beforeAll отримує fixtures і асинхронний колбек — він запускається один раз перед усіма тестами блоку. test.beforeEach — перед кожним тестом окремо, тут переходимо на потрібну сторінку. test.afterEach отримує другим аргументом testInfo — об'єкт з мета-інформацією про тест, порівнюємо status з expectedStatus і якщо не збігаються — робимо скриншот. test.afterAll — прибираємо тестові дані після всього блоку.
🗣 How to say it out loud test.describe is a grouping block — the first argument is the group name, the second an arrow function holding the tests. Inside it, test.beforeAll takes fixtures and an async callback and runs once before all the tests in the block. test.beforeEach runs before each individual test — that's where we navigate to the page we need. test.afterEach gets testInfo as its second argument, an object with metadata about the test; we compare status with expectedStatus and take a screenshot when they don't match. test.afterAll cleans the test data up once the whole block is done.
40 Який порядок виконання beforeAll, beforeEach, afterEach, afterAll при вкладених describe? What is the execution order of beforeAll, beforeEach, afterEach and afterAll with nested describes?
FixturesSenior▶

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

With a single describe it's obvious: beforeAll → beforeEach → test → afterEach → afterAll. But once describes are nested the order gets less obvious, and this is a common one to get wrong.

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

The base rule: hooks run outside-in on the way in and inside-out on the way out. Picture the layers of an onion — you take the outer layer off first, then the inner one. On the way back out it's the other way round.

зовнішній beforeAll        ← виконується ОДИН РАЗ перед усіма тестами
  внутрішній beforeAll     ← виконується ОДИН РАЗ перед тестами вкладеного describe
    зовнішній beforeEach   ← для КОЖНОГО тесту
      внутрішній beforeEach ← для КОЖНОГО тесту у вкладеному describe
        → ТЕСТ
      внутрішній afterEach  ← для КОЖНОГО тесту
    зовнішній afterEach    ← для КОЖНОГО тесту
  внутрішній afterAll      ← ОДИН РАЗ після всіх тестів вкладеного describe
зовнішній afterAll         ← ОДИН РАЗ після ВСІХ тестів включно з вкладеними
outer beforeAll        ← runs ONCE before all tests
  inner beforeAll      ← runs ONCE before the nested describe's tests
    outer beforeEach   ← for EVERY test
      inner beforeEach ← for EVERY test in the nested describe
        → TEST
      inner afterEach  ← for EVERY test
    outer afterEach    ← for EVERY test
  inner afterAll       ← ONCE after all tests of the nested describe
outer afterAll         ← ONCE after ALL tests, including nested ones
  • Проблема 1: beforeAll зовнішнього describe виконується для всіх тестів включно з вкладеними — навіть якщо setup потрібен тільки для зовнішніх тестів
  • Проблема 2: якщо зовнішній beforeEach встановлює стан, а внутрішній перевизначає — зовнішній все одно виконається першим
  • Проблема 3: afterAll зовнішнього describe чекає поки завершаться ВСІ вкладені describe включно з їх afterAll
  • Problem 1: the outer describe's beforeAll runs for every test, nested ones included — even when the setup is only needed by the outer tests
  • Problem 2: if the outer beforeEach sets some state and the inner one overrides it, the outer one still runs first
  • Problem 3: the outer describe's afterAll waits for EVERY nested describe to finish, their own afterAll included
TypeScript — повний приклад з виводом порядку
TypeScript — a full example that prints the order
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('Outer', () => {
  test.beforeAll(() => console.log('1. outer beforeAll'));
  test.afterAll(() =>  console.log('8. outer afterAll'));
  test.beforeEach(() => console.log('3. outer beforeEach'));
  test.afterEach(() =>  console.log('6. outer afterEach'));

  test.describe('Inner', () => {
    test.beforeAll(() => console.log('2. inner beforeAll'));
    test.afterAll(() =>  console.log('7. inner afterAll'));
    test.beforeEach(() => console.log('4. inner beforeEach'));
    test.afterEach(() =>  console.log('5. inner afterEach'));

    test('test', () => console.log('→ TEST'));
  });
});

// Console output:
// 1. outer beforeAll
// 2. inner beforeAll
// 3. outer beforeEach   ← outer FIRST
// 4. inner beforeEach
// → TEST
// 5. inner afterEach    ← inner FIRST on exit
// 6. outer afterEach
// 7. inner afterAll
// 8. outer afterAll     ← outer LAST
TypeScript — реальний кейс: різні ролі у вкладених describe
TypeScript — a real case: different roles in nested describes
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();
    });
  });
});
test.describe('Checkout', () => {
  // Outer beforeEach: shared setup for all checkout tests
  test.beforeEach(async ({ page }) => {
    await page.goto('/cart');
    await page.locator('.add-to-cart').click();
    // Runs for ALL tests — both outer and nested
  });

  test('cart is not empty', async ({ page }) => {
    await expect(page.locator('.cart-count')).toHaveText('1');
  });

  test.describe('logged-in user', () => {
    // Inner beforeEach: runs AFTER the outer one
    // So the order is: goto('/cart') → click('.add-to-cart') → loginAsUser()
    test.beforeEach(async ({ page }) => {
      await loginAsUser(page); // log in once the item is already in the cart
    });

    test('keeps the cart after login', async ({ page }) => {
      await expect(page.locator('.cart-count')).toHaveText('1');
    });
  });

  test.describe('guest', () => {
    // This beforeEach also runs AFTER the outer one
    test.beforeEach(async ({ page }) => {
      await page.locator('.guest-checkout').click();
    });

    test('guest sees the payment form', async ({ page }) => {
      await expect(page.locator('.payment-form')).toBeVisible();
    });
  });
});
TypeScript — пастка: beforeAll зовнішнього виконується для всіх вкладених
TypeScript — the trap: the outer beforeAll runs for every nested test too
// ❌ Типова помилка: думаємо що зовнішній 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 }) => { /* ... */ });
  });
});
// ❌ Common mistake: assuming the outer beforeAll is only for the outer tests
test.describe('Products', () => {
  test.beforeAll(async () => {
    await seedDatabase(); // ← also runs before the nested describe's tests!
  });

  test('shows the product list', async ({ page }) => { /* ... */ });

  test.describe('Filters', () => {
    // seedDatabase() also runs before these tests
    // even though the filters don't need the database
    test('price filter', async ({ page }) => { /* ... */ });
  });
});

// ✅ Correct: if setup is only needed for specific tests — move it inside
test.describe('Products', () => {
  test('shows the product list', async ({ page }) => { /* ... */ });

  test.describe('with database', () => {
    test.beforeAll(async () => {
      await seedDatabase(); // now only for this nested describe
    });
    test('price filter', async ({ page }) => { /* ... */ });
  });
});
💡 Мнемоніка для запам'ятовування:
Вхід: зовнішній → внутрішній (від шкірки цибулі до центру)
Вихід: внутрішній → зовнішній (від центру до шкірки)
beforeAll і afterAll — один раз на весь describe
beforeEach і afterEach — для кожного тесту окремо, але той самий принцип зовнішній→внутрішній
💡 A mnemonic to make it stick:
Going in: outer → inner (from the onion's skin to the centre)
Coming out: inner → outer (from the centre back out to the skin)
beforeAll and afterAll — once per describe
beforeEach and afterEach — once per test, but the same outer→inner principle
🔧 TypeScript / JS патерни в цьому прикладі test.beforeAll(() => console.log('1. outer beforeAll')) — стрілочна функція без async (якщо не потрібен await); console.log() — виклик методу глобального об'єкту console; числа в рядках для демонстрації порядку виконання.
🔧 TypeScript / JS patterns in this example test.beforeAll(() => console.log('1. outer beforeAll')) — an arrow function without async (when there is nothing to await); console.log() — a method call on the global console object; the numbers in the strings are there purely to show the order.
🗣 Як читати вголос При вкладених describe блоках хуки виконуються за принципом "від зовнішнього до внутрішнього при вході, від внутрішнього до зовнішнього при виході" — як шари цибулі. Для beforeEach це означає що зовнішній виконується першим, потім внутрішній, потім тест, потім внутрішній afterEach, і тільки потім зовнішній afterEach. Для beforeAll важлива пастка: він виконується один раз але для ВСІХ тестів в describe включно з вкладеними — тобто якщо ти зробила дорогий setup в зовнішньому beforeAll, він спрацює і перед тестами вкладеного describe, навіть якщо вони цього setup не потребують. Рішення: якщо setup потрібен тільки для певної групи тестів — перенести його у beforeAll того конкретного вкладеного describe, а не тримати у зовнішньому. afterAll зовнішнього describe чекає поки завершаться абсолютно всі вкладені describe включно з їх власними afterAll, тому teardown в зовнішньому afterAll виконується дійсно в самому кінці.
🗣 How to say it out loud With nested describe blocks the hooks run on the "outside-in going in, inside-out coming out" principle — like the layers of an onion. For beforeEach that means the outer one runs first, then the inner one, then the test, then the inner afterEach, and only then the outer afterEach. For beforeAll there's a trap worth knowing: it runs once, but for ALL the tests in the describe, the nested ones included — so if you put an expensive setup in the outer beforeAll, it fires before the nested describe's tests too, even when they don't need it. The fix is simple: if the setup is only needed by one group, move it into that nested describe's own beforeAll instead of keeping it outside. And the outer afterAll waits for absolutely every nested describe to finish, including their own afterAll, so teardown in the outer afterAll really does happen last.
41 Чому не можна шарити стан між тестами через звичайну глобальну змінну? Why can't you share state between tests through a plain global variable?
FixturesMiddle▶

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

The natural reflex is to stash the shared value in a global variable. In Playwright that behaves differently depending on your settings — and understanding why saves you from some non-obvious bugs.

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

With fullyParallel: false (the default) — the whole file runs in one worker. Tests in the same file go one after another, so a file-level global variable does work for the tests of that file:

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

test.beforeAll(async () => { sharedData = 'готово'; });
test('тест 1', async () => { console.log(sharedData); }); // ✅ бачить
test('тест 2', async () => { console.log(sharedData); }); // ✅ бачить
// product.spec.ts — all tests in one worker
let sharedData: string;

test.beforeAll(async () => { sharedData = 'ready'; });
test('test 1', async () => { console.log(sharedData); }); // ✅ sees it
test('test 2', async () => { console.log(sharedData); }); // ✅ sees it

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

With fullyParallel: true, or across different files — the tests spread out over different workers (separate Node.js processes). Every process has its own copy of memory, so a global variable in one process is invisible to another:

// ❌ Якщо тести потрапили в різні workers
let testCounter = 0;
test('тест 1', async () => { testCounter++; console.log(testCounter); }); // 1
test('тест 2', async () => { testCounter++; console.log(testCounter); }); // теж 1!
// Кожен worker має свою копію testCounter — вони не знають один про одного
// ❌ If the tests land in different workers
let testCounter = 0;
test('test 1', async () => { testCounter++; console.log(testCounter); }); // 1
test('test 2', async () => { testCounter++; console.log(testCounter); }); // also 1!
// Each worker has its own copy of testCounter — they don't know about each other

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

The takeaway: a global variable inside a single file at fullyParallel: false does technically work. But it's an unreliable pattern: the behaviour changes the moment you turn fullyParallel on or move the tests into another file. Better to use test.beforeAll with a local variable in the describe block, or a worker-scoped fixture — which says out loud "this is shared for the worker".

TypeScript — чому глобальна змінна не працює як очікується
TypeScript — why a global variable doesn't behave the way you expect
// ❌ Очікування: лічильник рахує всі тести по черзі
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 }));
// ❌ Expectation: the counter counts all tests in order
let testCounter = 0;

test('test 1', async () => {
  testCounter++;
  console.log(testCounter); // may print 1
});

test('test 2', async () => {
  testCounter++;
  console.log(testCounter); // may also print 1, not 2!
});
// If tests 1 and 2 land in DIFFERENT workers — they're separate Node.js processes,
// each with its own module copy and memory → testCounter is NOT shared

// ✅ For truly shared state — use external storage (file, DB, API)
await writeFile('./counter.json', JSON.stringify({ count: 1 }));
💡 Модель паралелізму Playwright:
fullyParallel: false (за замовчуванням) — один файл = один worker, тести всередині файлу послідовно, різні файли паралельно в різних workers.
fullyParallel: true — кожен тест може отримати свій worker, навіть тести з одного файлу.
Worker — це окремий Node.js процес (не потік!), тому змінні між workers не шаряться.
💡 Playwright's parallelism model:
fullyParallel: false (the default) — one file = one worker, tests inside the file run sequentially, different files run in parallel in different workers.
fullyParallel: true — every test can get its own worker, including tests from the same file.
A worker is a separate Node.js process (not a thread!), which is why variables are never shared between workers.
🔧 TypeScript / JS патерни в цьому прикладі let sharedState = 0 — змінна в module scope; sharedState++ — increment оператор; кожен тест — окремий Node.js worker process: змінна існує тільки в межах свого процесу; workerInfo.workerIndex — доступ до індексу worker через об'єкт testInfo.
🔧 TypeScript / JS patterns in this example let sharedState = 0 — a variable in module scope; sharedState++ — the increment operator; each test is a separate Node.js worker process: the variable only exists inside its own process; workerInfo.workerIndex — the worker index, reachable through the testInfo object.
🗣 Як читати вголос Playwright розпаралелює тести не через потоки в одному процесі, а через окремі Node.js процеси-воркери — за замовчуванням кожен test-файл отримує свій worker. Це означає, що звичайна змінна на рівні модуля, типу let testCounter = 0, в кожному воркері існує як власна, ізольована копія в пам'яті того процесу: зміна цієї змінної в одному файлі ніяк не видна іншому файлу, навіть якщо обидва імпортують той самий модуль. Це не баг, а навмисний дизайн — ізоляція процесів означає, що один тест не може випадково зламати стан іншого, навіть якщо в коді є помилка з мутацією спільних об'єктів. Якщо реально потрібен спільний стан між тестами — наприклад, лічильник чи прапорець "цей ресурс вже створено" — треба використовувати щось зовнішнє відносно Node.js процесу: файл на диску, запис у базі даних, чи API-виклик до сервісу, тому що тільки зовнішнє сховище буде дійсно одним і тим самим для всіх воркерів одночасно.
🗣 How to say it out loud Playwright parallelises tests through separate Node.js worker processes rather than threads inside one process — by default each spec file gets its own worker. Which means a plain module-level variable like let testCounter = 0 exists in every worker as its own isolated copy in that process's memory: changing it in one file is invisible to another file, even if both import the same module. That isn't a bug, it's the design — process isolation means one test can't accidentally corrupt another's state, even when the code does something careless with shared objects. If you genuinely need shared state between tests — a counter, or a "this resource already exists" flag — it has to live outside the Node.js process: a file on disk, a row in a database, an API call to some service, because only external storage is actually the same thing for every worker at once.
42 Коли реально потрібні вкладені describe? Чому не завжди достатньо одного? When do you actually need nested describes? Why isn't one always enough?
FixturesSenior▶

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

For most cases a flat structure with one describe and one beforeEach is the best answer. It's simpler and easier to read:

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.describe('/account page', () => {
  test.beforeEach(async ({ page }) => { await page.goto('/account'); });

  test('admin sees the delete button', async ({ page }) => {
    await loginAs(page, 'admin');
    await expect(page.locator('.delete-btn')).toBeVisible();
  });

  test('user does NOT see the delete button', async ({ page }) => {
    await loginAs(page, 'user');
    await expect(page.locator('.delete-btn')).not.toBeVisible();
  });
});

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

Nested describes are justified in exactly one real case — when a whole group of tests needs a shared multi-step beforeEach that hurts to duplicate in every test.

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

The rule is simple: if you can write it flat, write it flat. Nest only when there is real setup duplication that actually hurts.

TypeScript — коли вкладені describe НЕ потрібні: плоска структура
TypeScript — when you do NOT need nested describes: the flat structure
// ✅ Один 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');
  });
});
// ✅ One describe, beforeEach opens the page, each test logs in itself
// Simple, readable, no needless nesting
test.describe('/account page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/account');
  });

  test('admin sees the delete button', async ({ page }) => {
    await loginAs(page, 'admin');
    await expect(page.locator('.delete-btn')).toBeVisible();
  });

  test('user does NOT see the delete button', async ({ page }) => {
    await loginAs(page, 'user');
    await expect(page.locator('.delete-btn')).not.toBeVisible();
  });

  test('guest is redirected to /login', async ({ page }) => {
    await expect(page).toHaveURL('/login');
  });
});
TypeScript — коли вкладені describe ПОТРІБНІ: багато спільних кроків і кілька тестів у групі
TypeScript — when you DO need them: many shared steps and several tests per group
// Спільний 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 }) => { /* ... */ });
  });
});
// Shared setup: 3 steps for all + 1 group-specific + 3 tests per group
// Without nesting: 4 steps × 6 tests = 24 duplicated lines
// With nesting: 3 shared + 1×2 specific = 5 setup lines
test.describe('Checkout', () => {

  test.beforeEach(async ({ page }) => {
    // 3 shared steps for ALL tests
    await page.goto('/cart');
    await page.locator('.add-to-cart').first().click();
    await page.locator('.proceed-to-checkout').click();
  });

  test.describe('logged-in user', () => {
    test.beforeEach(async ({ page }) => {
      await loginAs(page, 'user'); // +1 step for this group only
    });

    // Without nested describe — you'd repeat 4 steps in each of the 3 tests
    test('sees saved addresses', async ({ page }) => { /* ... */ });
    test('sees saved cards', async ({ page }) => { /* ... */ });
    test('can check out in one click', async ({ page }) => { /* ... */ });
  });

  test.describe('guest', () => {
    test.beforeEach(async ({ page }) => {
      await page.locator('.guest-checkout').click(); // +1 step for guest
    });

    test('sees the address form', async ({ page }) => { /* ... */ });
    test('sees the card form', async ({ page }) => { /* ... */ });
    test('does not see "one click"', async ({ page }) => { /* ... */ });
  });
});
TypeScript — порівняння: без вкладеного describe (дублювання) і з ним
TypeScript — side by side: without a nested describe (duplication) and with one
// ❌ Без вкладеного 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 }) => { /* тільки тест */ });
});
// ❌ Without nested describe — 4 steps repeated in every test
test('user sees saved addresses', async ({ page }) => {
  await page.goto('/cart');                              // step 1
  await page.locator('.add-to-cart').first().click();   // step 2
  await page.locator('.proceed-to-checkout').click();   // step 3
  await loginAs(page, 'user');                           // step 4
  // ... the actual test
});

test('user sees saved cards', async ({ page }) => {
  await page.goto('/cart');                              // step 1 — again!
  await page.locator('.add-to-cart').first().click();   // step 2 — again!
  await page.locator('.proceed-to-checkout').click();   // step 3 — again!
  await loginAs(page, 'user');                           // step 4 — again!
  // ... the actual test
});
// And 4 more times for the remaining tests... 24 duplicated lines

// ✅ With nested describe — setup written once, the test is just the test
test.describe('logged-in user', () => {
  test.beforeEach(...); // 4 steps written ONCE

  test('sees saved addresses', async ({ page }) => { /* just the test */ });
  test('sees saved cards', async ({ page }) => { /* just the test */ });
});
💡 Коли вкладені describe виправдані — простий чеклист:
Є 2+ тести які потребують однакового setup з кількох кроків → вкладений describe
Setup відрізняється для різних груп тестів → окремий вкладений describe для кожної групи
Тільки один тест у групі → не варто, просто напиши все в тесті
Setup з одного кроку → теж зазвичай не варто, напиши прямо в тесті
💡 When a nested describe is justified — a short checklist:
Two or more tests need the same multi-step setup → nested describe
The setup differs between groups of tests → a separate nested describe per group
Only one test in the group → not worth it, just write it inside the test
A one-step setup → usually not worth it either, write it straight into the test
🔧 TypeScript / JS патерни в цьому прикладі test.describe('outer', () => { test.describe('inner', () => {}) }) — вкладені виклики функцій; test.beforeEach(async ({ page }) => {}) — hook з async callback; scope вкладеності: внутрішній beforeEach "бачить" зовнішній через closure.
🔧 TypeScript / JS patterns in this example test.describe('outer', () => { test.describe('inner', () => {}) }) — nested function calls; test.beforeEach(async ({ page }) => {}) — a hook with an async callback; nesting scope: the inner beforeEach "sees" the outer one through the closure.
🗣 Як читати вголос Плоска структура з одним describe і beforeEach — це завжди перший вибір, вона простіша і читабельніша. Якщо кожен тест потребує свого специфічного логіну або дії — просто пишемо це прямо в тест, без вкладення. Вкладені describe виправдані тільки коли є реальне дублювання яке болить: конкретно — коли є кілька кроків setup які однакові для цілої групи тестів, і ця група достатньо велика щоб дублювання відчувалось. Наприклад, три кроки щоб дістатись до сторінки checkout плюс логін — це чотири рядки які треба повторювати в кожному з шести тестів, виходить двадцять чотири рядки дублювання. З вкладеним describe ці чотири кроки пишуться один раз у beforeEach, і кожен тест містить тільки саму перевірку. Але якщо у вкладеному describe тільки один тест — вкладення не виправдане, простіше написати setup прямо в тесті.
🗣 How to say it out loud A flat structure with one describe and one beforeEach is always the first choice — it's simpler and it reads better. If each test needs its own specific login or action, just write that inside the test, no nesting. Nested describes earn their keep only when there's real duplication that hurts: specifically, when several setup steps are identical for a whole group of tests and the group is big enough that repeating them stings. Say it takes three steps to get to the checkout page plus a login — that's four lines you'd repeat in each of six tests, twenty-four lines of duplication. With a nested describe those four steps go into one beforeEach and every test is just the assertion. But if the nested describe holds a single test, the nesting isn't worth it — it's easier to put the setup in the test itself.
43 Що таке test.describe.serial() і чому це навмисний виняток з ізоляції тестів? What is test.describe.serial() and why is it a deliberate exception to test isolation?
FixturesMiddle▶

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

Playwright isolates every test — a new BrowserContext for each one. test.describe.serial() deliberately breaks that rule. There are E2E scenarios where the tests have to run in order and each one depends on the result of the previous: register → log in → place an order. If the first step failed there is no point running the rest.

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

// ✅ 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, не запуститься взагалі
  });
});
// By default: tests are independent, order isn't guaranteed,
// one failing does NOT affect the others

// ✅ serial: deliberately break isolation when tests are logically dependent
test.describe.serial('checkout flow step by step', () => {
  let orderId: string;

  test('1. add item to cart', async ({ page }) => {
    await page.goto('/cart');
    // ...
  });

  test('2. place order', async ({ page }) => {
    // runs ONLY if test 1 passed; orderId is available afterwards
    orderId = 'ORD-123';
  });

  test('3. check confirmation email', async () => {
    // if test 2 failed — this test is SKIPPED and never runs
  });
});
💡 Коли використовувати: рідко, тільки коли кроки реально не мають сенсу окремо (наприклад, багатокроковий checkout flow). Зловживання serial — ознака поганого дизайну тестів, бо втрачається паралелізм і ізоляція, заради якої ми все це робили.
💡 When to use it: rarely, and only when the steps genuinely make no sense on their own (a multi-step checkout flow, for example). Leaning on serial is a sign of poor test design, because you give up the parallelism and the isolation you did all of this for.
🔧 TypeScript / JS патерни в цьому прикладі test.describe.serial('назва', () => {}) — виклик методу на вкладеному об'єкті describe; .serial — property access на test.describe; serial mode: тести виконуються послідовно і якщо один впав — наступні автоматично skip; виняток з правила ізоляції [Якір 1].
🔧 TypeScript / JS patterns in this example test.describe.serial('name', () => {}) — a method call on a nested describe object; .serial — property access on test.describe; serial mode: the tests run in order and if one fails the rest are skipped automatically; an exception to the isolation rule [Anchor 1].
🗣 Як читати вголос Усе, що ми обговорювали про ізоляцію — окремий BrowserContext, окремий worker-процес — будується на припущенні, що тести незалежні: порядок виконання не гарантований, і фейл одного не повинен впливати на інші. test.describe.serial() — це навмисний, явний виняток із цього правила: всередині такого блоку тести виконуються строго по порядку, в одному й тому самому worker, і якщо один тест впаде — всі наступні в цьому ж serial-блоці автоматично позначаються як skipped, а не запускаються взагалі. Це корисно, коли кроки логічно є частинами одного бізнес-флоу, наприклад покроковий checkout, де немає сенсу перевіряти "підтвердження замовлення", якщо саме замовлення не було створено — простіше явно показати залежність через serial, ніж штучно ділити це на один величезний тест. Але це варто використовувати рідко й усвідомлено, бо кожен serial-блок втрачає головні переваги звичайних тестів — паралелізм і незалежність, тому якщо serial зустрічається часто в кодовій базі, це зазвичай сигнал, що тести варто перепроєктувати на менш зв'язані одиниці.
🗣 How to say it out loud Everything we've said about isolation — a separate BrowserContext, a separate worker process — rests on the assumption that tests are independent: the order isn't guaranteed, and one failing shouldn't affect the others. test.describe.serial() is a deliberate, explicit exception to that rule: inside such a block the tests run strictly in order, in one and the same worker, and if one fails, every later test in that serial block is marked skipped rather than run at all. It's useful when the steps really are parts of one business flow — a step-by-step checkout, say, where there's no point checking the order confirmation if the order was never created — and it's cleaner to state that dependency with serial than to bolt the whole thing into one enormous test. But use it rarely and consciously, because every serial block gives up the two things ordinary tests are good at, parallelism and independence, so if serial turns up a lot in a codebase that's usually a signal the tests want redesigning into less coupled units.
44 Що таке fullyParallel і чим він відрізняється від звичайного паралелізму між файлами? What is fullyParallel and how does it differ from ordinary file-level parallelism?
FixturesSenior▶

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

By default Playwright parallelises at the file level: every .spec.ts file gets its own worker process and runs in parallel with the other files. But inside a single file the tests run sequentially — one after another.

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

Picture it: you have a file with 20 tests. One worker picked that file up and works through all 20 in turn — the second doesn't start until the first has finished. Meanwhile every other worker is either waiting or busy with other files.

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

fullyParallel: true changes that: now every individual test() — whatever file it lives in — can get its own worker. 20 tests from one file can run across 6 workers at once.

  • Корисно коли: є великі файли з багатьма незалежними тестами — без fullyParallel весь файл виконується на одному worker, інші workers простоюють
  • Небезпечно коли: тести в одному файлі мають спільний стан або залежать один від одного — fullyParallel може зламати порядок. Але якщо всі тести правильно ізольовані (Якір 1) — проблем немає
  • Useful when: you have big files with many independent tests — without fullyParallel the whole file runs on one worker while the others sit idle
  • Dangerous when: tests in one file share state or depend on each other — fullyParallel can break the order. But if every test is properly isolated (Anchor 1) there is no problem

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

💡 fullyParallel (config) vs mode: 'parallel' (describe):
fullyParallel: true in the config — a global setting for the whole project.
test.describe.configure({ mode: 'parallel' }) — turns parallelism on locally, for one describe block only. Useful when fullyParallel: false globally but there is one block where you do want it.
test.describe.configure({ mode: 'serial' }) — turns parallelism off locally (when fullyParallel: true globally, but this block has dependencies).

TypeScript — без fullyParallel vs з fullyParallel
TypeScript — without fullyParallel vs with it
// Без 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)
// Without fullyParallel (default):
// checkout.spec.ts has 10 tests
// → Worker 1 runs all 10 tests sequentially: test1 → test2 → ... → test10
// → Workers 2-6 meanwhile run other files or sit idle
// → If checkout.spec.ts is the largest file — it becomes the "bottleneck"

// With fullyParallel: true:
// checkout.spec.ts has 10 tests
// → Workers 1-6 pick tests from checkout.spec.ts one at a time
// → test1(W1), test2(W2), test3(W3), test4(W4), test5(W5), test6(W6) — in parallel
// → Then test7(W1), test8(W2), test9(W3), test10(W4) — second round
// → 10 tests take ~2x time instead of ~10x (2 rounds of 6)
playwright.config.ts — увімкнення fullyParallel
playwright.config.ts — turning fullyParallel on
export default defineConfig({
  // Увімкнути паралелізм на рівні окремих тестів (не тільки файлів)
  fullyParallel: true,

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

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

// Можна також вимкнути fullyParallel для конкретного файлу або describe:
export default defineConfig({
  // Enable parallelism at the individual test level (not just files)
  fullyParallel: true,

  // The number of workers doesn't change — only how they're used
  workers: process.env.CI ? 4 : undefined, // undefined = automatic (50% CPU cores)

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

// You can also disable fullyParallel for a specific file or describe:
TypeScript — локальне вимкнення fullyParallel для залежних тестів
TypeScript — turning fullyParallel off locally for dependent tests
// Якщо 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 }) => { /* ... */ });
// If fullyParallel: true is global but a file has dependent tests —
// you can disable parallelism locally for that file

// Option 1: the whole file runs serially
test.describe.configure({ mode: 'serial' }); // at the top level of the file

test('step 1: login', async ({ page }) => { /* ... */ });
test('step 2: add item', async ({ page }) => { /* ... */ });
test('step 3: place order', async ({ page }) => { /* ... */ });
// All three run serially even with fullyParallel: true

// Option 2: only one describe block runs serially, the rest of the file in parallel
test.describe('dependent steps', () => {
  test.describe.configure({ mode: 'serial' });
  test('step 1', async ({ page }) => { /* ... */ });
  test('step 2', async ({ page }) => { /* ... */ });
});

// Independent tests outside the describe run in parallel
test('independent test A', async ({ page }) => { /* ... */ });
test('independent test B', async ({ page }) => { /* ... */ });
TypeScript — реальний вплив на час прогону
TypeScript — the real effect on run time
// Приклад: ~4000 тестів, 6 workers
//
// БЕЗ fullyParallel:
// Якщо є 10 файлів по 400 тестів — кожен файл на 1 worker
// Паралельно виконується max 6 файлів, кожен послідовно
// "Вузьке місце" = найдовший файл × кількість файлів / 6 workers
//
// З fullyParallel: true:
// Всі 4000 тестів розподіляються між 6 workers незалежно від файлів
// Workers ніколи не чекають — завжди беруть наступний тест зі спільної черги
// Теоретичний мінімум: загальний час / 6 (при ідеальному балансуванні)
// На практиці: регресія <3 год → може скоротитись до ~1.5 год
// Example: ~4000 tests, 6 workers
//
// WITHOUT fullyParallel:
// With 10 files of 400 tests each — each file goes to 1 worker
// At most 6 files run in parallel, each one sequentially
// "Bottleneck" = longest file × number of files / 6 workers
//
// With fullyParallel: true:
// All 4000 tests are spread across 6 workers regardless of file
// Workers never idle — they always take the next test from a shared queue
// Theoretical minimum: total time / 6 (with perfect balancing)
// In practice: a <3 h regression → can drop to ~1.5 h
💡 Коли вмикати fullyParallel:
Вмикай якщо: тести добре ізольовані (окремі fixtures, немає спільного стану), файли великі і нерівномірні за розміром, хочеш максимально завантажити всі workers.
Не вмикай якщо: є багато test.describe.serial() блоків, або тести залежать від порядку виконання — fullyParallel не зламає їх але й не пришвидшить.
💡 When to turn fullyParallel on:
Turn it on if: the tests are well isolated (own fixtures, no shared state), the files are large and uneven in size, and you want every worker fully loaded.
Don't bother if: there are lots of test.describe.serial() blocks, or the tests depend on execution order — fullyParallel won't break them, but it won't speed them up either.
🔧 TypeScript / JS патерни в цьому прикладі fullyParallel: true — булева конфігурація; workers: process.env.CI ? 4 : undefined — тернарний оператор; undefined — означає "використай значення за замовчуванням"; test.describe.configure({ mode: 'serial' }) — виклик на вкладеному об'єкті.
🔧 TypeScript / JS patterns in this example fullyParallel: true — a boolean config value; workers: process.env.CI ? 4 : undefined — the ternary operator; undefined — means "use the default"; test.describe.configure({ mode: 'serial' }) — a call on a nested object.
🗣 Як читати вголос За замовчуванням Playwright паралелізує на рівні файлів: кожен spec-файл отримує свій worker і виконується паралельно з іншими файлами, але всередині файлу тести ідуть послідовно. Це означає що якщо у тебе є файл з 50 тестами — один worker виконує їх всі по черзі, а інші workers в цей час або виконують менші файли або чекають. fullyParallel: true прибирає це обмеження: тепер кожен окремий тест незалежно від файлу може піти на вільний worker, як задачі в черзі — workers беруть наступну задачу як тільки звільняються. Кількість workers залишається тою самою — змінюється тільки гранулярність розподілу. Якщо тести правильно ізольовані, як і має бути за Якорем 1, fullyParallel дає відчутне прискорення особливо коли файли нерівномірні за розміром. Але якщо є залежні тести в файлі — для них треба явно вказати test.describe.configure({ mode: 'serial' }), тоді цей конкретний блок буде захищений від паралельного виконання навіть при глобальному fullyParallel.
🗣 How to say it out loud By default Playwright parallelises at the file level: every spec file gets its own worker and runs in parallel with the other files, but inside a file the tests go one after another. So if you have a file with 50 tests, one worker chews through all of them while the other workers either handle smaller files or sit idle. fullyParallel: true lifts that limit: now each individual test, whatever file it's in, can go to whichever worker is free — like tasks in a queue, where a worker picks up the next one as soon as it's done. The number of workers doesn't change, only the granularity of how the work is handed out. If the tests are properly isolated, which they should be under Anchor 1, fullyParallel gives you a real speed-up, especially when the files are uneven in size. But if there are dependent tests in a file you have to mark them explicitly with test.describe.configure({ mode: 'serial' }), and then that particular block is protected from parallel execution even with fullyParallel on globally.
45 Що таке test.use() на рівні describe і навіщо він потрібен якщо є глобальний use в конфізі? What is test.use() at the describe level, and why do you need it if the config already has a global use?
FixturesSenior▶

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

playwright.config.ts has a use block — that's where the global settings for every test live: viewport, locale, baseURL, storageState and so on. But what if you need to change those settings for one group of tests rather than for the whole project?

  • Один describe блок тестує мобільну версію — потрібен інший viewport
  • Один describe блок тестує залогіненого адміна — потрібен інший storageState
  • Один describe блок тестує французьку локаль — потрібен locale: 'fr-FR'
  • One describe block tests the mobile version — it needs a different viewport
  • One describe block tests a logged-in admin — it needs a different storageState
  • One describe block tests the French locale — it needs locale: 'fr-FR'

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

Without test.use() you'd have to either duplicate the settings by hand in every test, or create a separate project in the config for every variant. test.use() inside a describe block overrides the configuration locally — only for the tests in that block. The rest of the file goes on using the global settings.

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

⚠️ Important: test.use() has to be called at the top level of the describe block — not inside test(). It doesn't accumulate recursively — it replaces outright the particular field you named, and the remaining fields are inherited from the global config.

TypeScript — мобільний viewport для групи тестів
TypeScript — a mobile viewport for one group of tests
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();
});
import { test, expect, devices } from '@playwright/test';

// Globally: Desktop Chrome (from the config)

test.describe('mobile PLP', () => {
  // Locally for this describe: emulate iPhone 14
  test.use({ ...devices['iPhone 14'] });
  // devices['iPhone 14'] contains: viewport, userAgent, isMobile, hasTouch

  test('filters open as a bottom sheet', async ({ page }) => {
    await page.goto('/women/dresses');
    await page.locator('button.filters-toggle').click();
    // On mobile, filters open from the bottom instead of a sidebar
    await expect(page.locator('.bottom-sheet-filters')).toBeVisible();
  });

  test('swipe through the image gallery', async ({ page }) => {
    await page.goto('/product/trench-coat');
    // Viewport is already mobile — isMobile: true, hasTouch: true
    await expect(page.locator('.swipe-gallery')).toBeVisible();
  });
});

// This test is back to Desktop Chrome from the global config
test('desktop PLP has a filter sidebar', async ({ page }) => {
  await page.goto('/women/dresses');
  await expect(page.locator('.sidebar-filters')).toBeVisible();
});
TypeScript — різні ролі через storageState в одному файлі
TypeScript — different roles through storageState in one file
// В одному файлі тестуємо і адміна і звичайного юзера
// Без 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 both admin and regular user in one file
// Without test.use() you'd need either two separate files or a login in every test

test.describe('admin panel', () => {
  // All tests in this block get an admin session
  test.use({ storageState: '.auth/admin.json' });

  test('admin sees the "Manage products" button', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.locator('button.manage-products')).toBeVisible();
  });

  test('admin can delete a review', async ({ page }) => {
    await page.goto('/reviews');
    await expect(page.locator('button.delete-review')).toBeVisible();
  });
});

test.describe('regular user', () => {
  // This block gets a regular user session
  test.use({ storageState: '.auth/user.json' });

  test('user does NOT see the manage button', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.locator('button.manage-products')).not.toBeVisible();
  });
});
TypeScript — locale і timezone для групи тестів
TypeScript — locale and timezone for one group of tests
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('¥');
  });
});
test.describe('French locale', () => {
  test.use({
    locale: 'fr-FR',
    timezoneId: 'Europe/Paris',
  });

  test('prices are shown in EUR format', async ({ page }) => {
    await page.goto('/fr/products');
    await expect(page.locator('.price')).toContainText('€');
  });

  test('dates are formatted the French way', async ({ page }) => {
    await page.goto('/fr/orders');
    // French format: 15/01/2024, not 01/15/2024
    await expect(page.locator('.order-date')).toContainText('15/01/2024');
  });
});

test.describe('Japanese locale', () => {
  test.use({
    locale: 'ja-JP',
    timezoneId: 'Asia/Tokyo',
  });

  test('prices are shown in yen', async ({ page }) => {
    await page.goto('/jp/products');
    await expect(page.locator('.price')).toContainText('¥');
  });
});
TypeScript — що саме замінюється, а що наслідується з конфігу
TypeScript — what gets replaced and what is inherited from the config
// 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, до всіх тестів
});
// playwright.config.ts — global config
export default defineConfig({
  use: {
    baseURL: 'https://staging.myshop.com',
    locale: 'en-GB',
    viewport: { width: 1280, height: 720 },
  },
});

// tests/french.spec.ts
test.describe('French locale', () => {
  test.use({
    locale: 'fr-FR', // override ONLY this field
    // baseURL and viewport are NOT set — they stay from the config
  });

  test('prices in euros', async ({ page }) => {
    // baseURL → https://staging.myshop.com (from config, unchanged)
    // viewport → 1280x720 (from config, unchanged)
    // locale  → fr-FR (overridden via test.use)
    await page.goto('/fr/products');
    await expect(page.locator('.price')).toContainText('€');
  });
});

// Analogy: test.use() works like Object.assign
// finalConfig = Object.assign({}, globalConfig, localTestUse)
// i.e. you pass only what you want to change — the rest stays

// ❌ does NOT work — test.use() can't be called inside test()
test('some test', async ({ page }) => {
  test.use({ locale: 'fr-FR' }); // error!
  // test.use() only works at the top level of a describe, before any tests
});
💡 test.use() vs окремий project у конфізі — коли що:
test.use() всередині describe → варіація в межах одного файлу, невелика різниця в налаштуваннях
Окремий project у конфізі → системна різниця для великої групи файлів (наприклад, всі мобільні тести окремо від десктопних), або різні браузери для всього проєкту
💡 test.use() vs a separate project in the config — which one when:
test.use() inside a describe → a variation within one file, a small difference in settings
A separate project in the config → a systemic difference across a large group of files (all the mobile tests kept apart from the desktop ones, say), or different browsers for the whole project
🔧 TypeScript / JS патерни в цьому прикладі test.use({ locale: 'fr-FR', timezoneId: 'Europe/Paris' }) — виклик методу з options object всередині describe; { ...devices['iPhone 14'] } — spread operator для розгортання профілю; test.use() на верхньому рівні describe — важливо: НЕ всередині test().
🔧 TypeScript / JS patterns in this example test.use({ locale: 'fr-FR', timezoneId: 'Europe/Paris' }) — a method call with an options object inside describe; { ...devices['iPhone 14'] } — the spread operator unpacking a device profile; test.use() at the top level of describe — important: NOT inside test().
🗣 Як читати вголос test.use() всередині describe — це спосіб перевизначити конфігурацію з блоку use в конфізі локально, тільки для конкретної групи тестів, без створення окремого проєкту. Він викликається на верхньому рівні describe блоку, до всіх test() — і всі тести всередині цього блоку отримують ці налаштування, а тести поза блоком продовжують використовувати глобальні. Найпоширеніші кейси: різні viewport через devices для мобільних і десктопних тестів в одному файлі, різні storageState для тестування різних ролей — адмін, звичайний юзер, гість — в одному файлі без повторного логіну в кожному тесті, і різні locale або timezoneId для i18n сценаріїв. Важлива деталь: test.use() не накопичується — він повністю замінює конкретне поле яке ти передала, а решта полів наслідується з глобального конфігу. Тому якщо глобально є baseURL і ти в test.use() змінюєш тільки locale — baseURL залишиться глобальним.
🗣 How to say it out loud test.use() inside a describe is how you override the config's use block locally, for one group of tests only, without creating a separate project. You call it at the top level of the describe, above the tests, and every test in that block picks those settings up while the tests outside keep the global ones. The common cases: a different viewport through devices so mobile and desktop tests can live in the same file, a different storageState to cover different roles — admin, ordinary user, guest — in one file without logging in again in every test, and a different locale or timezoneId for i18n scenarios. One detail worth saying out loud: test.use() doesn't merge deeply — it replaces the particular field you passed, and everything else is inherited from the global config. So if baseURL is global and you only change locale in test.use(), baseURL stays exactly as it was.
▶ 5 11
46 Що таке BrowserContext і навіщо він потрібен? What is a BrowserContext and why do you need one?
БраузерBrowserMiddle▶

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

BrowserContext is an isolated "browser session": its own cookies, its own localStorage, its own cache. Think of it as an incognito window.

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

Why do you need it?

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

Picture two tests running in parallel, both logging into the site — but as different users. If they share one browser with no isolation, one test's cookies overwrite the other's. Test 1 signs in as an admin, test 2 signs in as a regular user, and suddenly test 1 is looking at the user's account because the cookies got mixed up.

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

BrowserContext solves that: every test gets its own isolated context with its own cookies and localStorage. Tests can't affect each other even when they run at the same time.

Ієрархія:

The hierarchy:

  • Browser — один процес браузера, живе весь прогін
  • BrowserContext — ізольована сесія, Playwright створює нову для кожного тесту
  • Page — вкладка всередині контексту
  • Browser — one browser process, alive for the whole run
  • BrowserContext — an isolated session; Playwright creates a fresh one for every test
  • Page — a tab inside a context

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

The nuance that matters: the smallest unit of isolation is the BrowserContext, not the Page. Two Pages inside one context deliberately share state — they are just two tabs belonging to the same user. Isolation only begins at the context boundary:

Browser
├── BrowserContext (Alice)
│   ├── Page
│   └── Page        ← ті самі cookies що й сусідня Page
└── BrowserContext (Bob)
    └── Page         ← окремі cookies, Alice їх не бачить
Browser
├── BrowserContext (Alice)
│   ├── Page
│   └── Page        ← same cookies as the sibling Page
└── BrowserContext (Bob)
    └── Page         ← separate cookies, Alice can't see them

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

That is also why page.close() does not log you out: it closes the tab, while the session lives in the BrowserContext, which is still there. What logs you out is context.close() — it takes every Page that belonged to it down with it.

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

One browser can hold many contexts at once, which is far cheaper than launching a separate browser per test. Even browser.newPage() — the method that looks like it makes a page straight off the browser — quietly creates a new BrowserContext under the hood and puts the Page in it. A page physically cannot exist outside a context.

TypeScript — multi-user тест
TypeScript — a multi-user test
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();
});
test('buyer and seller see same product', async ({ browser }) => {
  // Two independent contexts = two different "sessions"
  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();

  // Parallel actions
  await Promise.all([
    sellerPage.goto('/seller/product/123'),
    buyerPage.goto('/product/123'),
  ]);

  await sellerCtx.close();
  await buyerCtx.close();
});
🔧 TypeScript / JS патерни в цьому прикладі browser.newContext({ storageState, viewport, locale }) — factory method: метод що створює новий об'єкт; options object з кількома полями; await context.close() — явне закриття ресурсу (важливо для уникнення memory leak).
🔧 TypeScript / JS patterns in this example browser.newContext({ storageState, viewport, locale }) — a factory method: a method that builds and returns a new object; an options object with several fields; await context.close() — closing a resource explicitly, which is what keeps you from leaking memory.
🗣 Як читати вголос Тест деструктурує browser — це сам екземпляр браузера. Викликаємо browser.newContext двічі — кожен раз з різним storageState, тобто різними сесіями. З кожного контексту отримуємо newPage(). Потім Promise.all запускає обидва goto паралельно — це рядок що одночасно навігує обидві вкладки. В кінці обов'язково закриваємо контексти через .close(), інакше вони залишаться в пам'яті.
🗣 How to say it out loud The test destructures browser out of the fixtures — that's the browser instance itself. We call browser.newContext twice, each time with a different storageState, so each one is a different session. Out of each context we get a newPage(). Then Promise.all fires both goto calls in parallel — that one line navigates both tabs at once. And at the end we close the contexts with .close(), otherwise they just sit there in memory.
47 Як реалізувати авторизацію один раз для всіх тестів (storageState)? How do you authenticate once for the whole suite (storageState)?
БраузерBrowserMiddle▶

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

If every test starts by logging in, that's slow and brittle. The login takes two or three seconds, and the day the login form changes, every test goes red.

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

When you log in on a site, the browser stores cookies and localStorage. That's why the next time you open the site you're already signed in — the browser hands the server that data and the server says "fine, I know you".

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

storageState is a snapshot of exactly that state. Playwright writes the cookies and localStorage into a JSON file after the first login. Before the next test it loads that file into a BrowserContext, and the browser believes you're already signed in. The site can't tell the difference — it sees the same cookies it would after a real login.

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

The nuance that matters: storageState saves cookies and localStorage, but NOT sessionStorage. sessionStorage is tied to one specific tab and can't be serialised across sessions. If the site keeps its auth in sessionStorage, storageState won't help you.

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

storageState is loaded into a BrowserContext, not into the Browser. So every test still gets a fresh BrowserContext with that state pre-loaded — that's the isolation mechanism surviving a shared login.

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

So: you log in through the UI once and save the state to a file → every other test starts already signed in → nobody fills that login form again.

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

The file looks roughly like this:

{
  "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') — крок за кроком:

About path.join(__dirname, '../.auth/user.json') — step by step:

Мій проєкт/
├── tests/
│   └── auth.setup.ts   ← файл з кодом
└── .auth/
    └── user.json       ← сюди хочемо зберегти
My project/
├── tests/
│   └── auth.setup.ts   ← file with the code
└── .auth/
    └── user.json       ← where we want to save it
  • __dirname — автоматична змінна Node.js: "папка де лежить цей файл з кодом". Якщо код у tests/auth.setup.ts — то __dirname = Мій проєкт/tests. Не треба писати вручну, Node.js знає сам.
  • .. — означає "вийти з папки на рівень вгору". З Мій проєкт/tests виходимо в Мій проєкт.
  • path.join() — склеює частини шляху разом: Мій проєкт/tests + .. + .auth/user.json = Мій проєкт/.auth/user.json
  • __dirname — a Node.js built-in: "the folder this code file lives in". If the code sits in tests/auth.setup.ts, then __dirname is the project's tests folder. You never type it by hand, Node.js knows it.
  • .. — means "step one folder up". From the project's tests folder that gets you to the project root.
  • path.join() — glues the pieces of a path together: the project's tests + .. + .auth/user.json = the project's .auth/user.json

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

So what you're saying is: "start from the folder I'm in (auth.setup.ts), go one level up, step into .auth, the file is user.json". If you just wrote the plain string '../../.auth/user.json', it would depend on which directory you ran the command from. __dirname is always relative to the file itself, whatever directory you launched from.

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

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

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

  // Зберігаємо cookies + localStorage
  await page.context().storageState({ path: authFile });
});
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');

  // Save cookies + localStorage
  await page.context().storageState({ path: authFile });
});
playwright.config.ts
playwright.config.ts
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      storageState: '.auth/user.json', // ← всі тести стартують авторизованими
    },
    dependencies: ['setup'],
  },
]
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      storageState: '.auth/user.json', // ← all tests start authenticated
    },
    dependencies: ['setup'],
  },
]
🔧 TypeScript / JS патерни в цьому прикладі await context.storageState({ path: '.auth/user.json' }) — зберігаємо стан в файл; use: { storageState: '.auth/user.json' } — конфігурація через рядковий шлях; test.use({ storageState }) — виклик методу на об'єкті test з об'єктом конфігурації.
🔧 TypeScript / JS patterns in this example await context.storageState({ path: '.auth/user.json' }) — writing state out to a file; use: { storageState: '.auth/user.json' } — config through a string path; test.use({ storageState }) — calling a method on the test object with a config object.
🗣 Як читати вголос У файлі auth.setup.ts: звичайний логін через UI, але в кінці викликаємо page.context().storageState({ path: authFile }) — це серіалізує всі cookies і localStorage в JSON-файл. У конфігу додаємо окремий project 'setup' з testMatch на цей файл. Основний project chromium отримує storageState — шлях до збереженого JSON — і dependencies: ['setup'], тобто спочатку виконається setup, потім вже основні тести зі вже встановленою сесією.
🗣 How to say it out loud In auth.setup.ts it's an ordinary UI login, except that at the end we call page.context().storageState({ path: authFile }), which serialises every cookie and everything in localStorage into a JSON file. In the config we add a separate project called 'setup' whose testMatch points at that file. The main chromium project gets storageState — the path to that saved JSON — plus dependencies: ['setup'], so setup runs first and the real tests start with the session already in place.
48 Як працювати з localStorage в тестах? Що таке page.localStorage (Playwright 1.61+)? How do you work with localStorage in tests? What is page.localStorage (Playwright 1.61+)?
БраузерBrowserMiddle▶

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

localStorage is the browser store a site uses to keep data between sessions: tokens, preferences, the cart. In tests you often need to set it or check it — to seed state before a test, or to assert something actually got saved.

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

For a long time the only way in was page.evaluate(). Playwright 1.61 added a direct page.localStorage API.

TypeScript — старий спосіб через evaluate() (всі версії)
TypeScript — the old way, through evaluate() (any version)
// Встановити значення
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());
// Set a value
await page.evaluate(() => {
  localStorage.setItem('user_token', 'test-token-123');
  localStorage.setItem('locale', 'en-GB');
});

// Read a value
const token = await page.evaluate(() =>
  localStorage.getItem('user_token')
);

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

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

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

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

// Отримати всі ключі
const keys = await page.localStorage.keys();
// Set a value — without evaluate()
await page.localStorage.setItem('user_token', 'test-token-123');
await page.localStorage.setItem('locale', 'en-GB');

// Read a value
const token = await page.localStorage.getItem('user_token');

// Remove a single key
await page.localStorage.removeItem('user_token');

// Clear everything
await page.localStorage.clear();

// Get all keys
const keys = await page.localStorage.keys();
🔧 TypeScript / JS патерни в цьому прикладі page.localStorage — об'єкт з методами setItem/getItem/removeItem/clear/keys (Playwright 1.61+); page.evaluate(() => localStorage.setItem(...)) — старий спосіб через виконання коду в браузері; обидва підходи асинхронні — потребують await.
🔧 TypeScript / JS patterns in this example page.localStorage — an object with setItem/getItem/removeItem/clear/keys (Playwright 1.61+); page.evaluate(() => localStorage.setItem(...)) — the old way, running the code inside the browser; both are async, so both need await.
🗣 Як читати вголос localStorage — браузерне сховище де сайт зберігає дані між сесіями. Раніше в Playwright єдиний спосіб взаємодіяти з ним — через page.evaluate() де ми запускаємо код всередині браузера. Playwright 1.61 додав прямий API page.localStorage з тими самими методами що й нативний localStorage: setItem, getItem, removeItem, clear. Тепер не треба писати evaluate — просто await page.localStorage.setItem('key', 'value'). Обидва підходи асинхронні.
🗣 How to say it out loud localStorage is the browser store where a site keeps data between sessions. In Playwright the only way to touch it used to be page.evaluate(), where we run a bit of code inside the browser. Playwright 1.61 added a direct page.localStorage API with the same methods as the native one: setItem, getItem, removeItem, clear. So you don't write evaluate any more, it's just await page.localStorage.setItem('key', 'value'). Either way it's async, so it needs an await.
49 Як тестувати нові вкладки і popup-вікна? How do you test new tabs and popup windows?
БраузерBrowserMiddle▶

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

target="_blank" is the HTML attribute that tells the browser to open a link in a new tab instead of the current one. target is where to open it, _blank means "a new empty tab".

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

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

<!-- With target="_blank" — opens in a NEW tab -->
<a href="/products" target="_blank">Products</a>

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

When a user clicks a link with target="_blank", or JavaScript opens a window with window.open(), the browser creates a new tab. Playwright does not switch to it for you. You have to catch that new tab explicitly and get a Page object for it.

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

Worth knowing: a popup opens in the same BrowserContext, sharing its cookies and localStorage. That's what lets the new tab inherit the signed-in state of the parent page. It's also why we listen on the context, not on the browser.

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

The key pattern: subscribe to the "new tab" event first, and only then click — otherwise you can miss the event entirely.

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

There are two ways to catch a new tab — and when to use which:

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

page.on('popup') — fire and forget. You say "when a new tab shows up, do this" and carry on. The code doesn't stop. Good when you need one simple check and nothing else.

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

context.waitForEvent('page') — stop and wait. The code pauses until the new tab appears, then continues with that tab in a variable. Good when you need to keep working with it — clicking, filling a form, checking several things.

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

await page.click('.privacy-link');
// тест продовжується далі не чекаючи нової вкладки
// Subscribe and forget — code doesn't block, the new tab arrives in the callback
page.on('popup', async (popup) => {
  await expect(popup).toHaveURL(/privacy/); // checked, done
});

await page.click('.privacy-link');
// the test carries on without waiting for the new tab
TypeScript — context.waitForEvent('page'): кілька дій у новій вкладці
TypeScript — context.waitForEvent('page'): several actions in the new tab
// 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();
// Promise.all() returns an ARRAY of results:
// results = [newPage, undefined]
//            ↑           ↑
//         new tab        click returns nothing
//
// const [newPage] — array destructuring: take only the first element
// Same as: const results = await Promise.all([...]); const newPage = results[0];
const [newPage] = await Promise.all([
  context.waitForEvent('page'),  // listen BEFORE the click — we won't miss the event
  page.click('.privacy-link'),    // click
]);

// newPage is a full-fledged variable, interact with it as much as you like
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 встановлено ДО кліку — інакше подія може прийти раніше і ти її пропустиш.
💡 A simple rule:
One check → page.on('popup')
Several actions in the new tab → context.waitForEvent('page')

⚠️ Worth knowing: Promise.all() guarantees that waitForEvent is armed BEFORE the click — otherwise the event can land first and you'll miss it.
🔧 TypeScript / JS патерни в цьому прикладі const [newPage] = await Promise.all([context.waitForEvent('page'), page.click(...)]) — Promise.all: виконуємо два Promise паралельно і чекаємо обидва; деструктуризація масиву [newPage] — беремо перший елемент результату.
🔧 TypeScript / JS patterns in this example const [newPage] = await Promise.all([context.waitForEvent('page'), page.click(...)]) — Promise.all: run two promises in parallel and wait for both; array destructuring [newPage] — take the first element of the result.
🗣 Як читати вголос Оголошуємо константу через деструктуризацію масиву const [newPage]. Правa сторона — Promise.all з масивом двох промісів: перший context.waitForEvent('page') — встановлює слухача на появу нової вкладки і повертає проміс що зрезолвиться з об'єктом нової сторінки; другий — клік по лінку. Promise.all гарантує що слухач вже активний в момент кліку. Після цього newPage — це повноцінний об'єкт Page з усіма методами.
🗣 How to say it out loud We declare a constant by destructuring an array, const [newPage]. On the right is Promise.all with two promises: the first, context.waitForEvent('page'), arms a listener for a new tab and returns a promise that resolves with the new page object; the second is the click on the link. Promise.all is what guarantees the listener is already live at the moment of the click. After that newPage is a full Page object with all the usual methods on it.
50 Як працювати з iframe в Playwright? How do you work with iframes in Playwright?
БраузерBrowserJunior▶

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

An iframe is an HTML element that embeds one page inside another. The classic example is a Stripe or PayPal payment form — it shows up on your site, but it's really a separate page served by the payment provider inside an iframe.

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

The problem: by default locators only look inside the main document. Elements inside an iframe live in a document of their own, so they need their own way in.

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

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

// Вкладені iframe
const nested = page
  .frameLocator('#outer')
  .frameLocator('#inner');
await nested.getByRole('button').click();
// Via frameLocator (recommended)
const frame = page.frameLocator('iframe[name="payment"]');
await frame.getByLabel('Card number').fill('4111111111111111');

// Via frame() if you need page-level methods
const iframe = page.frame({ name: 'payment' });
const content = await iframe?.content();

// Nested iframes
const nested = page
  .frameLocator('#outer')
  .frameLocator('#inner');
await nested.getByRole('button').click();
🔧 TypeScript / JS патерни в цьому прикладі frameLocator vs page.frame — різниця: page.frameLocator('iframe') повертає FrameLocator — рекомендований сучасний API з auto-waiting і chaining, як звичайний Locator. page.frame({ name: 'payment' }) — старіший API, повертає Frame | null (потребує null-check), не має auto-waiting, але дає прямий доступ до Frame для низькорівневих операцій. У більшості тестів використовуй frameLocator().
🔧 TypeScript / JS patterns in this example frameLocator vs page.frame — the difference: page.frameLocator('iframe') returns a FrameLocator — the modern, recommended API, with auto-waiting and chaining, just like an ordinary Locator. page.frame({ name: 'payment' }) is the older API: it returns Frame | null (so it needs a null check) and has no auto-waiting, but it hands you the Frame directly for low-level work. In most tests, reach for frameLocator().
🗣 Як читати вголос page.frameLocator — шукаємо iframe на сторінці за CSS-селектором або атрибутом, повертає об'єкт FrameLocator. На ньому вже можна викликати всі знайомі методи: getByLabel, getByRole тощо — вони шукатимуть елементи всередині цього iframe. Альтернатива — page.frame({ name: '...' }), але це старіший API, він повертає Frame або null, тому використовуємо optional chaining ?.. Для вкладених iframe — чейнимо два frameLocator один за одним.
🗣 How to say it out loud page.frameLocator finds the iframe on the page by a CSS selector or an attribute and gives you back a FrameLocator. On that you can call all the familiar methods — getByLabel, getByRole and so on — and they'll look for elements inside that iframe. The alternative is page.frame({ name: '...' }), but that's the older API: it returns a Frame or null, so you need optional chaining with ?.. For nested iframes you just chain two frameLocator calls one after the other.
51 Як обробляти діалоги (alert, confirm, prompt)? How do you handle dialogs (alert, confirm, prompt)?
БраузерBrowserJunior▶

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

Now that we've been through the lifecycle hooks — beforeEach and afterEach — here's a different kind of event: the browser's native dialogs. Alert, confirm and prompt are the popup windows the browser shows from JavaScript. With no handler, Playwright dismisses them automatically and the test can hang.

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

The key point: register the handler before the action that triggers the dialog, because the dialog can appear the instant you click. So page.on('dialog', ...) goes on the first line, and only then do you click the button.

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

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

await page.getByRole('button', { name: 'Delete' }).click();
// The handler MUST be added BEFORE the action that triggers the dialog
page.on('dialog', async (dialog) => {
  console.log(dialog.message());       // dialog text
  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');   // with a response
  } else {
    await dialog.dismiss();            // Cancel
  }
});

await page.getByRole('button', { name: 'Delete' }).click();
🔧 TypeScript / JS патерни в цьому прикладі page.on('dialog', async (dialog) => {}) — підписка на подію з async callback; важливо: підписка ДО goto(), бо подія може спрацювати під час завантаження; dialog.accept() / dialog.dismiss() — методи об'єкта.
🔧 TypeScript / JS patterns in this example page.on('dialog', async (dialog) => {}) — subscribing to an event with an async callback; the important bit: subscribe before goto(), because the event can fire during load; dialog.accept() / dialog.dismiss() — methods on the object.
🗣 Як читати вголос Викликаємо page.on — підписуємось на подію 'dialog' першим рядком до будь-якого кліку. Колбек отримує об'єкт dialog: dialog.message() повертає текст, dialog.type() — тип. Перевіряємо тип через if/else if/else: для confirm викликаємо dialog.accept() — це натискання OK; для prompt — accept з рядком-відповіддю; інакше dismiss — це Cancel. Тільки після того як обробник встановлено — клікаємо кнопку що викличе діалог.
🗣 How to say it out loud We call page.on and subscribe to the 'dialog' event on the first line, before any click. The callback gets a dialog object: dialog.message() gives you the text, dialog.type() gives you the kind. We branch on the type with if/else if/else: for a confirm we call dialog.accept(), which is pressing OK; for a prompt, accept with the string we want to type in; otherwise dismiss, which is Cancel. Only once the handler is in place do we click the button that raises the dialog.
52 Як заповнити форму і завантажити файл? How do you fill in a form and upload a file?
БраузерBrowserJunior▶

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

Forms are one of the most common things you test in e-commerce: sign-up, login, checkout, search. Playwright has a dedicated method for each kind of field — text inputs, dropdowns, checkboxes, radio buttons and file inputs.

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

The rule of thumb: every field type has one right way to interact with it. For a <select>, for instance, there's selectOption() — you don't click it open and pick by hand.

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

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

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

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

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

// Drag & drop
await page.dragAndDrop('.source', '.target');
// Form
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();

// File upload
await page.getByLabel('Upload avatar').setInputFiles('avatar.jpg');

// Uploading multiple files
await page.locator('input[type="file"]').setInputFiles([
  'file1.pdf', 'file2.pdf'
]);

// Drag & drop
await page.dragAndDrop('.source', '.target');
🔧 TypeScript / JS патерни в цьому прикладі page.locator('#promo').fill('CODE') — method chaining; page.setInputFiles('[type=file]', 'path/file.pdf') — рядковий селектор + шлях як рядок; ['file1.pdf', 'file2.pdf'] — масив рядків для множинного завантаження.
🔧 TypeScript / JS patterns in this example page.locator('#promo').fill('CODE') — method chaining; page.setInputFiles('[type=file]', 'path/file.pdf') — a string selector plus a path as a string; ['file1.pdf', 'file2.pdf'] — an array of strings for a multi-file upload.
🗣 Як читати вголос getByLabel('Email').fill(...) — знаходимо поле за його лейблом і заповнюємо значенням. selectOption на селекторі — передаємо об'єкт з label або value, Playwright знайде потрібний option. getByRole('checkbox').check() — ставимо галочку. setInputFiles — передаємо шлях до файлу або масив шляхів, Playwright вставить їх у файловий input. dragAndDrop — приймає два CSS-селектори: звідки перетягнути і куди.
🗣 How to say it out loud getByLabel('Email').fill(...) finds the field by its label and types the value in. selectOption on the selector takes an object with a label or a value, and Playwright picks the matching option. getByRole('checkbox').check() ticks the box. setInputFiles takes a path or an array of paths and drops them into the file input. And dragAndDrop takes two CSS selectors: where you're dragging from and where you're dropping.
53 Що таке page.waitForEvent() і як правильно тестувати download, popup, dialog і filechooser? What is page.waitForEvent() and how do you test download, popup, dialog and filechooser properly?
БраузерBrowserMiddle▶

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

Up to now we've been waiting on the state of an element on the page — toBeVisible(), toBeEnabled(). But there's a class of events that happen outside the DOM, at the level of the browser itself. Ordinary assertions do nothing for those:

  • download — юзер клікнув "Завантажити PDF". Це не елемент на сторінці, це окремий браузерний процес.
  • popup — клік відкрив нову вкладку або вікно. Це нова Page яка ще не існувала, локатором її не знайдеш.
  • dialog — зʼявився нативний alert(), confirm() або prompt(). Це системний діалог браузера, поза DOM.
  • filechooser — клік відкрив системний діалог вибору файлу. Теж поза DOM.
  • download — the user clicked "Download PDF". That isn't an element on the page, it's a separate browser process.
  • popup — a click opened a new tab or window. That's a new Page that didn't exist a moment ago; no locator will find it.
  • dialog — a native alert(), confirm() or prompt() appeared. That's a system dialog of the browser, outside the DOM.
  • filechooser — a click opened the system file picker. Also outside the DOM.

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

All of these are asynchronous and one-shot. They happen once in response to some action, and if you miss one, it isn't coming back.

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

Правильний патерн — завжди навпаки: спочатку підписуємось на очікування події (Promise), потім виконуємо дію яка її тригерить. Playwright починає "слухати" ще до кліку і нічого не пропустить.
⚠️ The big trap — the order of operations (a race condition):
Your instinct is to write it as: click first, then wait for the event. But the click can fire so fast that the download has already started and finished before you subscribe with waitForEvent — and Playwright just misses it.

The right pattern is always the other way round: arm the wait first (create the promise), then do the thing that triggers it. Playwright is already listening before the click, so nothing gets lost.
TypeScript — download: правильний порядок підписка → дія → await
TypeScript — download: the right order, subscribe → act → await
test('завантаження PDF звіту', async ({ page }) => {
  await page.goto('/reports');

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

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

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

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

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

// ❌ НЕПРАВИЛЬНО — race condition: download може завершитись до waitForEvent
// await page.locator('button.download-report').click();
// const download = await page.waitForEvent('download'); // може пропустити!
test('downloading a PDF report', async ({ page }) => {
  await page.goto('/reports');

  // ✅ CORRECT: subscribe first — the Promise is created but not awaited yet
  const downloadPromise = page.waitForEvent('download');

  // Now trigger the event — Playwright is already "listening" and won't miss it
  await page.locator('button.download-report').click();

  // Now wait for the result — the download object is available
  const download = await downloadPromise;

  // Check the file name
  expect(download.suggestedFilename()).toBe('report-2024.pdf');

  // Save the file and check it's not empty
  const path = await download.path();
  expect(path).toBeTruthy();
});

// ❌ WRONG — race condition: the download may finish before waitForEvent
// await page.locator('button.download-report').click();
// const download = await page.waitForEvent('download'); // may miss it!
TypeScript — popup: перехоплюємо нову вкладку яку відкриває клік
TypeScript — popup: catching the new tab a click opens
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('link opens in a new tab', async ({ page, context }) => {
  await page.goto('/products');

  // Subscribe on context — a new tab is a context event, not a page event
  const popupPromise = context.waitForEvent('page');

  // Click the link with target="_blank"
  await page.locator('a[target="_blank"]').click();

  // Get the new Page — a full-fledged object you can interact with
  const newPage = await popupPromise;
  await newPage.waitForLoadState('domcontentloaded');

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

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

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

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

  // Після підтвердження — перевіряємо результат
  await expect(page.locator('.success-message')).toBeVisible();
});
test('confirming deletion via confirm()', async ({ page }) => {
  await page.goto('/settings');

  // ⚠️ IMPORTANT: if a dialog appears and there's no handler, Playwright
  // dismisses it automatically, and the test run gets blocked!
  // So ALWAYS register the handler BEFORE the action that triggers the dialog
  page.on('dialog', async (dialog) => {
    // Check the dialog text
    expect(dialog.message()).toBe('Are you sure you want to delete your account?');

    // Click "OK" (accept) or "Cancel" (dismiss)
    await dialog.accept(); // or dialog.dismiss() to cancel
  });

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

  // After confirming — check the result
  await expect(page.locator('.success-message')).toBeVisible();
});
TypeScript — filechooser: завантажуємо файл через системний діалог
TypeScript — filechooser: uploading through the system dialog
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();
});
test('uploading an avatar via the file picker', async ({ page }) => {
  await page.goto('/profile');

  // Same pattern: subscribe first, then trigger
  const fileChooserPromise = page.waitForEvent('filechooser');

  // Click the button that opens the native file picker
  await page.locator('button.upload-avatar').click();

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

  // Alternative — if input[type="file"] is directly accessible:
  // await page.locator('input[type="file"]').setInputFiles('./test-data/avatar.png')
  // ⚠️ setInputFiles() is only for input[type="file"], NOT for drag-and-drop zones

  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 результату, а не навпаки.
💡 Recap — what to use when:
waitForEvent('download') → a file starts downloading
context.waitForEvent('page') → a new tab opens (target="_blank")
page.on('dialog', handler) → alert/confirm/prompt (handled synchronously, through on)
waitForEvent('filechooser') → the system file picker opens

And always: subscribe → act → await the result, never the other way round.
🔧 TypeScript / JS патерни в цьому прикладі const [download] = await Promise.all([page.waitForEvent('download'), page.click(...)]) — деструктуризація масиву результату Promise.all; Promise.all() — виконує масив Promise паралельно; race condition патерн: спочатку підписка, потім дія.
🔧 TypeScript / JS patterns in this example const [download] = await Promise.all([page.waitForEvent('download'), page.click(...)]) — destructuring the array Promise.all returns; Promise.all() — runs an array of promises in parallel; the race-condition pattern: subscribe first, act second.
🗣 Як читати вголос 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() замість реального вибору файлу в системному діалозі.
🗣 How to say it out loud page.waitForEvent() exists for the class of events that happen outside the DOM, at the browser level — download, popup, dialog, filechooser. Normal locators and assertions are no use there, because there's no DOM element to find. The conceptual bit is the order of operations. Your instinct is to click first and then wait for the event, but that's a race: the click can fire fast enough that the event has already come and gone before Playwright started listening. So it's always the other way round — you create the promise with waitForEvent without awaiting it, that's the subscribe step, then you do the thing that triggers the event, and only then you await the promise. That way Playwright is already listening at the moment of the click and nothing slips past. For a download I'd check suggestedFilename() and path(). For a popup you get a full Page object and can work with it like any other page. Dialog is the exception: you subscribe with page.on('dialog', handler) rather than waitForEvent, because a dialog has to be handled synchronously the moment it appears or the browser sits there waiting for an answer. And for filechooser it's the same subscribe-act-await shape, then setFiles() instead of really picking a file in a system dialog.
54 Що таке page.on('console'), page.on('pageerror') і page.on('request')? Чим вони відрізняються від waitForEvent? What are page.on('console'), page.on('pageerror') and page.on('request')? How do they differ from waitForEvent?
БраузерBrowserMiddle▶

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

In the previous question we talked about waitForEvent, which waits for a single one-shot event. But there's another class of things that don't happen once — they keep happening for as long as the page is alive: every console.log in the browser, every JS error, every network request. For those there's page.on(), a subscription to a stream of events.

  • waitForEvent — чекаємо одну конкретну подію і рухаємось далі. Одноразово.
  • page.on() — вішаємо постійний слухач який спрацьовує на кожну подію цього типу поки сторінка жива.
  • waitForEvent — wait for one specific event and move on. One shot.
  • page.on() — attach a standing listener that fires on every event of that type for as long as the page is alive.
  • 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('console') — catches every console.log, console.warn and console.error from the page's own JavaScript. Handy for proving the page isn't quietly throwing errors into the console, or for catching one specific message after some action.
  • page.on('pageerror') — catches uncaught JS errors: TypeError, ReferenceError, any throw that no try/catch caught. Gold for smoke tests — you can prove a page loads without a JS crash even when it looks perfectly fine on screen.
  • page.on('request') and page.on('response') — catches every network request and response. Different from page.route() in that you're only watching the traffic, not changing it.

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

⚠️ One detail that matters: attach page.on() before page.goto(). Attach it after and you lose everything that happened during load: the first console.log calls, the errors thrown during init, the earliest network requests.

TypeScript — page.on('console'): перехоплюємо консольні повідомлення
TypeScript — page.on('console'): catching console messages
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('page logs no console errors on load', async ({ page }) => {
  const consoleErrors: string[] = [];

  // ✅ Subscribe BEFORE goto — so we don't miss load-time events
  page.on('console', (msg) => {
    // msg.type() → 'log' | 'warn' | 'error' | 'info' | 'debug'
    // msg.text() → the message text itself
    if (msg.type() === 'error') {
      consoleErrors.push(msg.text());
    }
  });

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

  // After load, check there were no errors
  expect(consoleErrors).toHaveLength(0);
});

test('catch a specific message', async ({ page }) => {
  const messages: string[] = [];

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

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

  // Check that after the click the code logged the expected message
  expect(messages.some(m => m.includes('Order submitted'))).toBe(true);
});
TypeScript — page.on('pageerror'): ловимо некеровані JS-помилки
TypeScript — page.on('pageerror'): catching uncaught JS errors
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('no JS crashes on the page', async ({ page }) => {
  const jsErrors: Error[] = [];

  // pageerror fires on any uncaught exception in the browser
  // That's a TypeError, ReferenceError, or any throw outside try/catch
  page.on('pageerror', (error) => {
    jsErrors.push(error); // error is a real Error object with message and stack
  });

  await page.goto('/dashboard');

  // Interact with the page — check that no scenario crashes
  await page.locator('nav a').first().click();
  await page.locator('.filter-btn').click();

  // If the array isn't empty — print the stack trace for debugging
  expect(jsErrors, jsErrors.map(e => e.message).join('\n')).toHaveLength(0);
});

// Smoke-test pattern: check several pages at once
const criticalPages = ['/', '/products', '/cart', '/account'];

for (const path of criticalPages) {
  test(`no JS errors on ${path}`, async ({ page }) => {
    const errors: Error[] = [];
    page.on('pageerror', e => errors.push(e));
    await page.goto(path);
    expect(errors).toHaveLength(0);
  });
}
TypeScript — page.on('request') і ('response'): спостерігаємо за мережею
TypeScript — page.on('request') and ('response'): watching the network
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);
});
test('analytics fires when adding to cart', async ({ page }) => {
  const analyticsRequests: string[] = [];

  // Observe requests — do NOT modify them (that's the difference from 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();

  // Check that the analytics request was sent
  expect(analyticsRequests.length).toBeGreaterThan(0);
});

test('API returns 200 for critical endpoints', async ({ page }) => {
  const failedRequests: {url: string, status: number}[] = [];

  // page.on('response') — fires when a response arrives
  page.on('response', (response) => {
    if (response.url().includes('/api/') && response.status() >= 400) {
      failedRequests.push({ url: response.url(), status: response.status() });
    }
  });

  await page.goto('/checkout');

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

Правило: якщо треба перевірити що запит відбувся → page.on. Якщо треба замінити відповідь → page.route.
💡 page.on('request') vs page.route() — the difference:
page.on('request') — passive observation. You see the request but can't change or block it.
page.route() — active interception. You can catch the request and return a mock response, rewrite headers, or block it outright.

The rule: need to prove a request happened → page.on. Need to replace the response → page.route.
🔧 TypeScript / JS патерни в цьому прикладі page.on('console', (msg) => {}) — підписка до goto(); msg.type() — виклик методу на об'єкті події; const errors: Error[] = [] — TypeScript масив з типом; errors.push(error) — додавання до масиву всередині closure.
🔧 TypeScript / JS patterns in this example page.on('console', (msg) => {}) — subscribing before goto(); msg.type() — calling a method on the event object; const errors: Error[] = [] — a typed TypeScript array; errors.push(error) — pushing into an array from inside a 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(), бо події що відбулись до підписки вже не повернути.
🗣 How to say it out loud page.on() is a subscription to a continuous stream of events, unlike waitForEvent, which waits for one specific event and moves on. page.on('console') fires on every console.log, warn or error from the page's own JavaScript — you get an object with type() and text(), you collect the errors into an array and at the end of the test you assert the array is empty. It's a cheap way to surface problems you'd otherwise never see: the page can look completely normal and still be throwing into the console. page.on('pageerror') is a level up from that — uncaught JS exceptions, TypeError, ReferenceError, anything try/catch didn't catch, and they arrive as real Error objects with a stack trace. That's why I pass the array of errors as the second argument to expect: it's the custom failure message, so the report tells you straight away what actually blew up. page.on('request') and page.on('response') are passive network observation — we watch, we don't interfere, which is the opposite of page.route(), where we actively swap responses out. And the one rule for all three is: subscribe before goto(), because events that fired before you subscribed are simply gone.
55 Яка різниця між page.on(), page.waitForEvent() і page.evaluate() для "слухання" подій? What's the difference between page.on(), page.waitForEvent() and page.evaluate() for "listening" to things?
БраузерBrowserMiddle▶

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

Playwright gives you three different ways to "wait" or "listen" for something. They solve different problems and they work at different levels.

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

1. page.on() — subscribes to a built-in Playwright event until page.off() is called or the page closes. The callback runs every time the event fires. The code doesn't stop.

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

2. page.waitForEvent() — waits once for a built-in Playwright event and returns a value. The code stops until the event arrives. Both of these listen to the same thing: built-in Playwright events.

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

3. page.evaluate() + a hand-rolled subscription — for when you need to listen to something inside the page's own JavaScript that Playwright knows nothing about. dataLayer.push(), custom events, method calls — Playwright can't see those over the CDP protocol, so you have to "go inside" the browser with evaluate() and hook in by hand.

page.on()
page.waitForEvent()
evaluate()
Підписка назавжди
Чекає один раз
Всередині JS сторінки
Код не зупиняється
Код зупиняється
Promise вручну
dialog, console, request...
dialog, popup, page...
dataLayer, кастомні події
page.on()
page.waitForEvent()
evaluate()
Subscribes forever
Waits once
Inside the page's JS
Code keeps running
Code stops
Promise by hand
dialog, console, request...
dialog, popup, page...
dataLayer, custom events

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

dataLayer.push() is not a Playwright event and not a browser event. It's just a method call on an array inside JavaScript. Playwright has no idea it happened — it only sees what's visible from the outside over CDP. So page.on() and page.waitForEvent() won't help you here.

TypeScript — page.on() vs page.waitForEvent(): обидва для вбудованих подій
TypeScript — page.on() vs page.waitForEvent(): both for built-in events
// 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
// page.on() — permanent subscription, callback for every event
page.on('dialog', dialog => dialog.accept());    // every dialog gets accepted
page.on('console', msg => console.log(msg.text())); // log every console.log

// page.waitForEvent() — waits once and returns the value
const dialog = await page.waitForEvent('dialog');  // wait and get the object
await dialog.accept();

// Built-in Playwright events: dialog, popup, page, request,
// response, console, pageerror, worker, websocket, download, filechooser
TypeScript — page.evaluate(): слухаємо те що Playwright не бачить
TypeScript — page.evaluate(): listening to what Playwright can't see
// 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' });
// dataLayer.push() is a method call inside the page's JS
// Playwright doesn't know about it — you have to hook in manually via evaluate()

// Replace dataLayer.push with our own function that "notifies" us via a 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]); // found it — notify
      return orig(...args); // always call the original push
    };
  })
);

// Now wait for the event to arrive
const event = await dataLayerPromise;
expect(event).toMatchObject({ event: 'add_to_cart' });
💡 Аналогія: page.on() і page.waitForEvent() — це як слухати вуличні динаміки зовні будівлі. Вони чують тільки те що виходить назовні. dataLayer.push() — це внутрішній телефон. Щоб його почути — треба зайти всередину через evaluate() і підключити власний апарат прямо до лінії.
💡 An analogy: page.on() and page.waitForEvent() are like listening to the loudspeakers outside a building. They only pick up what comes out. dataLayer.push() is the internal phone line. To hear that, you have to go inside with evaluate() and wire your own handset straight onto the line.
🔧 TypeScript / JS патерни в цьому прикладі new Promise(resolve => {}) — створення Promise вручну: resolve це функція яку викликаємо коли хочемо "зрезолвити" Promise; підміна методу: obj.method = newFn — замінюємо оригінальну функцію своєю; orig(...args) — виклик оригінальної функції з усіма аргументами через spread; args[0]?.event — optional chaining на елементі масиву.
🔧 TypeScript / JS patterns in this example new Promise(resolve => {}) — building a promise by hand: resolve is the function you call when you want to resolve it; monkey-patching a method: obj.method = newFn replaces the original with your own; orig(...args) — calling the original with every argument through a spread; args[0]?.event — optional chaining on an array element.
🗣 Як читати вголос У Playwright є три рівні "слухання". page.on() і page.waitForEvent() — обидва для вбудованих Playwright подій: dialog, popup, request, response, console і так далі. Різниця між ними: page.on() підписується назавжди і callback викликається щоразу, код не зупиняється. page.waitForEvent() чекає один раз і повертає значення, код зупиняється. dataLayer.push() — це не Playwright подія, це виклик методу на масиві всередині JavaScript сторінки. Playwright не знає про нього через CDP протокол. Тому треба третій спосіб: зайти всередину браузера через evaluate(), підмінити dataLayer.push своєю функцією яка перехоплює виклики і через Promise повідомляє тест коли прийшла потрібна подія.
🗣 How to say it out loud There are three levels of listening in Playwright. page.on() and page.waitForEvent() both deal with built-in Playwright events — dialog, popup, request, response, console and so on. The difference is that page.on() subscribes for good and the callback runs every time, without stopping your code, while page.waitForEvent() waits once, returns a value, and does stop your code. dataLayer.push() is neither of those — it's a method call on an array inside the page's JavaScript, and Playwright can't see it over CDP. So you need the third way: go into the browser with evaluate(), swap dataLayer.push for your own function that intercepts the calls and resolves a promise when the event you care about shows up.
56 Довідник основних класів Playwright — що кожен представляє і ключові методи A reference of the main Playwright classes — what each one represents and its key methods
БраузерBrowserSenior▶

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

Every Playwright class falls into one of five groups: the browser hierarchy, page interaction, network and requests, low-level input, and browser events. Most of them you never construct by hand — they arrive either as fixtures or as the return value of a method or an event.

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

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

// ── Page ─────────────────────────────────────────────────────────
// Що: одна вкладка браузера. Основний об'єкт з яким ти працюєш.
// Звідки: fixture 'page' (найчастіше), або context.newPage()
page.goto(url)                    // перейти на URL
page.locator(selector)            // Locator — рецепт пошуку елемента
page.getByRole / getByText / getByLabel // семантичні локатори
page.route(pattern, handler)      // перехопити мережевий запит
page.waitForResponse(url)         // чекати конкретну відповідь
page.waitForEvent('download')     // чекати browser-подію
page.evaluate(fn)                 // виконати код в браузері → Node.js
page.exposeFunction(name, fn)     // браузер → Node.js функція
page.addInitScript(fn)            // виконати до завантаження сторінки
page.screenshot()                 // скріншот → Buffer
page.on('console', handler)       // слухати консоль браузера
page.on('pageerror', handler)     // слухати JS помилки
page.on('worker', handler)        // слухати Service Worker
page.url() / page.title()         // поточний URL / заголовок
page.clock.install({ time })      // мокати системний час
page.clock.fastForward('30:00')   // перемотати час вперед
page.keyboard / page.mouse        // низькорівневий ввід
// ── Browser ───────────────────────────────────────────────────────
// What: a single browser process (Chromium / Firefox / WebKit)
// Expensive — one per run. Source: fixture 'browser'
browser.newContext()   // create an isolated BrowserContext
browser.contexts()     // all open contexts
browser.version()      // browser version as a string
browser.close()        // close the browser (rarely done manually)

// ── BrowserContext ────────────────────────────────────────────────
// What: an isolated "profile" — its own cookies, localStorage, permissions.
// Like a separate incognito window. Fresh for EVERY test.
// Source: fixture 'context', or browser.newContext({ ... })
context.newPage()                         // open a tab
context.cookies()                         // read cookies
context.addCookies([...])                 // set cookies
context.storageState({ path: 'auth.json' }) // save the session to a file
context.setGeolocation({ latitude, longitude }) // geolocation
context.grantPermissions(['camera'])      // grant permissions
context.setOffline(true)                  // simulate offline
context.routeFromHAR('file.har')          // replay a HAR
context.waitForEvent('page')              // wait for a new tab

// ── Page ─────────────────────────────────────────────────────────
// What: a single browser tab. The main object you work with.
// Source: fixture 'page' (most common), or context.newPage()
page.goto(url)                    // navigate to a URL
page.locator(selector)            // Locator — a recipe for finding an element
page.getByRole / getByText / getByLabel // semantic locators
page.route(pattern, handler)      // intercept a network request
page.waitForResponse(url)         // wait for a specific response
page.waitForEvent('download')     // wait for a browser event
page.evaluate(fn)                 // run code in the browser → Node.js
page.exposeFunction(name, fn)     // browser → Node.js function
page.addInitScript(fn)            // run before the page loads
page.screenshot()                 // screenshot → Buffer
page.on('console', handler)       // listen to the browser console
page.on('pageerror', handler)     // listen for JS errors
page.on('worker', handler)        // listen for Service Workers
page.url() / page.title()         // current URL / title
page.clock.install({ time })      // mock the system clock
page.clock.fastForward('30:00')   // fast-forward time
page.keyboard / page.mouse        // low-level input
Група 2 — Взаємодія зі сторінкою: Locator, Frame, Accessibility
Group 2 — page interaction: Locator, Frame, Accessibility
// ── Locator ───────────────────────────────────────────────────────
// Що: рецепт пошуку елемента. Шукає заново при кожній дії.
// Підтримує auto-waiting. Звідки: page.locator(), page.getBy*()
locator.click()                    // клік (з auto-waiting)
locator.fill('text')               // заповнити поле повністю
locator.type('text')               // друкувати посимвольно
locator.hover()                    // навести курсор
locator.selectOption('value')      // вибрати опцію у <select>
locator.check() / uncheck()        // чекбокс
locator.count()                    // кількість знайдених елементів
locator.first() / last() / nth(n)  // звузити до конкретного
locator.filter({ hasText: '...' }) // фільтрувати за вмістом
locator.locator('.child')          // знайти дочірній елемент
locator.waitFor()                  // явно чекати появи елемента
locator.isVisible() / isEnabled()  // стан (одноразово, без auto-waiting!)

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

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

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

// ── Accessibility ─────────────────────────────────────────────────
// Що: знімок дерева доступності (як screen reader бачить сторінку)
const snapshot = await page.accessibility.snapshot();
// { role: 'WebArea', name: 'Products', children: [...] }
// Але для a11y тестів краще @axe-core/playwright — автоматичний аудит
// ── Locator ───────────────────────────────────────────────────────
// What: a recipe for finding an element. Re-queries on every action.
// Supports auto-waiting. Source: page.locator(), page.getBy*()
locator.click()                    // click (with auto-waiting)
locator.fill('text')               // fill the whole field
locator.type('text')               // type character by character
locator.hover()                    // hover the cursor
locator.selectOption('value')      // pick an option in a <select>
locator.check() / uncheck()        // checkbox
locator.count()                    // number of matched elements
locator.first() / last() / nth(n)  // narrow down to a specific one
locator.filter({ hasText: '...' }) // filter by content
locator.locator('.child')          // find a child element
locator.waitFor()                  // explicitly wait for the element to appear
locator.isVisible() / isEnabled()  // state (one-shot, no auto-waiting!)

// ── Frame ─────────────────────────────────────────────────────────
// What: an <iframe> inside the page.
// Unlike a Page: it has no URL history and lives inside a page.

// Option 1: frameLocator (recommended — returns a Locator)
const frame = page.frameLocator('iframe[name="payment"]' );
await frame.locator('#card-number').fill('4111111111111111');

// Option 2: page.frame() — returns a Frame object (older API)
const paymentFrame = page.frame({ name: 'payment' });
await paymentFrame?.locator('#card-cvv').fill('123');

// ── Accessibility ─────────────────────────────────────────────────
// What: an accessibility tree snapshot (how a screen reader sees the page)
const snapshot = await page.accessibility.snapshot();
// { role: 'WebArea', name: 'Products', children: [...] }
// But for a11y tests @axe-core/playwright is better — an automated audit
Група 3 — Мережа і запити: Request, Response, APIRequestContext, Download
Group 3 — network and requests: Request, Response, APIRequestContext, Download
// ── Request ───────────────────────────────────────────────────────
// Що: один HTTP запит. Звідки: page.on('request') або page.route()
request.url()          // URL запиту
request.method()       // GET | POST | PUT | DELETE
request.headers()      // заголовки запиту
request.postData()     // тіло POST запиту як рядок
request.resourceType() // 'xhr' | 'fetch' | 'document' | 'image' | ...

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

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

// ── Download ──────────────────────────────────────────────────────
// Що: файл що скачується. Звідки: waitForEvent('download')
download.suggestedFilename()        // ім'я файлу яке пропонує браузер
await download.path()               // шлях до тимчасового файлу на диску
await download.saveAs('./my.pdf')   // зберегти у вказане місце
// ── Request ───────────────────────────────────────────────────────
// What: a single HTTP request. Source: page.on('request') or page.route()
request.url()          // request URL
request.method()       // GET | POST | PUT | DELETE
request.headers()      // request headers
request.postData()     // POST body as a string
request.resourceType() // 'xhr' | 'fetch' | 'document' | 'image' | ...

// ── Response ──────────────────────────────────────────────────────
// What: an HTTP response. Source: page.on('response') or waitForResponse()
response.url()              // response URL
response.status()           // 200, 404, 500...
response.headers()          // response headers
await response.json()        // body as JSON
await response.text()        // body as text
await response.body()        // body as a Buffer

// ── APIRequestContext ──────────────────────────────────────────────
// What: an HTTP client WITHOUT a browser. Faster than going through page.
// Source: 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 ──────────────────────────────────────────────────────
// What: a file being downloaded. Source: waitForEvent('download')
download.suggestedFilename()        // file name suggested by the browser
await download.path()               // path to the temp file on disk
await download.saveAs('./my.pdf')   // save to the given location
Група 4 — Низькорівневий ввід: Keyboard, Mouse, Touchscreen
Group 4 — low-level input: Keyboard, Mouse, Touchscreen
// ── Keyboard ──────────────────────────────────────────────────────
// Що: низькорівневий ввід з клавіатури. Доступний через page.keyboard
// Коли: звичайний locator.fill() не підходить (hot keys, shortcuts)
await page.keyboard.press('Enter')        // натиснути клавішу
await page.keyboard.press('Control+A')    // комбінація клавіш
await page.keyboard.press('Shift+Tab')    // tab назад
await page.keyboard.type('Hello')         // друкувати посимвольно
await page.keyboard.down('Shift')        // затиснути клавішу
await page.keyboard.up('Shift')          // відпустити клавішу

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

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

// ── Touchscreen ───────────────────────────────────────────────────
// Що: тач-події для мобільної емуляції. page.touchscreen
await page.touchscreen.tap(150, 300)       // тап за координатами
// ── Keyboard ──────────────────────────────────────────────────────
// What: low-level keyboard input. Available via page.keyboard
// When: a plain locator.fill() won't do (hot keys, shortcuts)
await page.keyboard.press('Enter')        // press a key
await page.keyboard.press('Control+A')    // key combination
await page.keyboard.press('Shift+Tab')    // tab backwards
await page.keyboard.type('Hello')         // type char by char
await page.keyboard.down('Shift')        // hold a key down
await page.keyboard.up('Shift')          // release a key

// ── Mouse ─────────────────────────────────────────────────────────
// What: low-level mouse actions. Available via page.mouse
// When: drag-and-drop, canvas, exact coordinates
await page.mouse.move(100, 200)          // move the cursor (x, y)
await page.mouse.click(100, 200)         // click at coordinates
await page.mouse.down()                   // press the mouse button
await page.mouse.up()                     // release the mouse button
await page.mouse.wheel(0, 500)           // scroll (deltaX, deltaY)

// Drag-and-drop via Mouse:
await page.mouse.move(100, 100);  // hover over the element
await page.mouse.down();            // press
await page.mouse.move(300, 300);  // drag
await page.mouse.up();              // release

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

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

// ── Worker ────────────────────────────────────────────────────────
// Що: Web Worker або Service Worker. Звідки: page.on('worker')
worker.url()          // URL скрипта воркера
worker.evaluate(fn)  // виконати код всередині воркера
page.workers()       // всі активні воркери на сторінці зараз
// ── Dialog ────────────────────────────────────────────────────────
// What: native alert/confirm/prompt. Source: page.on('dialog')
dialog.type()     // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
dialog.message() // dialog text
await dialog.accept('input') // OK (for prompt you can pass text)
await dialog.dismiss()      // Cancel

// ── ConsoleMessage ────────────────────────────────────────────────
// What: a single browser console message. Source: page.on('console')
msg.type()  // 'log' | 'warn' | 'error' | 'info' | 'debug'
msg.text()  // message text
msg.args()  // JSHandle[] — the arguments, e.g. console.log(obj1, obj2)

// ── Worker ────────────────────────────────────────────────────────
// What: a Web Worker or Service Worker. Source: page.on('worker')
worker.url()          // URL of the worker script
worker.evaluate(fn)  // run code inside the worker
page.workers()       // all workers currently active on the page
💡 Звідки що береться — швидка підказка:
З 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)
💡 Where each one comes from — the short version:
From fixtures, automatically: page, context, browser, request
From page: Locator (locator/getBy*), Frame (frameLocator), Keyboard (page.keyboard), Mouse (page.mouse), Clock (page.clock)
From events: Request (on/route), Response (on/waitForResponse), Dialog (on), ConsoleMessage (on), Download (waitForEvent), Worker (on)
🔧 TypeScript / JS патерни в цьому прикладі Картка-довідник. Ключові TypeScript концепти: класи (Browser, Page) — шаблони для об'єктів; методи — функції на класі; async method(): Promise — метод що повертає Promise; string[] — масив рядків; boolean — булевий тип.
🔧 TypeScript / JS patterns in this example A reference card. The TypeScript concepts behind it: classes (Browser, Page) — templates for objects; methods — functions on a class; an async method returning a promise of some type; an array of strings; a boolean.
🗣 Як читати вголос Класи Playwright можна розділити на п'ять груп. Перша — ієрархія браузера: Browser це сам процес, дорогий і один на прогін; BrowserContext це ізольований профіль зі своїми cookies і дозволами, новий для кожного тесту; Page це вкладка, основний об'єкт з яким ти працюєш. Друга — взаємодія: Locator це рецепт пошуку з auto-waiting, а не посилання на DOM-вузол; Frame це iframe всередині сторінки, доступний через frameLocator(). Третя — мережа: Request і Response зʼявляються через page.on() і page.route(); APIRequestContext це HTTP клієнт без браузера, доступний через fixture request; Download зʼявляється через waitForEvent('download'). Четверта — низькорівневий ввід: Keyboard і Mouse через page.keyboard і page.mouse — коли звичайний click() і fill() недостатньо, наприклад для drag-and-drop або keyboard shortcuts. П'ята — браузерні події: Dialog, ConsoleMessage і Worker — всі зʼявляються як аргументи в page.on(). Головне що треба памʼятати: більшість класів не створюються через new вручну — вони приходять або як fixtures, або як результати методів і подій.
🗣 How to say it out loud Playwright's classes split into five groups. First, the browser hierarchy: Browser is the process itself, expensive, one per run; BrowserContext is an isolated profile with its own cookies and permissions, a fresh one per test; Page is a tab, and it's the object you actually work with. Second, interaction: Locator is a recipe for finding an element, with auto-waiting — it's not a reference to a DOM node; Frame is an iframe inside the page, which you reach through frameLocator(). Third, network: Request and Response come to you through page.on() and page.route(); APIRequestContext is an HTTP client with no browser behind it, available as the request fixture; Download arrives through waitForEvent('download'). Fourth, low-level input: Keyboard and Mouse, through page.keyboard and page.mouse, for when plain click() and fill() aren't enough — drag-and-drop, keyboard shortcuts. Fifth, browser events: Dialog, ConsoleMessage and Worker, all of which show up as the argument to a page.on() handler. The thing worth remembering is that you almost never call new on any of these — they come to you as fixtures, or as the result of a method or an event.
▶ 6 9
57 Чим відрізняються page.evaluate(), page.exposeFunction() і page.addInitScript()? Це три різні "мости" між Node.js і браузером What's the difference between page.evaluate(), page.exposeFunction() and page.addInitScript()? Three different bridges between Node.js and the browser
EvaluateSenior▶

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

When Playwright runs a test there are two completely separate execution environments, and they can't see each other directly.

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

The Node.js process — that's where your test lives. page, expect, test, all your variables, all the TypeScript. The server side of the automation.

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

The browser — that's where the page's JavaScript lives. React, Vue, window, document, localStorage, dataLayer. The client side.

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

There's a network connection between them over a WebSocket — that's how Playwright drives the browser. But variables aren't shared: if you declare let myVar = 123 in the test, the browser knows nothing about it. And the other way round — if the page has window.cartItems, your test can't read it directly.

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

Playwright gives you three tools for getting code or data across that bridge, each with its own direction and timing:

  • page.evaluate() — ти відправляєш функцію в браузер, вона виконується там, і результат повертається назад в Node.js. Напрямок: Node.js → Браузер → Node.js. Одноразово, в конкретний момент.
  • page.exposeFunction() — ти реєструєш функцію з Node.js так, щоб браузер міг її викликати сам у будь-який момент. Напрямок: Браузер → Node.js. Браузер ініціює виклик, Node.js отримує дані.
  • page.addInitScript() — ти відправляєш код який виконується в браузері ще до того як завантажиться будь-який інший скрипт сторінки. Напрямок: Node.js → Браузер, але дуже рано — до <script> тегів самої сторінки.
  • page.evaluate() — you send a function into the browser, it runs there, and the result comes back to Node.js. Direction: Node.js → browser → Node.js. One shot, at a specific moment.
  • page.exposeFunction() — you register a Node.js function so the browser can call it whenever it likes. Direction: browser → Node.js. The browser initiates, Node.js receives.
  • page.addInitScript() — you send code that runs in the browser before any of the page's own scripts load. Direction: Node.js → browser, but very early — before the page's own <script> tags.

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

An analogy: Node.js is you at your desk, and the browser is a TV in the next room. evaluate() is walking in, doing something, walking out. exposeFunction() is giving the TV a button that rings your phone when it's pressed. addInitScript() is going in and reconfiguring the TV before anyone switches it on.

TypeScript — page.evaluate(): Node.js відправляє функцію в браузер, отримує результат назад
TypeScript — page.evaluate(): Node.js sends a function into the browser and gets the result back
// Напрямок: 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);
// Direction: Node.js → Browser → Node.js
// When: once, right now, at a specific point in the test
// Why: read something from the browser that a locator can't reach

// Example 1: read a value from the window object
const cartCount = await page.evaluate(() => {
  // This code runs IN THE BROWSER — window, document, localStorage are available
  return window.cartItems.length; // return the value back to Node.js
});
expect(cartCount).toBe(3); // back in Node.js — a regular assert

// Example 2: pass arguments FROM Node.js TO the browser via the second parameter
const productId = 'SKU-123'; // a Node.js variable
const price = await page.evaluate((id) => {
  // id came from Node.js — now it's available here, in the browser
  return window.productCatalog[id].price;
}, productId); // ← pass productId as an argument

// ⚠️ BIG GOTCHA: closures don't work with evaluate!
const myVar = 'hello';
await page.evaluate(() => {
  // ❌ myVar is undefined here! The browser can't see Node.js variables via closure
  // It's a separate process — closures don't cross the Node.js ↔ Browser boundary
  console.log(myVar); // undefined
});
// ✅ Correct — pass it via the second argument:
await page.evaluate((v) => console.log(v), myVar);
TypeScript — page.exposeFunction(): браузер може викликати твою Node.js функцію
TypeScript — page.exposeFunction(): the browser can call your Node.js function
// Напрямок: Браузер → 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');
// Direction: Browser → Node.js (the browser initiates!
// When: any time while the page is alive — the browser decides when to call it
// Why: capture browser events in real time (GTM dataLayer, analytics)

const capturedEvents: any[] = [];

// Register the function: after this the browser gets window.captureEvent()
// which any JS on the page can call
await page.exposeFunction('captureEvent', (event: any) => {
  // This callback runs IN NODE.JS when the browser calls window.captureEvent()
  capturedEvents.push(event);
});

// Now patch dataLayer.push so it also calls our callback
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]); // → goes to Node.js!
    return original(...args);
  };
});

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

// Check the collected events — they're already in the Node.js capturedEvents array
const addToCartEvent = capturedEvents.find(e => e.event === 'add_to_cart');
expect(addToCartEvent?.items[0].item_id).toBe('SKU-123');
TypeScript — page.addInitScript(): код що виконується ДО будь-якого скрипта сторінки
TypeScript — page.addInitScript(): code that runs BEFORE any of the page's scripts
// Напрямок: 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 в браузер
// Direction: Node.js → Browser, but VERY EARLY
// When: before any of the page's <script> tags load
// Why: replace/mock something before the page uses it

// Example 1: mock Math.random so tests are deterministic
await page.addInitScript(() => {
  // The page hasn't loaded yet, but our code is already in the browser
  // Replace Math.random BEFORE React/Vue uses it
  Math.random = () => 0.5; // always returns 0.5 — no randomness
});
await page.goto('/product'); // the page loads with our Math.random already in place

// Example 2: set a feature flag before load
await page.addInitScript(() => {
  window.featureFlags = { newCheckout: true, darkMode: false };
});
await page.goto('/checkout'); // the page reads featureFlags and sees our values

// Example 3: you can pass arguments just like with evaluate
const mockUserId = 'test-user-42';
await page.addInitScript(({ userId }) => {
  window.currentUser = { id: userId, role: 'admin' };
}, { userId: mockUserId }); // ← pass from Node.js to the browser
💡 Коротке резюме трьох мостів:
evaluate() — ти заходиш в браузер, щось робиш або читаєш, виходиш. Одноразово. Node.js чекає результату.
exposeFunction() — ти даєш браузеру кнопку яка телефонує тобі. Браузер сам вирішує коли натиснути. Node.js слухає.
addInitScript() — ти приходиш і переналаштовуєш браузер ще до того як сторінка увімкнулась. Одноразово, але дуже рано.
💡 The three bridges in one line each:
evaluate() — you walk into the browser, do or read something, walk out. One shot. Node.js waits for the result.
exposeFunction() — you give the browser a button that phones you. The browser decides when to press it. Node.js listens.
addInitScript() — you walk in and reconfigure the browser before the page is even switched on. One shot, but very early.
🔧 TypeScript / JS патерни в цьому прикладі await page.evaluate((arg) => { return window.fn(arg) }, nodeValue) — функція з аргументом що серіалізується через межу процесів; await page.exposeFunction('name', async (arg) => {}) — exposeFunction приймає рядок (ім'я) і async функцію; closure не працює через межу процесів.
🔧 TypeScript / JS patterns in this example await page.evaluate((arg) => { return window.fn(arg) }, nodeValue) — a function with an argument that gets serialised across a process boundary; await page.exposeFunction('name', async (arg) => {}) — exposeFunction takes a string (the name) and an async function; closures do not survive the process boundary.
🗣 Як читати вголос Playwright працює в двох абсолютно ізольованих середовищах: Node.js процес де живе тест, і браузер де живе JavaScript сторінки. Між ними є WebSocket але змінні через нього не шаряться — якщо в тесті є змінна myVar, браузер про неї нічого не знає, і навпаки. page.evaluate() — це спосіб відправити функцію в браузер і отримати результат назад. Важлива пастка: closure не працює через цей міст — якщо треба передати змінну з Node.js в браузер, вона має йти через другий аргумент evaluate, а не через замикання. page.exposeFunction() працює в протилежному напрямку: ми реєструємо функцію з Node.js, і після цього в браузері з'являється window.назва() яку може викликати будь-який клієнтський код — саме на цьому побудоване перехоплення GTM dataLayer, де ми підміняємо dataLayer.push щоб він також дзвонив нашому колбеку в Node.js. page.addInitScript() — це найраніший момент: код виконується в браузері до того як завантажиться будь-який скрипт самої сторінки, тому він ідеальний для мокання глобальних об'єктів типу Math.random, feature flags чи window.dataLayer — сторінка завантажується вже з нашими підміненими значеннями і ніколи не бачить "оригінальних".
🗣 How to say it out loud Playwright runs in two completely isolated environments: the Node.js process where the test lives, and the browser where the page's JavaScript lives. There's a WebSocket between them, but variables don't cross it — if the test has a variable called myVar, the browser has never heard of it, and vice versa. page.evaluate() is how you send a function into the browser and get a result back. The trap here is that closures don't survive the trip: if you need a value from Node.js inside the browser, it has to go through evaluate's second argument, not through the closure. page.exposeFunction() works the other way: you register a function from Node.js, and after that the browser has a window.something() that any client-side code can call — that's how you intercept a GTM dataLayer, by swapping out dataLayer.push so that it also rings your callback in Node.js. And page.addInitScript() is the earliest hook of all: the code runs in the browser before any of the page's own scripts, which makes it the right place to mock globals like Math.random, feature flags or window.dataLayer — the page loads with your values already in place and never sees the originals.
58 Що таке page.addStyleTag() і page.addScriptTag()? Коли їх використовувати замість addInitScript? What are page.addStyleTag() and page.addScriptTag()? When do you use them instead of addInitScript?
EvaluateMiddle▶

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

page.addStyleTag() injects a <style> tag with your CSS, or links an external stylesheet. The browser applies it to the current page immediately.

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

page.addScriptTag() injects a <script> tag with your JS, or links an external file. The script runs in the browser after the page has loaded.

  • addInitScript() — виконується ДО завантаження сторінки. Ідеально для підміни глобальних об'єктів (Math.random, dataLayer)
  • addScriptTag() — виконується ПІСЛЯ завантаження. Для динамічного додавання скриптів під час тесту
  • addStyleTag() — додає CSS в будь-який момент. Браузер одразу перераховує стилі
  • addInitScript() — runs BEFORE the page loads. Perfect for swapping out globals (Math.random, dataLayer)
  • addScriptTag() — runs AFTER the load. For adding scripts on the fly during a test
  • addStyleTag() — injects CSS at any moment. The browser restyles straight away

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

The most common real use: addStyleTag for stabilising screenshots — hide the cursor, kill CSS animations and transitions, hide anything that blinks or changes on its own.

TypeScript — addStyleTag: стабілізуємо сторінку для скріншотів
TypeScript — addStyleTag: freezing the page for screenshots
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' });
test('PDP visual regression', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // Disable all animations and transitions — the screenshot is always in the final state
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
        transition-delay: 0s !important;
      }
      /* Hide the caret — it can end up in the screenshot */
      * { cursor: none !important; }
      /* Hide elements that blink or update */
      .live-chat-widget,
      .cookie-banner,
      .countdown-timer { display: none !important; }
    `
  });

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

// Or attach an external CSS file
await page.addStyleTag({ url: 'https://example.com/test-overrides.css' });
// Or a file from disk
await page.addStyleTag({ path: './test-helpers/disable-animations.css' });
TypeScript — addStyleTag в auto fixture: вимикаємо анімації для всіх тестів
TypeScript — addStyleTag in an auto fixture: killing animations for every test
// Замість повторювати 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 }],
});
// Instead of repeating addStyleTag in every test, move it into an auto fixture
const test = base.extend({
  disableAnimations: [async ({ page }, use) => {
    // Add the CSS after every goto() — listen for navigation
    page.on('load', async () => {
      await page.addStyleTag({
        content: `* { animation: none !important; transition: none !important; }`
      });
    });
    await use(undefined);
  }, { auto: true }],
});
TypeScript — addScriptTag: коли реально потрібен — стан недоступний через DOM
TypeScript — addScriptTag: when you genuinely need it — state the DOM doesn't expose
// ⚠️ Важливо: для більшості завдань 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();
});
// ⚠️ Note: most tasks don't need addScriptTag
// Check element count → locator.count()
// Read text → locator.textContent()
// Click a button → locator.click()
// addScriptTag — only when you need something a locator can't reach

// Real case 1: a React component's internal state isn't in the DOM
test('React form state contains valid data', async ({ page }) => {
  await page.goto('/checkout');
  await page.locator('#email').fill('test@test.com');

  // Add a helper that reads React internal state via __reactFiber
  // Locators can't reach this — it's an in-memory JS object, not a DOM attribute
  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);
});

// Real case 2: simulating an event Playwright doesn't support natively
test('drag-and-drop via a custom script', async ({ page }) => {
  await page.goto('/kanban');

  // Some drag-and-drop libraries ignore the standard mouse.down/move/up
  // So inject a script that simulates the event via the dataTransfer API
  await page.addScriptTag({
    content: `
      window.simulateDrop = function(sourceSelector, targetSelector) {
        const src = document.querySelector(sourceSelector);
        const tgt = document.querySelector(targetSelector);
        const dt = new DataTransfer();
        src.dispatchEvent(new DragEvent('dragstart', { dataTransfer: dt, bubbles: true }));
        tgt.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true }));
      }
    `
  });

  await page.evaluate(() =>
    (window as any).simulateDrop('#card-1', '#column-done')
  );
  await expect(page.locator('#column-done #card-1')).toBeVisible();
});
TypeScript — різниця між addScriptTag і addInitScript на прикладі
TypeScript — addScriptTag vs addInitScript, side by side
// ── 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
  // Наша підміна вплине тільки на майбутні виклики, не на вже виконані
});
// ── addInitScript — runs BEFORE page load ────────────────────────
await page.addInitScript(() => {
  // Runs BEFORE any page script
  // The override is guaranteed to work — the page hasn't read Math.random yet
  Math.random = () => 0.5;
});
await page.goto('/product'); // ← the page loads with our Math.random already in place

// ── addScriptTag — runs AFTER page load ──────────────────────────
await page.goto('/product'); // ← the page has already loaded
await page.addScriptTag({
  content: `Math.random = () => 0.5;`
  // Override after load — the page already ran its code with the real Math.random
  // Our override only affects future calls, not ones already made
});
💡 Коли що використовувати:
Вимкнути анімації для стабільних скріншотів → addStyleTag({ content: '* { animation: none }' })
Підмінити глобальний об'єкт до завантаження → addInitScript()
Додати бібліотеку або helper після завантаження → addScriptTag()
Приховати нестабільні елементи (чат, банер) → addStyleTag({ content: '.chat { display: none }' })
💡 What to reach for:
Kill animations for stable screenshots → addStyleTag({ content: '* { animation: none }' })
Replace a global before the page loads → addInitScript()
Add a library or a helper after the load → addScriptTag()
Hide the flaky bits (chat widget, banner) → addStyleTag({ content: '.chat { display: none }' })
🔧 TypeScript / JS патерни в цьому прикладі await page.addStyleTag({ content: `...` }) — template literal (backtick) для багаторядкового рядка CSS; await page.addScriptTag({ content: `window.fn = function() {}` }) — ін'єкція JS через template literal; !important — CSS специфічність, не JS.
🔧 TypeScript / JS patterns in this example await page.addStyleTag({ content: ... }) — a template literal (backticks) for a multi-line CSS string; await page.addScriptTag({ content: ... }) — injecting JS through a template literal; !important — CSS specificity, not 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 щоб вимикання анімацій відбувалось автоматично для всіх тестів без повторення в кожному.
🗣 How to say it out loud page.addStyleTag() and page.addScriptTag() let you inject CSS or JavaScript into the page mid-test. For addStyleTag the everyday use is stabilising visual regression screenshots: we switch off every CSS animation and transition so the shot is always taken in the final state and never mid-transition, we hide the cursor so it doesn't end up in the image, and we drop the unstable bits like a live chat widget or a countdown timer that look different on every run. addScriptTag is for pulling in a library or a helper function after the page has loaded. The difference from addInitScript matters: addInitScript runs before the page loads, so swapping out a global like Math.random is guaranteed to land before the page ever uses it. addScriptTag runs after the load, so your replacement only affects future calls, not the code that already ran during startup. In practice it's worth pairing addStyleTag with an auto fixture so animations get switched off for every test without repeating yourself.
59 Що таке page.emulateMedia() і як тестувати темний режим та режим друку? What is page.emulateMedia() and how do you test dark mode and print styles?
EvaluateMiddle▶

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

CSS has media queries that change how a page looks depending on the context:

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

/* Спрацьовує коли юзер натиснув "Друкувати" */
@media print {
  nav { display: none; } /* навігація не потрібна на папері */
}
/* Applies when the OS is in dark mode */
@media (prefers-color-scheme: dark) {
  body { background: black; color: white; }
}

/* Applies when the user clicks "Print" */
@media print {
  nav { display: none; } /* no nav needed on paper */
}

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

The problem: how does a test check dark mode when the OS on the CI box is always light? How do you check the print stylesheet when nothing is actually being printed?

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

page.emulateMedia() is how you tell the browser "pretend it's dark mode" or "pretend the user just hit Print". The browser believes you and applies the matching CSS, exactly as it would in real life.

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

// Кажемо браузеру: вдавай що юзер натиснув "Друкувати"
await page.emulateMedia({ media: 'print' });
// Тепер @media print спрацьовує
// nav зникає — і ми можемо це перевірити
// Tell the browser: pretend the OS is in dark mode
await page.emulateMedia({ colorScheme: 'dark' });
// Now @media (prefers-color-scheme: dark) applies
// body turns black, text white — and we can assert on it

// Tell the browser: pretend the user clicked "Print"
await page.emulateMedia({ media: 'print' });
// Now @media print applies
// nav disappears — and we can assert on it
  • colorScheme — 'dark', 'light', 'no-preference'
  • media — 'screen' (звичайний) або 'print' (режим друку)
  • reducedMotion — 'reduce' (вимкнути анімації) або 'no-preference'
  • colorScheme — 'dark', 'light', 'no-preference'
  • media — 'screen' (the normal one) or 'print'
  • reducedMotion — 'reduce' (animations off) or 'no-preference'

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

Worth knowing: you can call emulateMedia() at any point — before or after goto(). The browser applies the new conditions immediately and the CSS is recalculated.

TypeScript — тестуємо темний режим
TypeScript — testing dark mode
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('dark mode — dark background, light text', async ({ page }) => {
  // Enable dark mode before load — the CSS applies immediately
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/product/trench-coat');

  // Check that the dark theme is applied
  const bgColor = await page.evaluate(() =>
    getComputedStyle(document.body).backgroundColor
  );
  expect(bgColor).toBe('rgb(18, 18, 18)'); // dark background

  // Or a visual check via snapshot
  await expect(page).toHaveScreenshot('dark-mode.png');
});

test('switching between dark and light mode', async ({ page }) => {
  await page.goto('/settings');

  // Start with light
  await page.emulateMedia({ colorScheme: 'light' });
  await expect(page.locator('.theme-icon')).toHaveClass(/sun/);

  // Switch to dark — emulateMedia can be called at any time
  await page.emulateMedia({ colorScheme: 'dark' });
  await expect(page.locator('.theme-icon')).toHaveClass(/moon/);
});
TypeScript — тестуємо режим друку
TypeScript — testing print styles
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');
});
test('order page renders correctly when printed', async ({ page }) => {
  await page.goto('/order/ORD-12345');

  // Enable print mode — triggers @media print { } in CSS
  // Nav, banners and extra blocks are usually hidden in print
  await page.emulateMedia({ media: 'print' });

  // Check that nav is hidden in print
  await expect(page.locator('nav.main-navigation')).not.toBeVisible();

  // Check that order details are visible
  await expect(page.locator('.order-details')).toBeVisible();
  await expect(page.locator('.order-total')).toBeVisible();

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

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

// Або встановити для всіх тестів через context при створенні
test('через context — темна тема глобально для всіх сторінок', async ({ browser }) => {
  const context = await browser.newContext({
    colorScheme: 'dark',        // темна тема
    reducedMotion: 'reduce',    // без анімацій
  });
  const page = await context.newPage();
  await page.goto('/products');
  // Всі сторінки в цьому context матимуть темну тему
  await context.close();
});
// reducedMotion: 'reduce' — for tests where animations hurt stability
// or to verify animations are disabled for accessibility
test('animations disabled with prefers-reduced-motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/product');

  // Check that CSS animation-duration is 0ms with reduced motion
  const duration = await page.evaluate(() =>
    getComputedStyle(document.querySelector('.hero-animation')!).animationDuration
  );
  expect(duration).toBe('0s');
});

// Or set it for all tests via the context at creation
test('via context — dark theme globally for all pages', async ({ browser }) => {
  const context = await browser.newContext({
    colorScheme: 'dark',        // dark theme
    reducedMotion: 'reduce',    // no animations
  });
  const page = await context.newPage();
  await page.goto('/products');
  // All pages in this context get the dark theme
  await context.close();
});
💡 Коли використовувати emulateMedia:
Тестуєш темну тему → emulateMedia({ colorScheme: 'dark' })
Тестуєш print-версію сторінки → emulateMedia({ media: 'print' })
Хочеш стабільніші тести без анімацій → emulateMedia({ reducedMotion: 'reduce' })
Глобально для всього проєкту → use: { colorScheme: 'dark' } в playwright.config.ts
💡 When to reach for emulateMedia:
Testing a dark theme → emulateMedia({ colorScheme: 'dark' })
Testing the print version of a page → emulateMedia({ media: 'print' })
Want steadier tests with no animations → emulateMedia({ reducedMotion: 'reduce' })
Project-wide → use: { colorScheme: 'dark' } in playwright.config.ts
🔧 TypeScript / JS патерни в цьому прикладі await page.emulateMedia({ colorScheme: 'dark' }) — options object з рядковим enum; await page.evaluate(() => getComputedStyle(el).opacity) — стрілочна функція що виконується в браузері і повертає значення; getComputedStyle() — браузерний API.
🔧 TypeScript / JS patterns in this example await page.emulateMedia({ colorScheme: 'dark' }) — an options object with a string enum; await page.evaluate(() => getComputedStyle(el).opacity) — an arrow function that runs in the browser and returns a value; getComputedStyle() — a browser 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 для всього проєкту.
🗣 How to say it out loud page.emulateMedia() lets you set the browser's media conditions explicitly, the ones that in real life come from the OS settings — dark or light through colorScheme, print mode through media: 'print', and reduced motion through reducedMotion. That matters for testing because on a CI box you can't change the system settings, and the page can behave completely differently: different colours in the dark theme, navigation and banners hidden by @media print rules in the print view. You can call it at any point, before or after goto() — the browser applies the new conditions right away and recalculates the CSS. You can also set these when you create the context, with browser.newContext({ colorScheme: 'dark' }), so every page in that context inherits them, or globally through use in playwright.config.ts for the whole project.
60 Як тестувати геолокацію, дозволи браузера і timezone-залежну логіку? How do you test geolocation, browser permissions and timezone-dependent logic?
EvaluateMiddle▶

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

Imagine testing a site that says "Delivery is available in your city" and works that out from the browser's GPS. Or a page that asks "Allow notifications?" where the behaviour depends on whether the user clicked Allow. Or an e-commerce site with different prices per timezone — a "until midnight" promo, but whose midnight?

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

In a real browser none of that is deterministic. Geolocation comes from GPS or the IP, and on a CI box that's Amsterdam or some AWS datacentre, not London where your test user supposedly lives. Permissions like notifications, camera and microphone raise a system popup you simply cannot automate — the test hangs waiting for a click that will never come. Timezone comes from the OS of whatever machine is running the browser, so the result depends on where CI physically sits.

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

BrowserContext lets you set all of it explicitly before the page loads. This is Anchor 3, explicitness: instead of depending on where the server happens to be, you state outright that "this test runs as if the user were in Paris, in Europe/Paris, with geolocation already granted".

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

⚠️ geolocation on its own isn't enough. Without permissions: ['geolocation'] the browser raises its "Allow location access?" popup and the test hangs. The two go together. And if the test depends on the region, you also want locale (price and date formats, language) and timezoneId (the "until midnight" promos, the schedules):

await browser.newContext({
  geolocation: { latitude: 51.5074, longitude: -0.1278 },
  permissions: ['geolocation'], // ← без цього — системний попап і зависання
  locale: 'en-GB',             // формат цін £1,990.00, дат, мова
  timezoneId: 'Europe/London', // щоб "до опівночі" було детерміновано
});
await browser.newContext({
  geolocation: { latitude: 51.5074, longitude: -0.1278 },
  permissions: ['geolocation'], // ← without it — system popup and a hang
  locale: 'en-GB',             // price format £1,990.00, dates, language
  timezoneId: 'Europe/London', // so "until midnight" is deterministic
});

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

Why on the context, not on the page or the browser: geolocation and permissions both belong to the BrowserContext — it's the "browser profile". One window has one location and one set of permissions for all of its tabs. Need to test two cities at once? Then you need two contexts. That falls straight out of the Browser → Context → Page hierarchy (Anchor 1).

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

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();
});
// Scenario: the site shows "Delivery available in London" if the user is in London

test('shows delivery for London', async ({ browser }) => {
  // Create a context with explicit London coordinates
  // Note: geolocation is set on the CONTEXT, not the page
  const context = await browser.newContext({
    geolocation: { latitude: 51.5074, longitude: -0.1278 }, // London
    permissions: ['geolocation'], // grant permission upfront — no system popup
  });

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

  // The page requested geolocation → got London → shows matching content
  await expect(page.locator('.delivery-info'))
    .toContainText('Delivery available in London');

  await context.close(); // we created the context manually — so we close it
});

// Compare two cities in one test — needs two separate contexts
test('different content for different cities', 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 }, // Paris
    permissions: ['geolocation'],
  });

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

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

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

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

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

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

// Можна також видати дозвіл після створення context — через context.grantPermissions()
test('доступ до камери', async ({ context }) => {
  // context тут — стандартна fixture від Playwright
  await context.grantPermissions(['camera', 'microphone']);
  // Далі будь-яка page з цього context матиме ці дозволи
});
// Without grantPermissions the browser shows a system popup — the test hangs
// With grantPermissions — permission is already granted, no popup at all

test('push notification subscription', async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ['notifications'], // user already "clicked Allow"
  });
  const page = await context.newPage();
  await page.goto('/notifications-settings');

  // The "Subscribe" button is available right away — no permission popup
  await page.locator('button.subscribe').click();
  await expect(page.locator('.status')).toHaveText('Subscribed');
  await context.close();
});

// You can also grant permission after creating the context — via context.grantPermissions()
test('camera access', async ({ context }) => {
  // context here is Playwright's built-in fixture
  await context.grantPermissions(['camera', 'microphone']);
  // From now on any page in this context has these permissions
});
TypeScript — timezone: тестуємо "до опівночі" в різних часових поясах
TypeScript — timezone: testing "until midnight" across time zones
// Сценарій: акція "знижка 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 в Амстердамі і локальна машина дають однаковий результат
// Scenario: a "20% off until midnight" sale — but whose midnight?
// For a Japanese user midnight comes earlier than for a British one

test('sale active for UK but over for Japan', async ({ browser }) => {
  // UK user — it's 23:00, the sale is still on
  const ukCtx = await browser.newContext({
    timezoneId: 'Europe/London', // IANA timezone name
  });

  // Japanese user — same UTC milliseconds, but it's already 08:00 tomorrow
  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();    // sale still on
  await expect(jpPage.locator('.sale-badge')).not.toBeVisible(); // sale is over

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

// Or via playwright.config.ts — set the timezone for the whole project:
// use: { timezoneId: 'Europe/London', locale: 'en-GB' }
// This guarantees CI in Amsterdam and your local machine give the same result
💡 Чому все це на рівні context, а не page: BrowserContext — це "профіль браузера". Одне вікно має одну геолокацію, один timezone, один набір дозволів для всіх вкладок всередині. Якщо треба два різних міста одночасно — треба два різних context, а отже і дві різних page. Це прямий наслідок ієрархії Browser → Context → Page (Якір 1).
💡 Why all of this lives on the context, not the page: a BrowserContext is a "browser profile". One window has one location, one timezone and one set of permissions for every tab inside it. If you need two different cities at once, you need two contexts, and therefore two pages. That falls straight out of the Browser → Context → Page hierarchy (Anchor 1).
🔧 TypeScript / JS патерни в цьому прикладі await context.setGeolocation({ latitude: 51.5, longitude: -0.1 }) — options object з числовими полями; await context.grantPermissions(['geolocation']) — масив рядків; timezoneId: 'Europe/Kyiv' — IANA timezone string; всі налаштування на рівні context.
🔧 TypeScript / JS patterns in this example await context.setGeolocation({ latitude: 51.5, longitude: -0.1 }) — an options object with numeric fields; await context.grantPermissions(['geolocation']) — an array of strings; timezoneId: 'Europe/Kyiv' — an IANA timezone string; all of it set at the context level.
🗣 Як читати вголос Проблема без цього API — в CI-середовищі геолокація визначається IP-адресою сервера (рандомний датацентр), timezone береться з ОС контейнера, а дозволи типу notifications викликають системний попап, який неможливо автоматизувати — тест просто зависне чекаючи кліку якого ніколи не буде. BrowserContext дозволяє встановити все це явно при створенні: передаємо geolocation з конкретними координатами, permissions з масивом дозволів які вже "видані", і timezoneId у форматі IANA назви timezone. Важливо розуміти чому саме context, а не page: context — це "профіль браузерного вікна", одне вікно має одну геолокацію і один timezone для всіх своїх вкладок. Якщо треба протестувати два різних міста паралельно — створюємо два різних context, кожен зі своєю page, і вони абсолютно ізольовані одне від одного, бо це Якір 1 — ізоляція через context. На практиці для великих multi-domain e-commerce проєктів зручніше встановити timezoneId і locale в playwright.config.ts на рівні всього проєкту — тоді результати тестів однакові незалежно від того де фізично запущений CI-сервер.
🗣 How to say it out loud The problem without this API is that on CI the geolocation comes from the server's IP — some random datacentre — the timezone comes from the container's OS, and permissions like notifications raise a system popup you can't automate, so the test just hangs waiting for a click that never comes. BrowserContext lets you set all of that explicitly when you create it: you pass geolocation with real coordinates, permissions with the list of things already granted, and timezoneId as an IANA timezone name. The reason it's the context and not the page is that a context is a browser profile — one window has one location and one timezone for all of its tabs. So if you want to test two cities side by side, you create two contexts, each with its own page, and they're completely isolated from one another, which is Anchor 1 again, isolation through the context. In practice, on a big multi-domain e-commerce project, I'd set timezoneId and locale in playwright.config.ts for the whole project — then the results are the same no matter where the CI runner physically lives.
61 Що таке page.clock і як тестувати поведінку залежну від часу без реального очікування? What is page.clock and how do you test time-dependent behaviour without actually waiting?
EvaluateMiddle▶

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

Imagine testing "the session expires after 30 minutes of inactivity" with a popup warning the user. How do you test that? Actually wait 30 minutes? Obviously not. Or: you're testing a banner that says "Sale ends in 2 hours", and that text comes from a new Date() inside a React component. How do you prove that in "2 hours" it changes to "Sale ended"?

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

The problem is that the browser lives in real time. new Date() returns the actual current time. setTimeout(fn, 30 * 60 * 1000) really does wait 30 minutes.

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

page.clock is the Playwright tool that replaces the browser's clock with one you control. You say "it's 10:00" and the browser believes you. Then you say "jump 31 minutes forward" and every setTimeout, setInterval, Date and performance.now() in the browser reacts instantly, as if that time really had passed. With no real waiting at all.

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

Worth knowing: page.clock only affects JavaScript in the browser. The Node.js process, where your test lives, carries on in real time — which is fine, because the test is the one doing the fast-forwarding.

TypeScript — базове використання: фіксуємо час і перемотуємо
TypeScript — the basics: pin the time and fast-forward
// Сценарій: сесія закінчується через 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('Ваша сесія закінчилась');
});
// Scenario: the session expires after 30 minutes and a popup appears

test('"session expired" popup appears after 30 minutes', async ({ page }) => {

  // 1. SET the time BEFORE navigating to the page
  // Pin "now" to 10:00:00 — the browser will think that's the current time
  await page.clock.install({ time: new Date('2024-01-15T10:00:00') });

  // 2. Open the page — all timers start from our pinned time
  await page.goto('/dashboard');

  // 3. Check there's no popup yet (30 minutes haven't passed)
  await expect(page.locator('.session-timeout-modal')).not.toBeVisible();

  // 4. FAST-FORWARD the clock 31 minutes — instantly, no real waiting
  // All setTimeout/setInterval in the browser fire as if 31 minutes had passed
  await page.clock.fastForward('31:00'); // format 'mm:ss'

  // 5. The popup appeared — assert on it
  await expect(page.locator('.session-timeout-modal')).toBeVisible();
  await expect(page.locator('.session-timeout-modal h2'))
    .toHaveText('Your session has expired');
});
TypeScript — тестування UI що показує "залишилось X часу"
TypeScript — testing UI that shows "X time left"
// Сценарій: банер "Акція діє ще 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('Акція закінчилась');
});
// Scenario: banner "Sale ends in 2 hours" → "Sale has ended"

test('sale banner updates when time runs out', async ({ page }) => {

  // Set the time: the sale ends at 12:00, it's now 10:00
  await page.clock.install({ time: new Date('2024-01-15T10:00:00') });
  await page.goto('/sale');

  // It's 10:00 — the banner shows the time left
  await expect(page.locator('.sale-banner')).toContainText('Sale ends in');

  // Fast-forward exactly 2 hours — now it's "12:00:00"
  await page.clock.fastForward('2:00:00'); // format 'hh:mm:ss'

  // The banner updated — it shows the sale has ended
  await expect(page.locator('.sale-banner')).toContainText('Sale has ended');
});
TypeScript — tick() vs fastForward() і setSystemTime()
TypeScript — tick() vs fastForward(), and setSystemTime()
// Є три способи керувати часом, і вони різні:

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

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

// 3. setSystemTime(time) — просто зсуває годинник БЕЗ виконання таймерів
//    Корисно коли треба що Date.now() повертав інше число,
//    але не треба щоб спрацювали всі проміжні setInterval
await page.clock.setSystemTime(new Date('2024-12-31T23:59:00'));
// Тепер new Date() в браузері = 31 грудня 23:59, але таймери не спрацювали
// There are three ways to control time, and they differ:

// 1. fastForward(time) — advances the clock AND immediately fires every timer that would have fired
//    With a setTimeout every 5 minutes, all 6 calls in 30 minutes will run
await page.clock.fastForward('30:00');

// 2. tick(milliseconds) — same thing, but the argument is in milliseconds
await page.clock.tick(30 * 60 * 1000); // 30 minutes in ms

// 3. setSystemTime(time) — just shifts the clock WITHOUT firing timers
//    Useful when you need Date.now() to return a different value,
//    but don't want all the intermediate setIntervals to fire
await page.clock.setSystemTime(new Date('2024-12-31T23:59:00'));
// Now new Date() in the browser = Dec 31 23:59, but no timers fired
💡 Важливий нюанс: page.clock.install() треба викликати ДО page.goto(). Якщо викликати після — деякі таймери і Date виклики що відбулись під час завантаження сторінки вже пройшли з реальним часом, і мокання буде неповним.
💡 The nuance that matters: call page.clock.install() BEFORE page.goto(). Call it after and some of the timers and Date calls that ran during page load have already gone through with the real clock, so the mock is only partial.
🔧 TypeScript / JS патерни в цьому прикладі await page.clock.install({ time: new Date('2024-01-15') }) — options з Date об'єктом; await page.clock.fastForward('30:00') — рядок у форматі MM:SS; new Date() — створення Date об'єкту; page.clock.setSystemTime(timestamp) — встановлення конкретного timestamp.
🔧 TypeScript / JS patterns in this example await page.clock.install({ time: new Date('2024-01-15') }) — options carrying a Date object; await page.clock.fastForward('30:00') — a string in MM:SS format; new Date() — constructing a Date; page.clock.setSystemTime(timestamp) — pinning a specific timestamp.
🗣 Як читати вголос Уяви що тестуєш функцію "сесія закінчується через 30 хвилин" — реально чекати 30 хвилин неможливо, а page.clock вирішує це елегантно: він повністю замінює годинник всередині браузера на підконтрольний нам. Ми встановлюємо фіксований час через clock.install() до того як заходимо на сторінку, і після цього браузер думає що зараз саме цей час — new Date() повертає його, всі setTimeout і setInterval відраховують від нього. Потім ми викликаємо fastForward('31:00') — і Playwright миттєво просуває час на 31 хвилину, виконуючи всі таймери які мали б спрацювати за цей період, без жодного реального очікування. Є три методи керування: fastForward і tick — обидва просувають час і виконують таймери, різниця лише в форматі аргументу; setSystemTime просто зсуває годинник без виконання таймерів — корисно коли треба лише щоб Date.now() повертав інше значення. Ключовий нюанс — install() завжди до goto(), бо інакше частина ініціалізаційного коду сторінки вже виконалась з реальним часом.
🗣 How to say it out loud Say you're testing "the session expires after 30 minutes" — you obviously can't sit there for 30 minutes, and page.clock solves that neatly by replacing the clock inside the browser with one we control. We pin a fixed time with clock.install() before we navigate, and from then on the browser thinks that's the current time — new Date() returns it, and every setTimeout and setInterval counts from it. Then we call fastForward('31:00') and Playwright advances the clock 31 minutes instantly, running every timer that should have fired in that window, with no real waiting. There are three methods: fastForward and tick both advance time and run the timers, and the only difference is the argument format; setSystemTime just moves the clock without running anything, which is what you want when you only need Date.now() to return something else. The one thing to get right is that install() always comes before goto(), otherwise part of the page's startup code has already run against the real clock.
62 Коли page.clock не спрацює, хоча час "перемотався"? When does page.clock quietly do nothing, even though time "moved"?
Evaluate Senior ▶

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

A browser actually has two independent clocks. page.clock controls only one of them — which is why in some scenarios fastForward() changes nothing at all. That isn't a bug in your test, it's the edge of what the tool can do.

  • Механічний годинник — 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).
  • The mechanical clock — setTimeout, setInterval, Date.now(), performance.now(). This is the one page.clock.install() replaces.
  • The animation clock — document.timeline.currentTime. It drives CSS animations (animation: progress 6s), CSS transitions (transition: width 6s) and the Web Animations API (element.animate(...)). page.clock doesn't touch it — an acknowledged Playwright limitation, still unimplemented (issue #38951).

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

The difference is easy to miss, because the code looks like it hangs off a timer:

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

// ✅ page.clock ДОПОМОЖЕ — перемикання прив'язане напряму до таймера
setTimeout(() => nextSlide(), 6000);
// ❌ page.clock WON'T help — the switch is tied to a CSS animation,
// not to setTimeout, even if there's some async code inside
animatePipProgress(6000)
  .onFinish(() => nextSlide());

// ✅ page.clock WILL help — the switch is tied directly to a timer
setTimeout(() => nextSlide(), 6000);

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

How to diagnose it: measure three deltas right after fastForward() — Date.now(), performance.now() and document.timeline.currentTime. If the first two jumped by the expected 6000 ms and the third is still near zero, this is exactly your case: the mechanical clock was replaced, the animation clock is still living in real time.

TypeScript — діагностика: який годинник насправді не рухається
TypeScript — diagnosis: which clock actually isn't moving
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 сильно менший за очікуване — ось і причина "тест не бачить перемикання"
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 ✓ fake clock advanced
  perfDelta: after.perf - before.perf,         // ≈ 6000 ✓ fake clock advanced
  timelineDelta: after.timeline - before.timeline, // ≈ 1000 ✗ real time, not 6000!
});
// timelineDelta is far below expected — that's why "the test doesn't see the switch"
Три варіанти рішення — від чіт-коду до правильної архітектури
Three ways out — from the cheat to the right architecture
// ❌ Варіант 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');
// ❌ Option 1 — force the animation to finish directly. Technically works,
// but it tests something the user DOESN'T see: users never call getAnimations()
await page.evaluate(() => {
  document.getAnimations().forEach(a => { try { a.finish(); } catch {} });
});

// ✅ Option 2 — an honest test: wait real time via expect.poll,
// test.slow() triples the timeout to compensate for slowness
test('carousel autoplay advances through slides', async ({ page }) => {
  test.slow();
  await expect
    .poll(() => getActiveSlideIndex(page), { timeout: 7000 })
    .toBe(2);
});

// ✅✅ Option 3 — best long term: a test hook from the frontend
// a query param that shortens autoplay duration in non-prod builds —
// the test no longer depends on clock or on real waiting
await page.goto('/plp?autoPlayDuration=100');
🗣 Як читати вголос В браузері насправді два годинники, і про це мало хто знає, поки не наступить на граблі. Один — механічний, він рахує час для setTimeout, setInterval, Date.now і performance.now. Другий — анімаційний, document.timeline.currentTime, і він рахує час окремо, для CSS-анімацій, transitions і Web Animations API. Playwright clock.install підміняє тільки перший. Другий він не чіпає, і це визнане обмеження, ще не реалізоване. На практиці це виглядає так: у тебе карусель, і перемикання слайду відбувається не по setTimeout, а по завершенню CSS-анімації прогрес-бару. Викликаєш fastForward на шість секунд — механічний годинник слухняно перемотується, а анімація як жила в реальному часі, так і живе. Слайд не перемикається, і тест падає, хоча код виглядає абсолютно правильним. Діагностика проста: заміряй три дельти одразу після fastForward — якщо dateDelta і perfDelta нормальні, а timelineDelta залишився крихітним — це саме воно. Є спокуса просто форснути всі анімації через getAnimations forEach finish, але це тестує не те, що бачить юзер. Чесний варіант — чекати реальний час з test.slow для збільшеного таймауту. А найкращий варіант у довгостроковій перспективі — попросити фронтенд додати тестовий хук, query-параметр який у не-проді скорочує тривалість автоплею, і тоді тест взагалі не залежить ні від годинника, ні від довгого реального очікування.
🗣 How to say it out loud A browser really has two clocks, and most people don't find that out until they trip over it. One is the mechanical clock, which drives setTimeout, setInterval, Date.now and performance.now. The other is the animation clock, document.timeline.currentTime, and it counts separately, for CSS animations, transitions and the Web Animations API. Playwright's clock.install only replaces the first one. It doesn't touch the second, and that's an acknowledged limitation, still not implemented. In practice it looks like this: you have a carousel, and the slide advances not on a setTimeout but when a CSS animation on the progress bar finishes. You call fastForward for six seconds, the mechanical clock dutifully jumps, and the animation carries on in real time exactly as before. The slide doesn't move, the test fails, and the code looks perfectly correct. Diagnosing it is easy: measure the three deltas right after fastForward, and if dateDelta and perfDelta look right while timelineDelta stayed tiny, that's it. It's tempting to just force every animation to finish with getAnimations forEach finish, but then you're testing something the user never does. The honest option is to wait real time with test.slow for a bigger timeout. And the best option long term is to ask the front end for a test hook — a query parameter that shortens the autoplay duration outside production — and then the test doesn't depend on the clock or on a long real wait at all.
63 Як тестувати lazy-loaded контент і virtualized lists? How do you test lazy-loaded content and virtualized lists?
EvaluateMiddle▶

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

Lazy loading means content is only fetched once it scrolls into view. A virtualized list only renders the visible slice into the DOM — say 10 rows out of 1000. Both patterns break the naive "find all the elements and count them" approach, because until you scroll, they simply aren't in the DOM.

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

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

// Virtualized list — DOM рендерить тільки видимі елементи
// Шукаємо конкретний елемент через scroll
await page.evaluate(async () => {
  const container = document.querySelector('.virtual-list')!;
  container.scrollTop = 5000;
});
await expect(page.getByText('Product #200')).toBeVisible();
// Scroll to the element to trigger lazy load
await page.locator('.product-card').last().scrollIntoViewIfNeeded();

// Scroll to the end and count all products
let prevCount = 0;
while (true) {
  const cards = page.locator('.product-card');
  const count = await cards.count();
  if (count === prevCount) break; // nothing more loads
  prevCount = count;
  await cards.last().scrollIntoViewIfNeeded();
  await page.waitForTimeout(500); // exception: no DOM signal — the only justified waitForTimeout
}

// Virtualized list — the DOM renders only visible items
// Find a specific item by scrolling
await page.evaluate(async () => {
  const container = document.querySelector('.virtual-list')!;
  container.scrollTop = 5000;
});
await expect(page.getByText('Product #200')).toBeVisible();
🔧 TypeScript / JS патерни в цьому прикладі const seenHrefs = new Set() — TypeScript generic: Set означає "Set який містить тільки string"; seenHrefs.add(href) і seenHrefs.size — методи і властивості Set; while (seenHrefs.size < totalCount) {} — цикл while з умовою.
🔧 TypeScript / JS patterns in this example const seenHrefs = new Set<string>() — a TypeScript generic: a Set that holds only strings; seenHrefs.add(href) and seenHrefs.size — methods and properties of a Set; while (seenHrefs.size < totalCount) {} — a while loop with a condition.
🗣 Як читати вголос locator('.product-card').last() — беремо локатор всіх карток, потім .last() — остання з них, і .scrollIntoViewIfNeeded() — скролимо до неї якщо вона поза viewport. В циклі while(true): кожну ітерацію рахуємо cards.count(), порівнюємо з попереднім значенням — якщо не змінилось, break. Для virtualized list — page.evaluate виконує код в браузері де напряму встановлюємо container.scrollTop, це прокручує контейнер до потрібної позиції щоб потрібний елемент відрендерився в DOM.
🗣 How to say it out loud locator('.product-card').last() takes a locator for every card, then .last() narrows it to the final one, and .scrollIntoViewIfNeeded() scrolls to it if it's outside the viewport. Inside the while(true) loop we count cards.count() on every pass and compare it with the previous value — if it hasn't changed, we break. For the virtualized list, page.evaluate runs code in the browser where we set container.scrollTop directly, which scrolls the container to the position we need so the element we're after actually renders into the DOM.
64 Як Playwright взаємодіє з Service Workers і чому вони можуть ламати page.route() і page.on('request')? How does Playwright interact with Service Workers, and why can they break page.route() and page.on('request')?
EvaluateSenior▶

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

A Service Worker is a JavaScript file the browser registers separately from the page, and it acts as a proxy between the page and the network. It can intercept requests, cache responses, and answer without ever touching the server. It's what powers PWAs, offline mode and push notifications.

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

The problem in tests: when you write page.route() or page.on('request'), Playwright intercepts at the browser-process level. But a Service Worker intercepts requests before they ever get that far. If the Service Worker decides to answer from cache, page.route() never sees the request at all. As far as Playwright is concerned, it never happened.

  • Твій page.route() мок не спрацює — відповідь прийшла з Service Worker кешу
  • page.on('request') не зафіксує запит — він не дійшов до рівня де Playwright слухає
  • Тест може проходити або падати залежно від стану кешу — класичний недетермінований результат
  • Your page.route() mock doesn't fire — the response came from the Service Worker cache
  • page.on('request') records nothing — the request never reached the level Playwright listens at
  • The test passes or fails depending on the state of the cache — the classic non-deterministic result

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

Two ways out: serviceWorkers: 'block' — turn Service Workers off entirely for the test context, so every request goes straight to the network and Playwright sees it. Or page.on('worker') — talk to the Service Worker directly, when the PWA behaviour is the thing you're actually testing.

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

    // 'allow' — за замовчуванням, Service Workers працюють як в реальному браузері
    // serviceWorkers: 'allow',
  },
});
export default defineConfig({
  use: {
    // 'block' — Service Workers are never registered
    // All network requests go direct, page.route() and page.on('request') work normally
    serviceWorkers: 'block',

    // 'allow' — the default, Service Workers work as in a real browser
    // serviceWorkers: 'allow',
  },
});
TypeScript — або вимикаємо для конкретного context
TypeScript — or switching them off for one context
test('мок API відповіді на сторінці з PWA', async ({ browser }) => {
  // Створюємо context з вимкненим Service Worker
  const context = await browser.newContext({
    serviceWorkers: 'block',
  });
  const page = await context.newPage();

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

  await page.goto('/products');
  await expect(page.locator('.product-name')).toHaveText('Trench Coat');
  await context.close();
});
test('mock API response on a PWA page', async ({ browser }) => {
  // Create a context with Service Workers disabled
  const context = await browser.newContext({
    serviceWorkers: 'block',
  });
  const page = await context.newPage();

  // Now page.route() will definitely fire — no Service Worker intercepting
  await page.route('**/api/products', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ items: [{ id: 1, name: 'Trench Coat' }] }),
    });
  });

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

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

  await page.goto('/');

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

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

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

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

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

  // Сторінка має завантажитись навіть без мережі
  await expect(page.locator('h1')).toBeVisible();
  await expect(page.locator('.offline-banner')).toBeVisible();
});
// If you need to TEST the PWA functionality itself — don't disable it, interact with it
test('Service Worker registers successfully', async ({ page }) => {
  const workers: any[] = [];

  // Subscribe before goto — so we don't miss registration during load
  page.on('worker', worker => {
    workers.push(worker);
    console.log('Worker registered:', worker.url());
  });

  await page.goto('/');

  // Wait for the Service Worker to register
  await page.waitForFunction(() =>
    navigator.serviceWorker.ready.then(() => true)
  );

  // Check that the worker is registered
  expect(workers.length).toBeGreaterThan(0);
  expect(workers[0].url()).toContain('service-worker.js');
});

test('page works offline (PWA cache)', async ({ page, context }) => {
  // First load online — the Service Worker caches resources
  await page.goto('/');
  await page.waitForFunction(() =>
    navigator.serviceWorker.ready.then(() => true)
  );

  // Switch to offline mode via context
  await context.setOffline(true);

  // Reload — the Service Worker should respond from cache
  await page.reload();

  // The page should load even without network
  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()
💡 What to use when:
Testing the page's business logic and want to mock the API → serviceWorkers: 'block' in the config or on the context, so the Service Worker stays out of page.route()'s way
Testing the PWA behaviour itself (offline, caching, push) → leave it on 'allow' and work through page.on('worker') and context.setOffline()
🔧 TypeScript / JS патерни в цьому прикладі serviceWorkers: 'block' — рядкове enum значення в конфігурації; page.on('worker', worker => {}) — підписка на появу worker; navigator.serviceWorker.ready.then(() => true) — Promise chain у браузерному коді; context.setOffline(true) — булевий аргумент.
🔧 TypeScript / JS patterns in this example serviceWorkers: 'block' — a string enum value in the config; page.on('worker', worker => {}) — subscribing to a worker appearing; navigator.serviceWorker.ready.then(() => true) — a promise chain in browser code; context.setOffline(true) — a boolean argument.
🗣 Як читати вголос Service Worker — це окремий JavaScript процес в браузері який сидить між сторінкою і мережею і може перехоплювати запити та відповідати з кешу. Проблема для Playwright в тому що page.route() і page.on('request') перехоплюють запити на рівні браузерного процесу — але якщо Service Worker відповів з кешу, запит до цього рівня взагалі не дійшов. Playwright його не бачить. Це класичне джерело нестабільних тестів де результат залежить від стану кешу Service Worker. Є два підходи. Якщо нас не цікавить PWA функціональність і ми просто хочемо тестувати бізнес-логіку — встановлюємо serviceWorkers: 'block' в конфізі або при створенні context, і тоді Service Workers взагалі не реєструються і всі запити йдуть напряму через Playwright. Якщо ж ми тестуємо саму PWA — наприклад що сторінка працює офлайн — тоді лишаємо Service Workers увімкненими, підписуємось на page.on('worker') щоб відстежувати реєстрацію, і використовуємо context.setOffline(true) щоб симулювати відсутність мережі і перевіряти що Service Worker коректно відповідає з кешу.
🗣 How to say it out loud A Service Worker is a separate JavaScript process in the browser that sits between the page and the network and can intercept requests and answer them from cache. The problem for Playwright is that page.route() and page.on('request') intercept at the browser-process level — so if the Service Worker answered from cache, the request never got that far and Playwright never sees it. That's a classic source of flaky tests, where the result depends on the state of the Service Worker cache. There are two approaches. If we don't care about the PWA side and just want to test business logic, we set serviceWorkers: 'block' in the config or when we create the context, and then Service Workers never register at all and every request goes through Playwright. If the PWA itself is what we're testing — that the page works offline, say — then we leave Service Workers on, subscribe to page.on('worker') to track the registration, and use context.setOffline(true) to simulate losing the network and check the Service Worker answers correctly from cache.
65 Що таке PWA і як тестувати PWA функціональність в Playwright? What is a PWA and how do you test PWA behaviour in Playwright?
EvaluateMiddle▶

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

A PWA (Progressive Web App) is a website that behaves like a native app. The user can install it to the home screen, it works offline and it can receive push notifications — but it's still a website, not an App Store build.

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

The three pieces of a PWA:

  • Service Worker — JavaScript файл який працює у фоні окремо від сторінки. Перехоплює мережеві запити, кешує ресурси, дає офлайн режим. Детально в наступній картці.
  • Web App Manifest — JSON файл з описом додатку: назва, іконки, колір, режим відображення. Саме завдяки йому браузер пропонує "Встановити".
  • HTTPS — обов'язково. Без нього Service Worker не реєструється.
  • Service Worker — a JavaScript file that runs in the background, separately from the page. It intercepts network requests, caches resources and gives you offline mode. Covered in detail in the next card.
  • Web App Manifest — a JSON file describing the app: name, icons, colour, display mode. It's what makes the browser offer to "Install".
  • HTTPS — mandatory. Without it a Service Worker won't register at all.

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

A real example: you open myshop.com in Chrome on your phone. The browser offers to "Add to home screen". After that the app opens with no address bar, keeps some data offline, and can push you about new collections.

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

For testing: Playwright lets you test offline mode with context.setOffline(true), listen for Service Workers with page.on('worker', ...), and emulate mobile devices with devices['iPhone 14']. One thing to watch: a Service Worker can intercept requests before page.route() ever sees them — that's the next card.

TypeScript — тестування PWA офлайн режиму
TypeScript — testing PWA offline mode
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('page shows cached content offline', async ({ page, context }) => {
  // 1. Load the page online — the Service Worker caches resources
  await page.goto('/products');
  await page.waitForLoadState('networkidle');

  // 2. Cut the network
  await context.setOffline(true);

  // 3. Reload — should show cached content
  await page.reload();
  await expect(page.locator('.product-grid')).toBeVisible();
  await expect(page.locator('.offline-banner')).toBeVisible();
});
TypeScript — перевірка Web App Manifest
TypeScript — checking the Web App Manifest
test('PWA manifest налаштований коректно', async ({ page }) => {
  await page.goto('/');

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

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

  expect(manifest.name).toBeTruthy();
  expect(manifest.icons).toHaveLength(expect.any(Number));
  expect(manifest.start_url).toBeTruthy();
});
test('PWA manifest is configured correctly', async ({ page }) => {
  await page.goto('/');

  // Check that the manifest is linked
  const manifestLink = page.locator('link[rel="manifest"]');
  await expect(manifestLink).toHaveAttribute('href', /manifest\.json/);

  // Fetch and check the manifest contents
  const manifestUrl = await manifestLink.getAttribute('href');
  const response = await page.request.get(manifestUrl!);
  const manifest = await response.json();

  expect(manifest.name).toBeTruthy();
  expect(manifest.icons).toHaveLength(expect.any(Number));
  expect(manifest.start_url).toBeTruthy();
});
🔧 TypeScript / JS патерни в цьому прикладі context.setOffline(true) — метод на рівні контексту (не page); await page.reload() — перезавантаження сторінки; page.request.get(url) — HTTP запит через Playwright без браузера; manifestUrl! — non-null assertion в TypeScript: кажемо компілятору що значення точно не null.
🔧 TypeScript / JS patterns in this example context.setOffline(true) — a method at the context level, not the page; await page.reload() — reloading the page; page.request.get(url) — an HTTP request through Playwright with no browser; manifestUrl! — a non-null assertion in TypeScript: telling the compiler this value definitely isn't null.
🗣 Як читати вголос PWA — Progressive Web App — це веб-сайт який поводиться як нативний додаток: встановлюється на головний екран, працює офлайн, отримує push-сповіщення. Складається з трьох компонентів: Service Worker який кешує ресурси і перехоплює мережеві запити, Web App Manifest — JSON файл з описом додатку завдяки якому браузер пропонує встановлення, і обов'язковий HTTPS. Playwright дозволяє тестувати PWA через context.setOffline(true) для симуляції офлайн режиму — завантажуємо сторінку онлайн щоб Service Worker скешував ресурси, потім вимикаємо мережу і перевіряємо що сторінка все одно показує контент. Також можна перевірити наявність і вміст Web App Manifest через звичайний HTTP запит і DOM перевірку наявності link[rel="manifest"].
🗣 How to say it out loud A PWA — Progressive Web App — is a website that behaves like a native app: it installs to the home screen, works offline and can receive push notifications. It's made of three pieces: a Service Worker that caches resources and intercepts network requests, a Web App Manifest, which is the JSON file describing the app that makes the browser offer to install it, and HTTPS, which is mandatory. In Playwright you test the offline side with context.setOffline(true) — load the page online so the Service Worker caches everything, then cut the network and check the page still renders content. And you can check the Web App Manifest is there and sane with an ordinary HTTP request plus a DOM check for link[rel="manifest"].
▶ 7 8
66 Як перехопити мережевий запит і замокати відповідь? How do you intercept a network request and mock the response?
МережаNetworkMiddle▶

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

page.route() is the method that tells Playwright: "when the browser tries to make a request to this address, don't send it — ask me what to do first".

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

route is an instance of Playwright's Route class. When Playwright passes route into your callback it hands you an object of that class with all its methods. In TypeScript you can spell it out — async (route: Route) => {} — but usually the type is inferred.

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

What lives on the route object:

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.request()           // request info — an instance of the Request class
route.request().url()      // request URL
route.request().method()   // GET, POST, PUT...
route.request().headers()  // request headers
route.request().postData() // POST request body

route.fulfill()   // respond with your own response
route.continue()  // pass the request on to the server
route.fetch()     // perform the request and get the response in Node.js
route.abort()     // block the request entirely

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

route is the object Playwright hands you in the callback. It represents one intercepted request and gives you a choice of what to do with it. So page.route() is customs control for network requests. Every request matching the pattern comes past you, and you decide what happens to it.

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

Tests depend on the real API — and that's a problem. The API can be unreachable in CI, return different data every time, or do real work (charge money, send emails). page.route() intercepts the network request and lets you return your own response instead of going to the real server.

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

How it works: page.route(pattern, callback) is customs control for network requests. The first argument is a URL pattern ('**/api/**', say — every request to /api/). The second is the function Playwright calls for every request that matches the pattern.

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

route — the object passed into the callback. It represents one intercepted request and gives you a choice of what to do with it:

  • route.fulfill() — повернути власну відповідь, реальний сервер не викликається
  • route.continue() — відправити запит далі, але можна змінити headers або тіло
  • route.abort() — заблокувати запит повністю
  • route.fetch() — виконати реальний запит, отримати відповідь і вирішити що повернути браузеру
  • route.fulfill() — return your own response; the real server is never called
  • route.continue() — send the request on, but you can change the headers or the body first
  • route.abort() — block the request entirely
  • route.fetch() — make the real request, get the response and decide what to hand the browser

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

This isn't cheating the test — it's controlling its dependencies. The test checks the UI logic, not the reliability of some external service.

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

context.route() vs page.route(): page.route() intercepts requests from one page only. context.route() covers every page in the BrowserContext. Handy for global mocks that should fire on all tabs. Order of handling: page.route() fires first, then context.route() — so you can override a global route with a local one.

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

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

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

// Шпигувати за запитами (без перехоплення)
page.on('request', req => {
  if (req.url().includes('/api/')) console.log(req.method(), req.url());
});
// Replace an API response
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 }
    ]),
  });
});

// Modify the real response
await page.route('**/api/user', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  json.isPremium = true;  // add a field
  await route.fulfill({ json });
});

// Block requests (analytics, ads)
await page.route('**/*.{png,jpg,css}', route => route.abort());

// Spy on requests (without intercepting)
page.on('request', req => {
  if (req.url().includes('/api/')) console.log(req.method(), req.url());
});
🔧 TypeScript / JS патерни в цьому прикладі await page.route('**/api/**', async route => {}) — передача async стрілочної функції як callback; route.fulfill({ status: 200, body: JSON.stringify(data) }) — options object; JSON.stringify() — серіалізація об'єкта в JSON рядок; glob pattern **/api/** — wildcard: ** означає "будь-яка кількість символів включно з /".
🔧 TypeScript / JS patterns in this example await page.route('**/api/**', async route => {}) — passing an async arrow function as the callback; route.fulfill({ status: 200, body: JSON.stringify(data) }) — an options object; JSON.stringify() — serialising an object into a JSON string; the glob pattern **/api/** — a wildcard: ** means "any number of characters, slashes included".
🗣 Як читати вголос Викликаємо page.route, передаємо патерн URL і асинхронний колбек з об'єктом route. Всередині — route.fulfill, відкриваємо дужки, передаємо об'єкт: status — HTTP статус, contentType — заголовок, body — тіло як рядок через JSON.stringify. Другий варіант: викликаємо route.fetch() — він справді іде на сервер, отримуємо реальну відповідь, модифікуємо її і передаємо в fulfill({ json }). route.abort() — просто скасовує запит, браузер отримає мережеву помилку. page.on('request', ...) — лише спостереження без зміни запиту.
🗣 How to say it out loud We call page.route, pass a URL pattern and an async callback that receives the route object. Inside it, route.fulfill — open the braces and pass an object: status for the HTTP status, contentType for the header, body as a string through JSON.stringify. The other variant: we call route.fetch(), which really does hit the server, we get the real response back, change it and pass it into fulfill({ json }). route.abort() simply cancels the request, so the browser gets a network error. And page.on('request', ...) is pure observation — it watches without changing anything.
67 Як модифікувати запит або відповідь через page.route()? Різниця між fulfill, continue, fetch і abort How do you modify a request or a response with page.route()? The difference between fulfill, continue, fetch and abort
МережаNetworkSenior▶

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

page.route() is the method that tells Playwright: "when the browser tries to make a request to this address, don't send it — ask me what to do first". route is the object Playwright passes into the callback, and it represents one intercepted request.

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

Most people only know one scenario — replace the response with a mock entirely. There are actually four different actions, each for its own case:

  • route.fulfill() — повернути відповідь без реального запиту. Ти вигадуєш що повернути. Сервер не викликається.
  • route.continue() — відправити запит далі на реальний сервер, але з можливістю змінити headers, метод, тіло або URL перед відправкою. Відповідь іде напряму в браузер.
  • route.fetch() — виконати реальний запит, отримати відповідь в Node.js, змінити її і повернути браузеру. "Людина посередині".
  • route.abort() — заблокувати запит повністю. Браузер отримає мережеву помилку.
  • route.fulfill() — return a response with no real request at all. You invent what comes back. The server is never called.
  • route.continue() — send the request on to the real server, with the option to change the headers, method, body or URL before it goes. The response goes straight to the browser.
  • route.fetch() — make the real request, get the response in Node.js, change it and hand it to the browser. Man in the middle.
  • route.abort() — block the request entirely. The browser gets a network error.

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

The main difference between continue() and fetch(): continue() sends the request and forgets about it — the response goes straight to the browser and we never see it. fetch() sends the request, we get the response in Node.js, read it and change it, and only then hand it to the browser through fulfill().

TypeScript — route.fulfill(): повний мок без реального запиту
TypeScript — route.fulfill(): a full mock with no real request
// Реальний сервер не викликається — відповідь повністю вигадана
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');
// Сторінка отримала наш мок — завжди одні й ті самі дані, стабільно
// The real server isn't called — the response is fully fabricated
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');
// The page got our mock — always the same data, stable
TypeScript — route.continue(): модифікуємо запит перед відправкою
TypeScript — route.continue(): changing the request before it goes
// Запит іде на реальний сервер, але з нашими зміненими 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',
    },
  });
});
// The request goes to the real server, but with our modified headers
await page.route('**/api/**', async route => {
  // Take the original headers and add our own
  const headers = {
    ...route.request().headers(), // all original headers
    'Authorization': 'Bearer test-token-123', // add a token
    'X-Test-Mode': 'true',               // add a custom header
  };

  // Send with our headers — the response goes straight to the browser
  await route.continue({ headers });
});

// You can also change the POST request body:
await page.route('**/api/cart/add', async route => {
  const originalBody = JSON.parse(route.request().postData() || '{}');

  // Swap the SKU for a test product
  const modifiedBody = { ...originalBody, sku: 'TEST-SKU-999' };

  await route.continue({
    postData: JSON.stringify(modifiedBody),
    headers: {
      ...route.request().headers(),
      'Content-Type': 'application/json',
    },
  });
});
TypeScript — route.fetch(): отримуємо реальну відповідь і модифікуємо її
TypeScript — route.fetch(): getting the real response and changing it
// "Людина посередині": запит пішов на сервер, відповідь прийшла до нас,
// ми її змінили і віддали браузеру
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),          // модифіковане тіло
  });
});
// "Man in the middle": the request went to the server, the response came to us,
// we modified it and passed it to the browser
await page.route('**/api/products', async route => {
  // 1. Perform the real request — get the actual response
  const response = await route.fetch();
  const body = await response.json();

  // 2. Modify the response — add a test product to the list
  body.items.push({ id: 'TEST-001', name: 'Test Product', price: 0 });

  // 3. Return the modified response to the browser
  await route.fulfill({
    status: response.status(),          // original status
    headers: response.headers(),        // original headers
    body: JSON.stringify(body),          // modified body
  });
});
TypeScript — route.abort(): блокуємо запити
TypeScript — route.abort(): blocking requests
// Блокуємо всі запити до аналітики — тести не залежать від сторонніх сервісів
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' })
);
// Block all analytics requests — tests don't depend on third-party services
await page.route('**/{analytics,tracking,ads}/**', route => route.abort());

// Block images — faster tests when visuals don't matter
await page.route('**/*.{png,jpg,jpeg,gif,webp,svg}', route => route.abort());

// Check that a request to a removed resource returns 404:
await page.route('**/api/deleted-product', route =>
  route.fulfill({ status: 404, body: 'Not Found' })
);
TypeScript — комбінуємо кілька route handlers в одному тесті
TypeScript — combining several route handlers in one test
// Різні правила для різних 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');
});
// Different rules for different URLs — register several routes at once
test('checkout with mocked API and real auth server', async ({ page }) => {
  // Mock products — stable test data
  await page.route('**/api/products/**', route =>
    route.fulfill({ status: 200, body: JSON.stringify({ price: 1990 }) })
  );

  // Block analytics — we don't want external requests
  await page.route('**/gtm.js', route => route.abort());

  // Auth requests — add a test token but send them for real
  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()
💡 Which one when:
You need stable test data → route.fulfill() — a full mock
You need to add a header or a token → route.continue({ headers }) — modify the request
You need to change the response but keep the real data → route.fetch() + route.fulfill()
You need external dependencies gone (analytics, ads) → route.abort()
🔧 TypeScript / JS патерни в цьому прикладі await page.route('**/api/**', async route => {}) — async callback для route handler; const response = await route.fetch() — await на fetch всередині route handler; const body = await response.json() — другий await; body.items.push({ ... }) — мутація масиву в об'єкті.
🔧 TypeScript / JS patterns in this example await page.route('**/api/**', async route => {}) — an async callback for a route handler; const response = await route.fetch() — awaiting fetch inside the route handler; const body = await response.json() — a second await; body.items.push({ ... }) — mutating an array inside an object.
🗣 Як читати вголос page.route() перехоплює мережеві запити і дає чотири варіанти що з ними робити. fulfill() — повністю вигадати відповідь: реальний сервер не викликається, браузер отримує те що ми описали у статус, contentType і body. continue() — відправити запит далі на реальний сервер, але перед цим можна змінити headers, тіло POST запиту, метод або URL; відповідь від сервера іде напряму в браузер без нашої участі. fetch() — виконати реальний запит і отримати відповідь в Node.js: це дає можливість прочитати що сервер повернув, змінити це і тільки потім передати браузеру через fulfill() — класична схема "людина посередині". abort() — заблокувати запит повністю: браузер отримає мережеву помилку, корисно щоб відрізати зовнішні сервіси типу аналітики або зображення для прискорення тестів. Можна комбінувати кілька page.route() в одному тесті для різних URL — Playwright застосує перший handler який збігається.
🗣 How to say it out loud page.route() intercepts network requests and gives you four options for what to do with them. fulfill() — invent the response completely: the real server is never called and the browser gets whatever we put in status, contentType and body. continue() — send the request on to the real server, but change the headers, the POST body, the method or the URL first if you want; the server's answer goes straight to the browser without us seeing it. fetch() — make the real request and get the response in Node.js: that's what lets you read what the server returned, change it, and only then pass it to the browser through fulfill() — the classic man-in-the-middle shape. abort() — block the request outright: the browser gets a network error, which is handy for cutting off third-party services like analytics, or images to make the tests faster. You can combine several page.route() calls in one test for different URLs — Playwright applies the first handler that matches.
68 Навіщо після route.fetch() ще й route.fulfill()? Як це працює разом? Why do you still need route.fulfill() after route.fetch()? How do the two work together?
МережаNetworkSenior▶

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

Without interception the response goes straight from the server to the browser — you never see it and you can't change it. To change a response you first have to take it for yourself, and then explicitly hand it on to the browser.

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

route.fetch() — says "make the real request, but bring the response to me in Node.js instead of sending it straight to the browser". The browser waits.

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

route.fulfill() — says "here is what to send the browser". Only after that does the browser get a response.

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

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

WITH route.fetch() + fulfill():
Server → response → Node.js (you read and edit it) → Browser
                         ↑                                  ↑
                      route.fetch()                    route.fulfill()

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

Important: fetch() only brings the response into Node.js — it does not send it to the browser by itself. Without a fulfill() at the end the browser waits forever. fetch() = "bring it to me". fulfill() = "now give it to the browser".

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

📌 Playwright 1.48+: route.fulfill() calls no longer show up in Trace Viewer. Intercepted requests appear in the Network tab of the trace instead — it shows that the request was intercepted and what response the browser got.

TypeScript — fetch() + fulfill(): додаємо тестовий товар до реальних даних
TypeScript — fetch() + fulfill(): adding a test product to the real data
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. Perform the real request — get actual data from the server
  //    The browser waits, it hasn't received anything yet
  const response = await route.fetch();
  const body = await response.json();

  // 2. Modify — add a test product to the real list
  body.items.push({ id: 'TEST-001', name: 'Test Product', price: 0 });

  // 3. Only now send it to the browser — with our change
  //    Without this line the browser waits forever!
  await route.fulfill({
    status: response.status(),     // original status from the server
    headers: response.headers(),   // original headers
    body: JSON.stringify(body),     // our modified body
  });
});
TypeScript — умовний мок: є продукт → пропускаємо, немає → мок
TypeScript — a conditional mock: product there → let it through, missing → mock it
// Реальний кейс: сервер не завжди повертає тестовий продукт
// Якщо є — пропускаємо без змін. Якщо немає — підміняємо моком.
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().
// Real case: the server doesn't always return the test product
// If present — pass through unchanged. If not — substitute a mock.
await page.route('**/api/products/**', async route => {
  // 1. Always perform the real request
  const response = await route.fetch();
  const body = await response.json();

  if (body.items && body.items.length > 0) {
    // 2a. Product present — pass the real response to the browser unchanged
    await route.fulfill({
      status: response.status(),
      headers: response.headers(),
      body: JSON.stringify(body),
    });
  } else {
    // 2b. Product missing — return a mock
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        items: [{ id: 'TEST-SKU-001', name: 'Trench Coat', price: 1990 }]
      }),
    });
  }
});

// Important: DON'T use continue() after fetch() —
// continue() would make a SECOND real request.
// We already have the response — just pass it via fulfill().
🔧 TypeScript / JS патерни в цьому прикладі await route.fetch() — async виклик що повертає Response об'єкт; await response.json() — другий await для читання тіла (воно теж асинхронне); body.items.push() — мутація масиву всередині об'єкта; JSON.stringify(body) — серіалізація назад в рядок перед передачею.
🔧 TypeScript / JS patterns in this example await route.fetch() — an async call that returns a Response object; await response.json() — a second await to read the body (that is asynchronous too); body.items.push() — mutating an array inside an object; JSON.stringify(body) — serialising it back into a string before handing it over.
🗣 Як читати вголос route.fetch() і route.fulfill() завжди використовуються разом коли треба отримати реальну відповідь і щось в ній змінити. fetch() виконує реальний запит до сервера але замість відправити відповідь одразу в браузер — повертає її в Node.js. Браузер в цей момент чекає. Ми читаємо відповідь, змінюємо що треба, і тільки потім через fulfill() явно кажемо що саме відправити браузеру. Без fulfill() браузер чекатиме вічно бо Playwright перехопив запит але так і не дав відповідь. Думай про це як "людина посередині": запит пішов на сервер, відповідь прийшла до нас, ми її прочитали і змінили, і тільки потім передали браузеру.
🗣 How to say it out loud route.fetch() and route.fulfill() always come as a pair when you need the real response and want to change something in it. fetch() makes the real request to the server but, instead of sending the response straight on to the browser, returns it into Node.js. The browser is sitting there waiting at that point. We read the response, change what we need, and only then use fulfill() to say explicitly what to send the browser. Without the fulfill() the browser waits forever, because Playwright intercepted the request and then never answered it. Think of it as man in the middle: the request went to the server, the response came back to us, we read it and changed it, and only then passed it along.
69 Як винести логіку page.route() на рівень конфігурації щоб вона спрацьовувала для всіх тестів? How do you lift page.route() logic to the config level so it applies to every test?
МережаNetworkSenior▶

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

If the same interception logic is needed in a lot of tests, you don't repeat it in each one. You move it into an auto fixture in a base file. The fixture fires automatically for every test that imports test from that file.

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

Two options: auto: true — for every test automatically. Without auto — only for the tests that explicitly ask for the fixture.

fixtures/base.ts — логіка route() в auto fixture
fixtures/base.ts — route() logic inside an 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 = для всіх тестів автоматично
});
import { test as base } from '@playwright/test';

export const test = base.extend({
  // auto: true — applies to ALL tests automatically
  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) {
        // Product present — pass the real response through
        await route.fulfill({
          status: response.status(),
          headers: response.headers(),
          body: JSON.stringify(body),
        });
      } else {
        // Product missing — return a mock
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({
            items: [{ id: 'TEST-SKU-001', name: 'Trench Coat', price: 1990 }]
          }),
        });
      }
    });

    await use(undefined); // the test runs here
    // the route is cleaned up automatically after the test
  }, { auto: true }], // ← auto: true = for all tests automatically
});
TypeScript — як використовувати в тестах
TypeScript — how to use it in the tests
// ✅ Варіант 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 }) => { /* спрацює */ });
// ✅ Option 1: auto: true — the test knows nothing about the mock
import { test } from '../fixtures/base'; // ← important: our test, not the one from @playwright/test

test('PDP check', async ({ page }) => {
  await page.goto('/product/trench-coat');
  // mockMissingProducts applies automatically
});

// ✅ Option 2: no auto — only where you request it explicitly
export const test2 = base.extend({
  mockMissingProducts: async ({ page }, use) => { /* same logic */ }
  // no { auto: true }
});

test('test without mock', async ({ page }) => { /* mock not applied */ });
test('test with mock', async ({ page, mockMissingProducts }) => { /* applied */ });
💡 Коли auto: true а коли без:
auto: true — логіка потрібна завжди для всіх тестів (блокування аналітики, умовні моки)
Без auto — логіка потрібна тільки в конкретних тестах або файлах
💡 When auto: true and when not:
auto: true — the logic is always needed, by every test (blocking analytics, conditional mocks)
Without auto — the logic is only needed in particular tests or files
🔧 TypeScript / JS патерни в цьому прикладі base.extend({ fixtureName: [async fn, { auto: true }] }) — масив де перший елемент функція, другий конфігурація; await use(undefined) — передаємо undefined бо fixture нічого не повертає в тест; ?.length — optional chaining: якщо items не існує — повертає undefined а не кидає помилку.
🔧 TypeScript / JS patterns in this example base.extend({ fixtureName: [async fn, { auto: true }] }) — an array whose first element is the function and whose second is the configuration; await use(undefined) — we pass undefined because the fixture returns nothing to the test; ?.length — optional chaining: if items doesn't exist it returns undefined instead of throwing.
🗣 Як читати вголос Щоб логіка page.route() спрацьовувала для всіх тестів без повторення — виносимо її в auto fixture в базовому файлі. Fixture визначається через base.extend() де другий елемент масиву це об'єкт { auto: true } — це означає що fixture запускається для кожного тесту автоматично без оголошення в параметрах. Тест імпортує test з нашого базового файлу замість стандартного @playwright/test і більше нічого не робить — мок спрацьовує сам. Якщо мок потрібен не всім тестам — прибираємо auto: true і явно запитуємо fixture в параметрах тих тестів де він потрібен.
🗣 How to say it out loud To make page.route() logic apply to every test without repeating it, you move it into an auto fixture in a base file. The fixture is defined through base.extend(), where the second element of the array is the object { auto: true } — that's what makes it run for every test automatically, without being declared in the parameters. The test imports test from our base file instead of the standard @playwright/test and does nothing else — the mock just happens. If not every test wants the mock, you drop auto: true and ask for the fixture explicitly in the parameters of the tests that need it.
70 Що таке HAR-файли і як використовувати page.routeFromHAR() для стабільних тестів без живого API? What are HAR files, and how do you use page.routeFromHAR() for stable tests with no live API?
МережаNetworkSenior▶

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

HAR stands for HTTP Archive — a JSON format that holds a complete record of every network request and response: URL, method, headers, request body, response body, status code, timing, all of it.

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

Why does that matter in tests? Imagine testing a page that depends on an external API — a product recommendation service, say, or a payment gateway. You have three problems: the external API can be unreachable in CI; it can return different data every time, which makes the tests non-deterministic; and every real call to a payment gateway costs you either money or a complicated test account.

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

HAR solves that elegantly: you record a real session against the live API once, and then Playwright replays those responses locally with no real requests at all. The browser thinks it's talking to the API — in fact it's getting saved responses out of a file.

  • Запис — routeFromHAR(path, { update: true }). Playwright робить реальні мережеві запити і одночасно записує їх у файл. Запускаєш один раз — отримуєш HAR файл.
  • Відтворення — routeFromHAR(path). Playwright перехоплює запити і замість реальних викликів повертає збережені відповіді. Швидко, стабільно, без залежності від зовнішніх сервісів.
  • Recording — routeFromHAR(path, { update: true }). Playwright makes the real network requests and writes them into the file as it goes. Run it once and you have a HAR file.
  • Replaying — routeFromHAR(path). Playwright intercepts the requests and returns the saved responses instead of making real calls. Fast, stable, no dependency on external services.

Різниця від page.route() з ручним fulfill(): HAR автоматично записує реальні відповіді з усіма headers, cookies і тілом — не треба руками описувати мок-відповідь. Особливо цінно для складних API де відповідь має специфічну структуру яку важко відтворити вручну.

How it differs from page.route() with a hand-written fulfill(): HAR records the real responses automatically, with all their headers, cookies and bodies — you don't have to describe the mock by hand. That is especially valuable for complex APIs whose response shape is hard to reproduce manually.

TypeScript — крок 1: запис HAR файлу (один раз з живим API)
TypeScript — step 1: recording the HAR file (once, against the live 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 — запит + відповідь
});
// Run this test ONCE to record real responses
// update: true — record mode: makes real requests AND saves them to a file
test('record HAR for the recommendations page', async ({ page }) => {
  await page.routeFromHAR(
    './test-data/recommendations.har', // where to save the file
    {
      update: true,       // record mode (not replay)
      url: '**/api/**',   // record only API requests, not static assets
    }
  );

  await page.goto('/product/trench-coat');
  // Playwright made real API requests and saved the responses to a .har file
  // The file is JSON with an entries array where each entry is request + response
});
TypeScript — крок 2: відтворення HAR у всіх наступних тестах
TypeScript — step 2: replaying the HAR in every test after that
// Тепер 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();
});
// Now the HAR file exists — use it for stable tests
// WITHOUT update: true — replay mode: intercepts requests and serves them from the file
test('recommendations block shows 4 products', async ({ page }) => {
  await page.routeFromHAR('./test-data/recommendations.har', {
    url: '**/api/**', // intercept only API — static assets go to the network
  });

  await page.goto('/product/trench-coat');
  // Requests to /api/** → responses from the HAR file (no real API)
  // Requests for static assets → real network

  await expect(page.locator('.recommendation-card')).toHaveCount(4);
  // Always 4 — the response is pinned in the HAR, the API is no longer called
});

test('payment form with a test token', async ({ page }) => {
  // Recorded the HAR once against the real payment gateway
  // Now tests make no real requests to the payment provider
  await page.routeFromHAR('./test-data/payment.har');
  await page.goto('/checkout/payment');
  await expect(page.locator('.payment-form')).toBeVisible();
});
TypeScript — налаштування що робити якщо запит не знайдений в HAR
TypeScript — configuring what happens when a request isn't in the HAR
// За замовчуванням якщо запит не знайдений в HAR — Playwright кидає помилку
// Можна змінити поведінку через fallback:
await page.routeFromHAR('./test-data/api.har', {
  url: '**/api/**',
  fallback: 'continue', // якщо не знайдено в HAR → зробити реальний запит
  // або 'abort' → заблокувати запит (за замовчуванням 'abort' → помилка)
});

// notFound: 'fallback' — більш явна назва для того самого
await page.routeFromHAR('./test-data/api.har', {
  notFound: 'fallback', // незнайомі запити йдуть в реальну мережу
});

// Корисно при поступовому переході: спочатку частина запитів з HAR,
// нові endpoint-и ще не записані — fallback дозволяє тестам продовжувати працювати
// By default, if a request isn't found in the HAR, Playwright throws an error
// You can change this via fallback:
await page.routeFromHAR('./test-data/api.har', {
  url: '**/api/**',
  fallback: 'continue', // if not found in HAR → make a real request
  // or 'abort' → block the request (default 'abort' → error)
});

// notFound: 'fallback' — a more explicit name for the same thing
await page.routeFromHAR('./test-data/api.har', {
  notFound: 'fallback', // unknown requests go to the real network
});

// Useful for gradual migration: some requests come from the HAR first,
// new endpoints aren't recorded yet — fallback keeps tests working
TypeScript — HAR через контекст (для всіх сторінок в тесті)
TypeScript — HAR at the context level (for every page in the test)
// 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');
});
// page.routeFromHAR() — only for this page
// context.routeFromHAR() — for ALL pages in this context
test('multi-page scenario with HAR', async ({ context }) => {
  // Every page opened in this context gets the HAR
  await context.routeFromHAR('./test-data/full-session.har', {
    url: '**/api/**',
  });

  const page1 = await context.newPage();
  const page2 = await context.newPage(); // also gets the HAR automatically

  await page1.goto('/products');
  await page2.goto('/cart');
});
💡 Практичний workflow з HAR:
1. Запускаєш тест з update: true один раз — HAR файл записаний
2. Комітиш HAR файл у репозиторій разом з тестами
3. В CI тести використовують HAR — жодних реальних запитів до зовнішніх API
4. Коли API змінилось — перезаписуєш HAR: знову запускаєш з update: true

HAR файл — це "знімок API" який живе в git разом з кодом тестів.
💡 The practical HAR workflow:
1. Run the test with update: true once — the HAR file is recorded
2. Commit the HAR file to the repository alongside the tests
3. In CI the tests use the HAR — no real requests to external APIs at all
4. When the API changes, re-record the HAR: run with update: true again

A HAR file is a "snapshot of the API" that lives in git next to the test code.
🔧 TypeScript / JS патерни в цьому прикладі await page.routeFromHAR('file.har', { update: true, url: '**/api/**' }) — options object; { update: true } — булевий прапор; notFound: 'fallback' — рядкове enum; glob pattern **/api/** — wildcard для URL matching.
🔧 TypeScript / JS patterns in this example await page.routeFromHAR('file.har', { update: true, url: '**/api/**' }) — an options object; { update: true } — a boolean flag; notFound: 'fallback' — a string enum; the glob pattern **/api/** — a wildcard for URL matching.
🗣 Як читати вголос HAR — це HTTP Archive, JSON файл який містить повний запис мережевої сесії: кожен запит, кожна відповідь, всі headers і тіло. page.routeFromHAR() має два режими. З update: true — це запис: Playwright робить реальні мережеві запити і паралельно зберігає їх у файл. Цей режим запускається один раз. Без update: true — це відтворення: Playwright перехоплює мережеві запити і замість реальних викликів повертає збережені відповіді з файлу, тобто зовнішнє API взагалі не викликається. Головна перевага перед ручним page.route() з fulfill() в тому що не треба описувати мок-відповідь вручну — HAR автоматично зберігає реальну відповідь з усіма деталями. Особливо цінно для складних API типу платіжних шлюзів де структура відповіді складна і важко відтворити її руками. Workflow простий: записуєш HAR один раз, комітиш у репозиторій, і тепер CI-тести не залежать від зовнішніх сервісів. Коли API змінилось — перезаписуєш HAR знову з update: true.
🗣 How to say it out loud HAR is HTTP Archive, a JSON file holding a complete record of a network session: every request, every response, all the headers and bodies. page.routeFromHAR() has two modes. With update: true it records: Playwright makes the real network requests and saves them into the file as it goes. You run that mode once. Without update: true it replays: Playwright intercepts the network requests and returns the saved responses from the file instead of making real calls, so the external API is never touched at all. The big win over hand-writing page.route() with fulfill() is that you don't have to describe the mock response yourself — HAR captures the real one with every detail. That's especially valuable for complex APIs like payment gateways, where the response shape is fiddly and hard to reproduce by hand. The workflow is simple: record the HAR once, commit it to the repo, and now the CI tests don't depend on external services. When the API changes you re-record it with update: true again.
71 Як тестувати WebSocket з'єднання? How do you test WebSocket connections?
МережаNetworkMiddle▶

WebSocket — це постійне двостороннє з'єднання між браузером і сервером. Використовується для real-time даних: чат, live ціни, сповіщення. Є два підходи: спостерігати за реальним WebSocket через page.on('websocket'), або перехоплювати і мокати через page.routeWebSocket() — новий API з Playwright 1.48+.

A WebSocket is a persistent two-way connection between the browser and the server. It is used for real-time data: chat, live prices, notifications. There are two approaches: watch the real WebSocket through page.on('websocket'), or intercept and mock it with page.routeWebSocket() — the newer API from Playwright 1.48+.

TypeScript — Підхід 1: спостерігаємо за реальним WebSocket
TypeScript — approach 1: watching the real WebSocket
test('real-time notifications via WebSocket', async ({ page }) => {
  const messages: string[] = [];

  // Підписуємось на WS події ДО навігації
  page.on('websocket', (ws) => {
    ws.on('framesent', frame => console.log('sent:', frame.payload));
    ws.on('framereceived', frame => {
      messages.push(frame.payload.toString());
    });
    ws.on('close', () => console.log('WS closed'));
  });

  await page.goto('/notifications');
  await page.request.post('/api/trigger-notification');

  await expect.poll(() => messages.length, { timeout: 10_000 })
    .toBeGreaterThan(0);

  const parsed = JSON.parse(messages[0]);
  expect(parsed.type).toBe('order_update');
});
test('real-time notifications via WebSocket', async ({ page }) => {
  const messages: string[] = [];

  // Subscribe to WS events BEFORE navigation
  page.on('websocket', (ws) => {
    ws.on('framesent', frame => console.log('sent:', frame.payload));
    ws.on('framereceived', frame => {
      messages.push(frame.payload.toString());
    });
    ws.on('close', () => console.log('WS closed'));
  });

  await page.goto('/notifications');
  await page.request.post('/api/trigger-notification');

  await expect.poll(() => messages.length, { timeout: 10_000 })
    .toBeGreaterThan(0);

  const parsed = JSON.parse(messages[0]);
  expect(parsed.type).toBe('order_update');
});
TypeScript — Підхід 2: мокаємо WebSocket через routeWebSocket() (Playwright 1.48+)
TypeScript — approach 2: mocking the WebSocket with routeWebSocket() (Playwright 1.48+)
test('WebSocket mock — live price updates', async ({ page }) => {
  // Перехоплюємо WebSocket з'єднання до вказаного URL
  await page.routeWebSocket('wss://prices.myshop.com/ws', async ws => {
    // ws — об'єкт WebSocketRoute

    // Слухаємо повідомлення від браузера до сервера
    ws.onMessage(message => {
      console.log('browser sent:', message);
    });

    // Надсилаємо мок-повідомлення від "сервера" браузеру
    ws.send(JSON.stringify({ price: '£1,990', sku: '8078560' }));
  });

  await page.goto('/product/trench-coat');
  await expect(page.locator('.live-price')).toHaveText('£1,990');
});

// Також доступно на рівні context — для всіх сторінок:
// await context.routeWebSocket('wss://prices.myshop.com/ws', handler);
test('WebSocket mock — live price updates', async ({ page }) => {
  // Intercept WebSocket connections to the given URL
  await page.routeWebSocket('wss://prices.myshop.com/ws', async ws => {
    // ws — a WebSocketRoute object

    // Listen to messages from browser to server
    ws.onMessage(message => {
      console.log('browser sent:', message);
    });

    // Send a mock message from the "server" to the browser
    ws.send(JSON.stringify({ price: '£1,990', sku: '8078560' }));
  });

  await page.goto('/product/trench-coat');
  await expect(page.locator('.live-price')).toHaveText('£1,990');
});

// Also available at context level — for all pages:
// await context.routeWebSocket('wss://prices.myshop.com/ws', handler);
💡 Коли який підхід:
page.on('websocket') — спостерігаємо за реальним WebSocket, сервер відповідає справжніми даними
page.routeWebSocket() — мокаємо WebSocket повністю, реальний сервер не потрібен. Корисно для тестування real-time UI без залежності від backend.
💡 Which approach when:
page.on('websocket') — watch the real WebSocket, the server answers with real data
page.routeWebSocket() — mock the WebSocket completely, no real server needed. Useful for testing real-time UI without depending on the backend.
🔧 TypeScript / JS патерни в цьому прикладі page.on('websocket', ws => {}) — підписка на подію; ws.on('framereceived', event => {}) — вкладена підписка; page.routeWebSocket(url, handler) — перехоплення WebSocket з'єднання (Playwright 1.48+); ws.onMessage() — слухаємо повідомлення від браузера; ws.send() — надсилаємо повідомлення браузеру.
🔧 TypeScript / JS patterns in this example page.on('websocket', ws => {}) — subscribing to an event; ws.on('framereceived', event => {}) — a nested subscription; page.routeWebSocket(url, handler) — intercepting a WebSocket connection (Playwright 1.48+); ws.onMessage() — listening to messages from the browser; ws.send() — sending a message to the browser.
🗣 Як читати вголос Є два підходи до тестування WebSocket в Playwright. Перший — спостерігати за реальним з'єднанням через page.on('websocket'): підписуємось на події ДО навігації, отримуємо об'єкт ws і слухаємо framereceived — кадри від сервера. Другий підхід — page.routeWebSocket() доданий в Playwright 1.48: перехоплює WebSocket з'єднання до вказаного URL і дає повний контроль — можна слухати повідомлення від браузера через ws.onMessage() і надсилати мок-відповіді через ws.send(). Реальний сервер не потрібен. Також доступно на рівні context через context.routeWebSocket() — спрацьовує для всіх сторінок в контексті.
🗣 How to say it out loud There are two ways to test WebSockets in Playwright. The first is to watch the real connection through page.on('websocket'): you subscribe before navigating, get the ws object and listen to framereceived — the frames coming from the server. The second is page.routeWebSocket(), added in Playwright 1.48: it intercepts the WebSocket connection to a given URL and gives you full control — you can listen to what the browser sends through ws.onMessage() and push mock responses back with ws.send(). No real server needed. It's available at the context level too, through context.routeWebSocket(), which then applies to every page in that context.
72 Як тестувати API requests напряму (без браузера) в Playwright? How do you test API requests directly (without a browser) in Playwright?
МережаNetworkMiddle▶

Не все треба тестувати через браузер. Якщо потрібно перевірити що API повертає правильні дані, або підготувати тестові дані перед UI тестом — fixture request робить HTTP запити напряму. Браузер взагалі не відкривається — запити виконуються через мережевий стек Node.js. Це в 10-20 разів швидше.

Not everything has to go through a browser. If you need to check that an API returns the right data, or to seed test data before a UI test, the request fixture makes HTTP calls directly. The browser never opens at all — the requests go through the Node.js network stack. That is 10-20 times faster.

⚠️ request fixture vs page.request — різні речі:

⚠️ The request fixture vs page.request — different things:

  • request (fixture) — незалежний APIRequestContext. Свої cookies, не пов'язаний з жодним BrowserContext. Для чистого API тестування.
  • page.request — прив'язаний до BrowserContext сторінки і використовує його cookies. Тобто якщо ти залогінена в браузері — page.request вже автентифікований. Для запитів "від імені" залогіненого юзера.
  • request (the fixture) — an independent APIRequestContext. Its own cookies, tied to no BrowserContext. For pure API testing.
  • page.request — bound to the page's BrowserContext and using its cookies. So if you are logged in in the browser, page.request is already authenticated. For calls made "on behalf of" the logged-in user.

Це класичне питання на Senior інтерв'ю. Практично: підготувати дані перед тестом → request. Перевірити API-виклик який робить залогінений юзер → page.request.

This is a classic Senior interview question. In practice: seeding data before a test → request. Checking an API call a logged-in user makes → page.request.

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

test('POST /api/users creates user', async ({ request }) => {
  const response = await request.post('/api/users', {
    headers: { 'Authorization': 'Bearer token123' },
    data: { name: 'John', email: 'john@test.com' },
  });

  await expect(response).toBeOK();
  expect(response.status()).toBe(201);

  const body = await response.json();
  expect(body).toMatchObject({
    name: 'John',
    email: 'john@test.com',
    id: expect.any(Number),
  });
});

// Глобальна авторизація для APIRequestContext
const apiContext = await request.newContext({
  baseURL: 'https://api.myapp.com',
  extraHTTPHeaders: { 'x-api-key': process.env.API_KEY! },
});
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),
  });
});

// Global auth for APIRequestContext
const apiContext = await request.newContext({
  baseURL: 'https://api.myapp.com',
  extraHTTPHeaders: { 'x-api-key': process.env.API_KEY! },
});
🔧 TypeScript / JS патерни в цьому прикладі const response = await request.get('/api/products') — await на методі що повертає Promise; await response.json() — другий await для читання тіла відповіді; expect(response).toBeOK() — перевірка статус коду через matcher; await response.text() — альтернативний метод для текстової відповіді.
🔧 TypeScript / JS patterns in this example const response = await request.get('/api/products') — awaiting a method that returns a Promise; await response.json() — a second await to read the response body; expect(response).toBeOK() — checking the status code through a matcher; await response.text() — the alternative method for a text response.
🗣 Як читати вголос Тест деструктурує request із fixtures — це APIRequestContext без жодного браузера. Викликаємо request.post, передаємо URL і об'єкт з headers і data — дані серіалізуються автоматично. expect(response).toBeOK() перевіряє що статус 2xx. response.status() — метод що повертає число, порівнюємо з 201. response.json() парсить тіло, toMatchObject перевіряє що об'єкт містить очікувані поля, expect.any(Number) — що поле id є числом будь-якого значення.
🗣 How to say it out loud The test destructures request out of the fixtures — that's an APIRequestContext with no browser involved at all. We call request.post, pass the URL and an object with headers and data — the data gets serialised for us. expect(response).toBeOK() checks the status is 2xx. response.status() is a method that returns a number, and we compare it with 201. response.json() parses the body, toMatchObject checks the object contains the fields we expect, and expect.any(Number) says the id field is a number of any value.
73 Як перехопити і валідувати GTM/dataLayer події? How do you intercept and validate GTM/dataLayer events?
МережаNetworkMiddle▶

GTM (Google Tag Manager) — це інструмент маркетингу який відстежує дії користувачів на сайті: клікнув кнопку, додав товар в кошик, оформив замовлення. Ці дані відправляються в Google Analytics, Facebook Pixel і інші системи аналітики.

GTM (Google Tag Manager) is a marketing tool that tracks what users do on the site: clicked a button, added an item to the cart, placed an order. That data is forwarded to Google Analytics, Facebook Pixel and other analytics systems.

dataLayer — це масив в JavaScript через який сторінка "розмовляє" з GTM. Коли юзер додає товар — код сайту пушить подію в dataLayer. GTM читає цей масив і відправляє дані в аналітику.

dataLayer is a JavaScript array through which the page "talks" to GTM. When a user adds an item, the site's code pushes an event into dataLayer. GTM reads that array and sends the data on to analytics.

// Так виглядає подія в dataLayer коли юзер додає товар:
window.dataLayer.push({
  event: 'add_to_cart',
  ecommerce: {
    items: [{ item_id: 'SKU-123', item_name: 'Trench Coat', price: 1990 }]
  }
});
// This is what the dataLayer event looks like when a user adds a product:
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.

Why test it? If a GTM event doesn't fire, the business never sees that the item went into the cart, can't count conversions and can't set up remarketing. That hits revenue directly. Which is why in e-commerce, testing GTM events is a critical part of QA.

TypeScript — ✅ простий спосіб: expect.poll()
TypeScript — ✅ the simple way: expect.poll()
test('fires add_to_cart GTM event', async ({ page }) => {
  await page.goto('/product/123');
  await page.getByRole('button', { name: 'Add to Cart' }).click();

  // expect.poll() ретраїть функцію поки вона не поверне truthy значення
  // Playwright сам повторює evaluate() поки dataLayer не матиме потрібну подію
  await expect.poll(async () => {
    return await page.evaluate(() =>
      (window as any).dataLayer?.find((e: any) => e.event === 'add_to_cart')
    );
  }).toMatchObject({
    event: 'add_to_cart',
    ecommerce: { items: [{ item_id: 'SKU-123' }] }
  });
});
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() retries the function until it returns a truthy value
  // Playwright keeps re-running evaluate() until dataLayer has the event
  await expect.poll(async () => {
    return await page.evaluate(() =>
      (window as any).dataLayer?.find((e: any) => e.event === 'add_to_cart')
    );
  }).toMatchObject({
    event: 'add_to_cart',
    ecommerce: { items: [{ item_id: 'SKU-123' }] }
  });
});
TypeScript — просунутий спосіб: перехоплення push() через Promise (коли потрібна синхронність)
TypeScript — the advanced way: intercepting push() through a Promise (when you need it synchronous)
// Використовується коли потрібно перехопити подію ДО того як 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' });
});
// Use this when you need to intercept the event BEFORE GTM processes it
// More complex, but guarantees we won't miss an instant event
test('fires add_to_cart GTM event', async ({ page }) => {
  await page.goto('/product/123');

  // Replace dataLayer.push with our own function BEFORE the click
  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 keeps working
      };
    })
  );

  await page.getByRole('button', { name: 'Add to Cart' }).click();
  const event = await dataLayerPromise;
  expect(event).toMatchObject({ event: 'add_to_cart' });
});
🔧 TypeScript / JS патерни в цьому прикладі await page.waitForFunction(() => {}) — передача стрілочної функції яка виконується в браузері; (window as any).dataLayer — type assertion; dl.some((e: any) => e.event === 'add_to_cart') — Array.some() з стрілочним callback; dl.find() — Array.find() повертає перший елемент що відповідає умові.
🔧 TypeScript / JS patterns in this example await page.waitForFunction(() => {}) — passing an arrow function that runs inside the browser; (window as any).dataLayer — a type assertion; dl.some((e: any) => e.event === 'add_to_cart') — Array.some() with an arrow callback; dl.find() — Array.find() returns the first element that matches.
🗣 Як читати вголос Викликаємо page.evaluate — це виконання коду всередині браузера, а не в Node.js. Всередині повертаємо new Promise, тобто проміс що зрезолвиться коли GTM надішле потрібну подію. Зберігаємо оригінальний dataLayer.push в змінну orig, потім перезаписуємо метод своєю функцією: вона перевіряє кожен push — якщо event дорівнює 'add_to_cart', резолвить проміс з цим об'єктом, і завжди викликає оригінальний orig щоб GTM продовжував працювати. Клік відбувається після того як перехоплювач встановлено, потім await dataLayerPromise чекає поки GTM спрацює.
🗣 How to say it out loud We call page.evaluate, which runs the code inside the browser rather than in Node.js. Inside it we return a new Promise — a promise that resolves when GTM pushes the event we're after. We keep the original dataLayer.push in a variable called orig, then overwrite the method with our own function: it inspects every push, and if the event equals 'add_to_cart' it resolves the promise with that object, and it always calls the original orig so GTM carries on working. The click happens after the interceptor is in place, and then await dataLayerPromise waits for GTM to fire.
▶ 8 6
74 Як ти дебажиш тест, що падає тільки в CI, а локально проходить? How do you debug a test that fails only in CI but passes locally?
DebugMiddle▶

Тест падає в CI але локально проходить — одна з найнеприємніших ситуацій. Зазвичай причина в різниці середовища: інший viewport, швидкість мережі, headless режим без GPU, або різний порядок при паралельному запуску.

A test that fails in CI but passes locally is one of the more unpleasant situations. The cause is usually a difference in environment: a different viewport, network speed, headless mode with no GPU, or a different ordering when tests run in parallel.

Режими trace — важливо знати всі:The trace modes — worth knowing all of them:

  • trace: 'on' — записує для кожного тесту (повільно, для дебагу)
  • trace: 'on' — records for every test (slow, for debugging)
  • trace: 'retain-on-failure' — записує для всіх, видаляє для успішних
  • trace: 'retain-on-failure' — records for everything, deletes the traces of the tests that passed
  • trace: 'on-first-retry' — найпоширеніший у production: записує тільки при першому retry. Не навантажує CI при нормальному прогоні
  • trace: 'on-first-retry' — the most common one in production: records only on the first retry. It puts no load on CI during a normal run
  • trace: 'off' — вимкнено
  • trace: 'off' — off
Workflow: Playwright Inspector, page.pause(), Trace Viewer
Workflow: Playwright Inspector, page.pause(), Trace Viewer
// 1. Локальна репродукція: запускаємо headless, як в CI (не headed!)
// npx playwright test login.spec.ts --headed=false --workers=1

// 2. Вмикаємо trace ЗАВЖДИ при retry (типове налаштування для CI)
// playwright.config.ts:
export default defineConfig({
  use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' },
});

// 3. Точкова зупинка прямо в коді тесту (працює тільки headed/--debug)
await page.pause(); // відкриває Playwright Inspector, можна крокувати вручну

// 4. Аналіз trace.zip з CI артефактів — без повторного запуску
// npx playwright show-trace trace.zip
// показує DOM-снепшоти, мережу, консоль і timeline КОЖНОЇ дії
// 1. Reproduce locally: run headless, like in CI (not headed!)
// npx playwright test login.spec.ts --headed=false --workers=1

// 2. ALWAYS enable trace on retry (the typical CI setting)
// playwright.config.ts:
export default defineConfig({
  use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' },
});

// 3. Breakpoint right in the test code (works only headed/--debug)
await page.pause(); // opens Playwright Inspector, lets you step through manually

// 4. Analyze trace.zip from CI artifacts — no need to rerun
// npx playwright show-trace trace.zip
// shows DOM snapshots, network, console and timeline of EVERY action
Інструментарій дебагу — codegen, Inspector, UI Mode
The debugging toolkit — codegen, Inspector, UI Mode
# ── Codegen: записує твої дії в браузері як код ────────────────
npx playwright codegen https://myshop.com
npx playwright codegen --target=typescript https://myshop.com
npx playwright codegen --device="iPhone 14" https://myshop.com
# ⚠️ Codegen генерує хорошу ОСНОВУ, а не готовий тест.
# Згенерований код майже завжди треба рефакторити: винести в Page Object,
# замінити крихкі локатори на семантичні, додати нормальні assertions.
# Основна цінність — підказує який локатор обрати для елемента.

# ── Playwright Inspector: покрокове виконання ──────────────────
PWDEBUG=1 npx playwright test login.spec.ts
# PWDEBUG=1 — найпоширеніший спосіб: відкриває Inspector,
# вимикає таймаути і запускає headed. Можна крокувати по діях,
# дивитись locator playground і правити селектори наживо.

npx playwright test --debug   # те саме через CLI флаг

# ── UI Mode: головна перевага — швидкий re-run ─────────────────
npx playwright test --ui
# Дозволяє перезапускати ОКРЕМІ тести без рестарту всього процесу —
# правиш код, тиснеш re-run, бачиш результат за секунду.
# Плюс watch mode, timeline, DOM-снепшоти і фільтри по тегах.
# ── Codegen: records your browser actions as code ──────────────
npx playwright codegen https://myshop.com
npx playwright codegen --target=typescript https://myshop.com
npx playwright codegen --device="iPhone 14" https://myshop.com
# ⚠️ Codegen produces a good STARTING POINT, not a finished test.
# The generated code almost always needs refactoring: move it into a Page Object,
# replace brittle locators with semantic ones, add proper assertions.
# Its main value — it suggests which locator to pick for an element.

# ── Playwright Inspector: step-by-step execution ───────────────
PWDEBUG=1 npx playwright test login.spec.ts
# PWDEBUG=1 — the most common way: opens Inspector,
# disables timeouts and runs headed. You can step through actions,
# use the locator playground and fix selectors live.

npx playwright test --debug   # same thing via a CLI flag

# ── UI Mode: the main win — fast re-runs ───────────────────────
npx playwright test --ui
# Lets you rerun INDIVIDUAL tests without restarting the whole process —
# edit the code, hit re-run, see the result in a second.
# Plus watch mode, timeline, DOM snapshots and tag filters.
TypeScript — page.pause(): точка зупинки прямо в коді
TypeScript — page.pause(): a breakpoint right in the code
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();
});
test('debugging a specific spot', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay' }).click();

  await page.pause();
  // ↑ Pauses execution and opens Playwright Inspector.
  // From there you can step manually, try locators in the playground,
  // and inspect the actual page state at that moment.
  // Works only in headed mode (--debug / PWDEBUG=1 / --headed).
  // ⚠️ Remember to remove it before committing — the test will hang in CI.

  await expect(page.locator('.order-confirmed')).toBeVisible();
});
💡 Типові причини "тільки в CI": інша швидкість мережі/рендеру (CI повільніший за локальну машину), інший viewport/headless-режим, паралелізм воркерів конкурує за ресурси, відсутні env-змінні/секрети, інша timezone або locale контейнера.
💡 Typical "only in CI" causes: different network/render speed (CI is slower than your machine), a different viewport or headless mode, parallel workers competing for resources, missing env vars or secrets, a different timezone or locale in the container.
🔧 TypeScript / JS патерни в цьому прикладі Переважно bash команди і конфігурація. PWDEBUG=1 npx playwright test — встановлення environment variable перед командою; --headed / --slowmo — CLI флаги; trace: 'on' — рядкове enum значення в конфіг об'єкті.
🔧 TypeScript / JS patterns in this example Mostly bash commands and configuration. PWDEBUG=1 npx playwright test — setting an environment variable in front of a command; --headed / --slowmo — CLI flags; trace: 'on' — a string enum value in a config object.
🗣 Як читати вголос Перший крок — спробувати відтворити локально в тих самих умовах, що й CI: headless-режим, а не headed, і з тим самим числом workers, бо паралелізм часто і є причиною — тести конкурують за CPU чи мережу, і race condition, який непомітний при одному worker, зʼявляється при шести. Якщо локально не відтворюється, я покладаюсь на артефакти з самого CI-прогону: налаштування trace: 'retain-on-failure' зберігає Playwright trace тільки для тестів, що впали, і його можна відкрити локально командою show-trace — це дає повний timeline, мережеві запити, DOM-снепшот до і після кожної дії, консольні логи, без потреби перезапускати щось у CI. Якщо я підозрюю конкретний момент у тесті, додаю page.pause() — це відкриває Playwright Inspector і дозволяє покроково виконувати дії вручну, дивлячись, що саме відбувається на сторінці. Найчастіші причини розбіжності CI проти локального — це швидкість рендеру, бо CI-контейнер повільніший, інший viewport за замовчуванням, або відсутні env-змінні, які локально підхоплюються з .env, а в CI мають бути явно прописані як secrets.
🗣 How to say it out loud The first step is to try reproducing it locally under the same conditions as CI: headless rather than headed, and with the same number of workers, because parallelism is often the cause — the tests compete for CPU or network, and a race condition that never shows up with one worker appears with six. If it won't reproduce locally, I lean on the artifacts from the CI run itself: setting trace: 'retain-on-failure' keeps the Playwright trace only for the tests that failed, and you can open it locally with show-trace — that gives you the full timeline, the network requests, a DOM snapshot before and after every action, and the console logs, without having to rerun anything in CI. If I suspect a specific moment in the test, I drop in page.pause(), which opens the Playwright Inspector and lets me step through the actions by hand and watch what's actually happening on the page. The most common reasons for CI diverging from local are render speed, because the CI container is slower, a different default viewport, or missing env vars that get picked up from .env locally but have to be set explicitly as secrets in CI.
75 Як зробити скриншот і відео в тесті? How do you take a screenshot and record video in a test?
DebugJunior▶

Playwright вміє автоматично робити скріншоти і записувати відео при падінні тесту — це налаштовується в конфізі. Але іноді потрібно зробити скріншот вручну в певний момент тесту — наприклад щоб прикріпити до звіту або порівняти візуально.

Playwright can take screenshots and record video automatically when a test fails — you set that up in the config. But sometimes you need a screenshot at a particular moment in the test by hand — to attach to a report, say, or to compare visually.

Важливо про відео: файл відео фіналізується після закриття BrowserContext, тому він стає доступним тільки після завершення тесту. Під час виконання тесту відео ще не є готовим файлом.

An important thing about video: the video file is finalised after the BrowserContext closes, so it only becomes available once the test has finished. While the test is still running the video is not a finished file yet.

fullPage: true — робить скріншот всієї сторінки включаючи частини поза viewport (потрібен скрол для перегляду). Без цього параметру знімається тільки видима область. Використовується для довгих сторінок або коли потрібно захопити весь контент.

fullPage: true — captures the whole page, including the parts outside the viewport (the parts you would have to scroll to). Without it you only get the visible area. Use it for long pages, or when you need the whole content in one image.

TypeScript
TypeScript
// Скриншот вручну
await page.screenshot({ path: 'screenshot.png', fullPage: true });

// Скриншот конкретного елементу
await page.locator('.product-card').screenshot({ path: 'card.png' });

// Відео та trace — в config:
use: {
  video: 'retain-on-failure',    // always | never | retain-on-failure
  screenshot: 'only-on-failure',
  trace: 'on-first-retry',
}

// Переглянути trace:
// npx playwright show-trace trace.zip
// Manual screenshot
await page.screenshot({ path: 'screenshot.png', fullPage: true });

// Screenshot of a specific element
await page.locator('.product-card').screenshot({ path: 'card.png' });

// Video and trace — in config:
use: {
  video: 'retain-on-failure',    // always | never | retain-on-failure
  screenshot: 'only-on-failure',
  trace: 'on-first-retry',
}

// View the trace:
// npx playwright show-trace trace.zip
🔧 TypeScript / JS патерни в цьому прикладі { path: 'screenshot.png', fullPage: true } — options object з булевим полем; await page.locator('.hero').screenshot() — method chaining: спочатку знаходимо елемент, потім робимо screenshot; use: { screenshot: 'only-on-failure' } — конфігурація через рядкові enum-значення.
🔧 TypeScript / JS patterns in this example { path: 'screenshot.png', fullPage: true } — an options object with a boolean field; await page.locator('.hero').screenshot() — method chaining: find the element first, then screenshot it; use: { screenshot: 'only-on-failure' } — configuration through string enum values.
🗣 Як читати вголос page.screenshot — метод на об'єкті page, передаємо об'єкт з path куди зберегти і fullPage: true щоб захопити всю сторінку, а не тільки viewport. Щоб зробити скриншот конкретного елементу — беремо локатор і викликаємо .screenshot() вже на ньому. В конфігу video: 'retain-on-failure' означає: записувати відео, але зберігати тільки якщо тест впав. trace: 'on-first-retry' — збираємо trace тільки при першому повторному запуску. Потім переглядаємо через CLI команду show-trace.
🗣 How to say it out loud page.screenshot is a method on the page object; we pass an object with path, where to save it, and fullPage: true to capture the whole page rather than just the viewport. To screenshot one specific element, take the locator and call .screenshot() on that instead. In the config, video: 'retain-on-failure' means record video but only keep it if the test failed. trace: 'on-first-retry' collects a trace only on the first retry. Then you open it with the show-trace CLI command.
76 Як налаштувати retry та обробити flaky тести? How do you configure retries and handle flaky tests?
DebugMiddle▶

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

A flaky test is one that passes sometimes and fails other times with no change to the code. The causes: a race condition, a dependency on an external service, an unstable environment.

Важливо: retries не виправляють flaky тести — вони лише дозволяють їм пройти повторно. Це механізм підвищення стабільності CI, а не спосіб усунення першопричини. Flaky тести необхідно аналізувати і виправляти. Retries використовуються як тимчасове рішення або для нестабільних зовнішніх залежностей.

Important: retries do not fix flaky tests — they only let them pass on a second attempt. It is a way to make CI more stable, not a way to remove the root cause. Flaky tests have to be analysed and fixed. Retries are a temporary measure, or something you use for genuinely unstable external dependencies.

Ізоляція при retry: перед кожною повторною спробою Playwright створює новий BrowserContext — це гарантує що retry виконується в повністю ізольованому середовищі, без стану від попередньої спроби.

Isolation on a retry: before every retry Playwright creates a new BrowserContext — which guarantees the retry runs in a completely isolated environment, with no state left over from the previous attempt.

TypeScript
TypeScript
// Глобально в config:
retries: process.env.CI ? 2 : 0,

// Для конкретного тесту:
test('flaky animation test', { retries: 3 }, async ({ page }) => {
  // ...
});

// Визначити що тест на retry:
test('...', async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    console.log(`Retry #${testInfo.retry}`);
    await page.reload(); // clean state
  }
});

// Позначити відомо нестабільний тест:
test.fixme('known flaky: ticket PW-123', async () => { /* */ });
test.skip('skip on CI', () => process.env.CI === 'true', async () => { /* */ });
// Globally in config:
retries: process.env.CI ? 2 : 0,

// For a specific test:
test('flaky animation test', { retries: 3 }, async ({ page }) => {
  // ...
});

// Detect that the test is on a retry:
test('...', async ({ page }, testInfo) => {
  if (testInfo.retry > 0) {
    console.log(`Retry #${testInfo.retry}`);
    await page.reload(); // clean state
  }
});

// Mark a known flaky test:
test.fixme('known flaky: ticket PW-123', async () => { /* */ });
test.skip('skip on CI', () => process.env.CI === 'true', async () => { /* */ });
🔧 TypeScript / JS патерни в цьому прикладі retries: process.env.CI ? 2 : 0 — тернарний оператор condition ? valueIfTrue : valueIfFalse; process.env.CI — доступ до змінної оточення (environment variable) через глобальний об'єкт Node.js; test.fail() — виклик методу без аргументів.
🔧 TypeScript / JS patterns in this example retries: process.env.CI ? 2 : 0 — the ternary operator, condition ? valueIfTrue : valueIfFalse; process.env.CI — reading an environment variable through the Node.js global object; test.fail() — a method call with no arguments.
🗣 Як читати вголос У конфігу retries: process.env.CI ? 2 : 0 — тернарний вираз: якщо в CI то два повтори, локально нуль. Для конкретного тесту — передаємо третім аргументом до test() об'єкт options з retries. У тілі тесту testInfo.retry — це поточний номер спроби, нуль при першому запуску. test.fixme — позначає тест як відомо зламаний, він буде скіпнутий і помічений у звіті окремо. test.skip з функцією-умовою — пропускає тест якщо умова true.
🗣 How to say it out loud In the config, retries: process.env.CI ? 2 : 0 is a ternary: two retries in CI, none locally. For one specific test you pass an options object with retries as the third argument to test(). Inside the test body, testInfo.retry is the current attempt number — zero on the first run. test.fixme marks a test as known-broken, so it gets skipped and flagged separately in the report. test.skip with a condition function skips the test when the condition is true.
77 Що таке race condition і як вона проявляється в тестах? What is a race condition and how does it show up in tests?
DebugSenior▶

Race condition — це коли результат програми залежить від того в якому порядку виконались дві дії, і цей порядок непередбачуваний.

A race condition is when the result depends on the order in which two things happened, and that order is unpredictable.

Простий приклад: два тести одночасно читають і змінюють одну змінну. Обидва прочитали 0 і обидва записали 1. Хто встиг другим — перезаписав результат першого. Очікувалось 2, отримали 1.

A simple example: two tests read and change the same variable at the same time. Both read 0 and both write 1. Whoever got there second overwrote the first one's result. You expected 2 and got 1.

У тестах race condition — головна причина flaky тестів. Тест то проходить, то падає — залежно від того наскільки швидко спрацював UI або мережа в конкретний момент.

In tests, race conditions are the number one cause of flakiness. The test passes sometimes and fails other times — depending on how fast the UI or the network happened to be at that moment.

TypeScript — race condition з UI і як її уникнути
TypeScript — a race condition with the UI, and how to avoid it
// ❌ 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 — the click may happen before the UI updates
await page.click('.add-to-cart');
expect(await page.locator('.cart-count').textContent()).toBe('1');
// cart-count may not be updated yet at this point — flaky test

// ✅ A web-first assertion waits on its own until the condition is true
await page.click('.add-to-cart');
await expect(page.locator('.cart-count')).toHaveText('1');
// Playwright retries the check until it sees '1' or times out
TypeScript — race condition з новою вкладкою і Promise.all як рішення
TypeScript — a race condition with a new tab, and Promise.all as the fix
// ❌ Race condition — click може відкрити вкладку ДО того
// як waitForEvent встановлено, і ми її пропустимо
await page.click('.open-link');
const newPage = await context.waitForEvent('page'); // вже пізно!

// ✅ Promise.all гарантує порядок:
// спочатку встановлюємо слухача, потім клікаємо
// навіть якщо вкладка відкриється миттєво — ми вже слухаємо
const [newPage] = await Promise.all([
  context.waitForEvent('page'), // спочатку підписуємось
  page.click('.open-link'),   // потім клікаємо
]);
// ❌ Race condition — the click may open the tab BEFORE
// waitForEvent is set up, and we'll miss it
await page.click('.open-link');
const newPage = await context.waitForEvent('page'); // already too late!

// ✅ Promise.all guarantees the order:
// first set up the listener, then click
// even if the tab opens instantly — we're already listening
const [newPage] = await Promise.all([
  context.waitForEvent('page'), // subscribe first
  page.click('.open-link'),   // then click
]);
💡 Коротко: race condition — "хто перший той і правий" але ти не контролюєш хто буде першим.

Як уникати в Playwright:
— Замість await locator.textContent() → await expect(locator).toHaveText()
— Замість "клік потім слухай" → Promise.all([слухай, клікай])
— Замість waitForTimeout(1000) → waitForSelector() або web-first assertion
💡 In short: a race condition is "first one wins" when you do not control who goes first.

How to avoid them in Playwright:
— Instead of await locator.textContent() → await expect(locator).toHaveText()
— Instead of "click, then listen" → Promise.all([listen, click])
— Instead of waitForTimeout(1000) → waitForSelector() or a web-first assertion
🔧 TypeScript / JS патерни в цьому прикладі Promise.all([p1, p2]) — запускає два Promise одночасно і чекає обидва; порядок в масиві визначає порядок підписки але не порядок виконання; const [newPage] = await Promise.all([...]) — деструктуризація масиву результатів; race condition — це про непередбачуваний порядок асинхронних операцій.
🔧 TypeScript / JS patterns in this example Promise.all([p1, p2]) — starts two Promises at once and waits for both; the order in the array decides the order of subscription, not the order of execution; const [newPage] = await Promise.all([...]) — destructuring the array of results; a race condition is about the unpredictable ordering of async operations.
🗣 Як читати вголос Race condition — це коли результат залежить від порядку виконання двох операцій, і цей порядок непередбачуваний. У тестах це головна причина flaky тестів — тест то проходить, то падає залежно від швидкості UI або мережі. Типовий приклад: клікнули і одразу перевіряємо textContent — але UI ще не встиг оновитись. Рішення: web-first assertions які самі ретраяться поки умова не стане true. Інший приклад: спочатку клікаємо посилання що відкриває нову вкладку, потім намагаємось підписатись на подію — але вкладка вже відкрилась і ми пропустили подію. Рішення: Promise.all де спочатку встановлюємо слухача, потім клікаємо — навіть якщо вкладка відкриється миттєво ми вже слухаємо.
🗣 How to say it out loud A race condition is when the outcome depends on the order two operations happen in, and that order isn't predictable. In tests it's the main cause of flakiness — the test passes or fails depending on how fast the UI or the network was. The classic example: you click and immediately check textContent, but the UI hasn't re-rendered yet. The fix is web-first assertions, which retry until the condition holds. Another one: you click a link that opens a new tab and then try to subscribe to the event, but the tab already opened and you missed it. The fix there is Promise.all, where you set the listener up first and click second — so even if the tab opens instantly, you're already listening.
78 Які класи помилок є в Playwright і коли використовувати try/catch у тестах? What error classes does Playwright have, and when should you use try/catch in a test?
DebugSenior▶

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

When a test fails, Playwright throws an exception. Not one universal "something went wrong", but a specific error class depending on what actually happened.

  • TimeoutError — найчастіший клас. Спрацьовує коли будь-який таймаут вийшов: action timeout на click(), expect timeout на toBeVisible(), або test timeout на весь тест.
  • TimeoutError — the most common class. It fires whenever any timeout ran out: the action timeout on click(), the expect timeout on toBeVisible(), or the test timeout on the whole test.
  • strict mode violation — локатор знайшов більше одного елемента і ти намагаєшся виконати дію над "одним".
  • strict mode violation — the locator matched more than one element and you are trying to act on "the one".
  • Target closed — сторінка або контекст були закриті під час виконання дії.
  • Target closed — the page or the context was closed while the action was running.
  • net::ERR_CONNECTION_REFUSED — браузер не зміг підключитись до URL. Або сервер не запущений, або неправильний порт.
  • net::ERR_CONNECTION_REFUSED — the browser could not connect to the URL. Either the server is not running or the port is wrong.

Навіщо взагалі try/catch якщо Playwright сам ловить помилки?Why use try/catch at all if Playwright catches errors itself?

Ти абсолютно права: у 95% випадків try/catch не потрібен. Playwright сам ловить помилку, записує у звіт, робить скріншот, і тест падає з червоним статусом. Це правильна поведінка. Але є три конкретні ситуації де без try/catch не обійтись:

You are quite right: 95% of the time you do not need try/catch. Playwright catches the error itself, writes it into the report, takes a screenshot, and the test goes red. That is the correct behaviour. But there are three specific situations where you cannot do without it:

Ситуація 1: елемент який може бути, а може не бути — і обидва варіанти правильніCase 1: an element that may or may not be there — and both are correct

Уяви банер "Безкоштовна доставка від 100$" — він зʼявляється тільки для певних юзерів або певних країн. Твій тест перевіряє щось інше (наприклад checkout), але хоче закрити цей банер якщо він є. Якщо написати просто click() без try/catch — тест впаде коли банера немає. А відсутність банера — це не баг, це нормально. Тут try/catch говорить "ця відсутність — очікувана, йдемо далі".

Picture a "Free delivery over $100" banner — it only shows for certain users or certain countries. Your test is checking something else (checkout, say) but wants to dismiss that banner if it is there. Write a plain click() with no try/catch and the test fails whenever the banner is absent. And an absent banner is not a bug, it is normal. Here try/catch says "this absence is expected, carry on".

Ситуація 2: ти тестуєш що щось НЕ працює — негативний сценарійCase 2: you are testing that something does NOT work — a negative scenario

Наприклад, неавторизований юзер не може зайти в /account. Ти навмисно хочеш побачити помилку — і треба це явно сказати Playwright через .rejects.toThrow(), інакше він сам зловить її і зафейлить тест.

An unauthenticated user cannot get into /account, for instance. You deliberately want to see the error — and you have to say so explicitly with .rejects.toThrow(), otherwise Playwright catches it itself and fails the test.

Ситуація 3: хочеш додати контекст до помилкиCase 3: you want to add context to the error

Тест все одно впаде — але замість загального "timeout exceeded" ти отримаєш конкретне повідомлення з URL і заголовком сторінки де це сталось. Корисно для дебагу в CI де немає можливості відкрити браузер вручну.

The test still fails — but instead of a generic "timeout exceeded" you get a specific message with the URL and the page title where it happened. Useful for debugging in CI, where you cannot open a browser by hand.

⚠️ Головне: try/catch в Playwright — це не спосіб "врятувати" тест від падіння. Це спосіб сказати "ця конкретна помилка є очікуваною і я знаю як її обробити". Якщо ти ловиш помилку і тест йде далі як ні в чому не бувало — це майже завжди антипатерн.

⚠️ The main point: try/catch in Playwright is not a way to "rescue" a test from failing. It is a way to say "this particular error is expected and I know how to handle it". If you catch an error and the test carries on as though nothing happened, that is almost always an anti-pattern.

TypeScript — читаємо повідомлення про помилку і розуміємо клас
TypeScript — reading the error message and working out the class
// 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() викликали занадто рано?
// TimeoutError: expect(locator).toBeVisible() timeout 5000ms exceeded
// → expect timeout. The element didn't appear within 5s
// → either the element isn't on the page at all, or it needs more time

// TimeoutError: page.click: Timeout 30000ms exceeded
// → action timeout. Element found but never became clickable
// → covered by another element? disabled? animating?

// Error: strict mode violation: locator('button') resolved to 5 elements
// → locator is too broad, matched 5 buttons instead of one
// → narrow it with .filter(), .first(), or a more specific selector

// Error: page.goto: net::ERR_CONNECTION_REFUSED
// → the server isn't responding on this port
// → check that baseURL in the config is correct and the server is running

// Error: Target closed
// → the page or context closed while you were interacting with it
// → popup closed on its own? context.close() called too early?
TypeScript — Ситуація 1: елемент який може бути, а може не бути
TypeScript — Case 1: an element that may or may not be there
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();
});
import { errors } from '@playwright/test';

test('checkout flow with optional banner', async ({ page }) => {
  await page.goto('/checkout');

  // ❌ Without try/catch — the test fails if there's no banner, even though that's fine
  // await page.locator('.promo-banner .close').click();

  // ✅ With try/catch — handle both cases
  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; // rethrow everything else!
    // no banner — that's fine, move on
  }

  // The test continues whether or not the banner is present
  await page.locator('button.place-order').click();
});
TypeScript — Ситуація 2: негативний сценарій — перевіряємо що дія кидає помилку
TypeScript — Case 2: a negative scenario — checking that an action throws
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('unauthenticated user cannot access /account', async ({ page }) => {
  // .rejects.toThrow() — tells Playwright: "I expect this to fail"
  // if it does NOT fail — the test fails (unexpected pass)
  await expect(
    page.goto('/account/orders')
  ).rejects.toThrow();

  // Or check for a specific error class
  await expect(
    page.locator('.admin-panel').click({ timeout: 1000 })
  ).rejects.toThrow(errors.TimeoutError);
});
TypeScript — Ситуація 3: додаємо корисний контекст до помилки перед падінням
TypeScript — Case 3: adding useful context to the error before failing
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; // решту перекидаємо без змін
  }
});
test('credit card payment', async ({ page }) => {
  await page.goto('/checkout');

  try {
    await page.locator('.payment-form').waitFor({ timeout: 10000 });
  } catch (e) {
    if (e instanceof errors.TimeoutError) {
      // The test still fails — but with a useful message for CI debugging
      throw new Error(
        `Payment form did not appear.
URL: ${page.url()}
Title: ${await page.title()}`
      );
    }
    throw e; // rethrow everything else unchanged
  }
});
TypeScript — три антипатерни і правильна версія
TypeScript — three anti-patterns and the right version
// ❌ Антипатерн 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;
  // тільки тут — обробляємо відсутність банера як нормальну ситуацію
}
// ❌ Anti-pattern 1: empty catch — silently swallows ALL errors
try {
  await page.locator('.buy-btn').click();
} catch (e) {
  // nothing — the test moves on even if the button wasn't found!
}

// ❌ Anti-pattern 2: catch logs and moves on — the error is hidden
try {
  await page.locator('.buy-btn').click();
} catch (e) {
  console.log('error, but moving on', e); 'error, but moving on'
}

// ❌ Anti-pattern 3: catching everything — even "Target closed" and network errors
try {
  await page.locator('.banner').waitFor({ timeout: 2000 });
} catch (e) {
  // no checks at all — swallows everything
}

// ✅ Correct: catch only the specific class, always rethrow the rest
try {
  await page.locator('.banner').waitFor({ timeout: 2000 });
} catch (e) {
  if (!(e instanceof errors.TimeoutError)) throw e;
  // only here — treat a missing banner as a normal case
}
💡 Золоте правило try/catch в тестах:
Перш ніж писати try/catch — запитай себе: "чи це легітимна опціональна поведінка або я намагаюсь обійти нестабільний тест?" Якщо друге — треба фіксити тест, а не ловити помилку. Якщо перше — лови тільки конкретний клас і завжди перекидай решту.
💡 The golden rule for try/catch in tests:
Before you write a try/catch, ask yourself: "is this legitimately optional behaviour, or am I working around an unstable test?" If it is the second, fix the test rather than catching the error. If it is the first, catch only the specific class and always rethrow the rest.
🔧 TypeScript / JS патерни в цьому прикладі try { ... } catch (e) { if (e instanceof errors.TimeoutError) { ... } else { throw e; } } — try/catch з instanceof перевіркою типу; instanceof — оператор перевірки типу об'єкту; throw e — повторне кидання помилки; new Error(`message`) — створення Error з template literal.
🔧 TypeScript / JS patterns in this example try { ... } catch (e) { if (e instanceof errors.TimeoutError) { ... } else { throw e; } } — try/catch with an instanceof type check; instanceof — the operator that checks an object's type; throw e — rethrowing the error; new Error(`message`) — creating an Error from a template literal.
🗣 Як читати вголос Playwright кидає різні класи помилок залежно від того що трапилось, і читання повідомлення про помилку — перший крок діагностики. TimeoutError з текстом expect(locator).toBeVisible() timeout 5000ms exceeded — це expect timeout, елемент не зʼявився за 5 секунд. TimeoutError з текстом page.click: timeout exceeded — це action timeout, елемент знайдений але не став клікабельним. strict mode violation — локатор знайшов кілька елементів. Target closed — сторінка закрилась під час взаємодії. Кожен клас — окремий діагноз і окреме рішення. Щодо try/catch: в Playwright тестах він потрібен рідко, бо помилки автоматично потрапляють у звіт. Три легітимних кейси: перехоплення опціонального елемента де відсутність — це нормально, перевірка що дія очікувано кидає помилку через .rejects.toThrow(), і логування деталей помилки перед перекиданням далі. Головні антипатерни — порожній catch і catch який просто логує і йде далі: обидва роблять тест "зеленим" з прихованою проблемою. Правило: якщо ловиш помилку — лови тільки конкретний клас через instanceof, і все що не цей клас — обов'язково перекидай через throw e.
🗣 How to say it out loud Playwright throws different error classes depending on what went wrong, and reading the message is the first step of the diagnosis. A TimeoutError saying expect(locator).toBeVisible() timeout 5000ms exceeded is an expect timeout — the element never appeared within five seconds. A TimeoutError saying page.click: timeout exceeded is an action timeout — the element was found but never became clickable. strict mode violation means the locator matched several elements. Target closed means the page closed mid-interaction. Each class is its own diagnosis and its own fix. As for try/catch, you rarely need it in Playwright tests, because errors end up in the report automatically. There are three legitimate cases: handling an optional element where absence is fine, checking that an action throws on purpose with .rejects.toThrow(), and logging details before rethrowing. The big anti-patterns are an empty catch and a catch that just logs and moves on — both leave you with a green test hiding a real problem. The rule is: if you catch, catch one specific class with instanceof, and always rethrow anything else with throw e.
79 Розкажи про складний flaky-баг, який ти діагностувала і виправила Tell me about a hard flaky bug you diagnosed and fixed
DebugSenior▶

Реальний кейс з E-commerce PLP: тест підрахунку товарів на PLP нестабільно падав в CI. Проблема виявилась у virtualized list — DOM рендерить тільки видиму частину, тому locator.count() повертав різні числа залежно від стану скролу. Рішення: перейти від DOM-count до GTM dataLayer як source of truth.

A real case from an e-commerce PLP: a test counting products on the PLP failed intermittently in CI. The problem turned out to be a virtualized list — the DOM only renders the visible part, so locator.count() returned different numbers depending on the scroll position. The fix: move from a DOM count to the GTM dataLayer as the source of truth.

Кейс: virtualization ламала DOM-based assertions на PLP
The case: virtualization was breaking DOM-based assertions on the PLP
// ❌ Було: рахуємо елементи в DOM напряму
const count = await page.locator('.product-tile').count();
expect(count).toBe(48); // flaky: virtual list рендерить лише видиму частину

// ✅ Стало: GTM dataLayer як source of truth
const events: DataLayerEvent[] = [];
await page.exposeFunction('captureEvent', (e: DataLayerEvent) => events.push(e));
await page.addInitScript(() => {
  window.dataLayer = window.dataLayer || [];
  const originalPush = window.dataLayer.push;
  window.dataLayer.push = function (...args: any[]) {
    (window as any).captureEvent(args[0]);
    return originalPush.apply(window.dataLayer, args);
  };
});

const plpViewEvent = events.find(e => e.event === 'plp_view');
expect(plpViewEvent?.items.length).toBe(48); // стабільно: GTM фіксує повний список незалежно від рендеру
// ❌ Before: counting elements in the DOM directly
const count = await page.locator('.product-tile').count();
expect(count).toBe(48); // flaky: virtual list renders only the visible part

// ✅ After: GTM dataLayer as the 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); // stable: GTM records the full list regardless of rendering
💡 Чому це сильна відповідь: показує не просто фікс, а зміну джерела правди — від крихкого UI-стану до бізнес-події. Це демонструє senior-мислення: симптом (flaky test) → корінна причина (virtualization рендерить тільки viewport) → архітектурне рішення, а не патч (retry/wait).
💡 Why this is a strong answer: it shows more than a fix — it shows the source of truth changing, from fragile UI state to a business event. That is senior thinking on display: symptom (a flaky test) → root cause (virtualization only renders the viewport) → an architectural decision rather than a patch (retry/wait).
🔧 TypeScript / JS патерни в цьому прикладі Поведінковий кейс без складного коду. Ключові терміни: flaky — нестабільний тест (то проходить то падає); virtualization — рендеринг тільки видимих елементів; dataLayer — масив подій Google Tag Manager; source of truth — єдине надійне джерело даних.
🔧 TypeScript / JS patterns in this example A behavioural question with no complicated code. The key terms: flaky — an unstable test (passes sometimes, fails others); virtualization — rendering only the visible elements; dataLayer — the Google Tag Manager event array; source of truth — the one reliable place the data comes from.
🗣 Як читати вголос Проблема була в тому, що список товарів на PLP — virtualized: у DOM в кожен момент є лише видимі картки, решта довантажується при скролі. Тест, який рахував .product-tile елементи напряму, був недетермінованим — результат залежав від того, скільки встигло відрендеритись. Рішення: замість перевірки DOM я перехоплюю dataLayer.push через page.addInitScript ще до завантаження сторінки, підміняючи метод push так, щоб кожна подія додатково передавалась у Playwright через exposeFunction. GTM фіксує бізнес-подію plp_view з повним масивом items одразу при завантаженні, незалежно від того, що зараз у viewport. Це дало стабільний source of truth і прибрало клас flaky-тестів повністю, а не одну симптоматичну дефектну точку.
🗣 How to say it out loud The problem was that the product list on the PLP is virtualized: at any given moment the DOM only holds the cards you can see, and the rest load in as you scroll. So a test counting .product-tile elements directly was non-deterministic — the number depended on how much had rendered by then. What I did instead of checking the DOM was intercept dataLayer.push with page.addInitScript, before the page loads, swapping the push method so every event is also handed to Playwright through exposeFunction. GTM fires a plp_view business event with the full items array as soon as the page loads, regardless of what's in the viewport right then. That gave us a stable source of truth and removed the whole class of flaky tests, not just the one symptom.
▶ 9 10
80 Що таке Page Object Model (POM) в Playwright? Покажи реалізацію. What is the Page Object Model (POM) in Playwright? Show an implementation.
АрхітектураArchitectureMiddle▶

POM — паттерн інкапсуляції UI-логіки в класах. Відокремлює "де знаходяться елементи" від "що тестуємо".

POM is a pattern for encapsulating UI logic in classes. It separates "where the elements are" from "what we are testing".

Але "одна Page Object на кожну сторінку" — не догма. Сучасний Playwright частіше використовує гнучкіші структури:

But "one Page Object per page" is not dogma. Modern Playwright leans towards more flexible structures:

  • Component Objects — клас на переюзабельний UI-компонент (хедер, кошик, картка товару), а не на сторінку. Компонент з'являється на десятках сторінок — Page Object на кожну дублював би його.
  • Fragments — частини складної сторінки як окремі об'єкти. PDP = Gallery + SizeSelector + AddToBag, а не один клас на 400 рядків.
  • Application Actions — методи на рівні бізнес-дії (loginAs(user), addToBag(sku)), які можуть ходити через API замість UI. Швидше і стабільніше для setup-кроків.
  • Component Objects — a class per reusable UI component (header, cart, product card) rather than per page. A component shows up on dozens of pages — a Page Object for each one would duplicate it every time.
  • Fragments — parts of a complex page as separate objects. A PDP is Gallery + SizeSelector + AddToBag, not one 400-line class.
  • Application Actions — methods at the level of a business action (loginAs(user), addToBag(sku)) that can go through the API instead of the UI. Faster and more stable for setup steps.

Component Object vs Helper — різниця:

Component Object vs Helper — the difference:

  • Component Object — має стан: тримає свій root локатор і всі елементи всередині нього. new CartWidget(page.locator('.cart')) — знає де він на сторінці.
  • Helper — stateless функція: отримує все аргументами і нічого не пам'ятає. formatPrice(1990), expectValidPrice(price). Немає прив'язки до DOM.
  • Component Object — has state: it holds its own root locator and every element inside it. new CartWidget(page.locator('.cart')) — it knows where it is on the page.
  • Helper — a stateless function: everything comes in as arguments and it remembers nothing. formatPrice(1990), expectValidPrice(price). No attachment to the DOM.

Композиція замість успадкування: не роби class ProductPage extends BasePage. Замість цього class ProductPage яка містить this.header = new Header(page) і this.cart = new CartWidget(page). Успадкування створює крихку ієрархію — зміна в BasePage ламає все нижче. Композиція дає гнучкість: сторінка бере тільки ті компоненти які на ній реально є. Це зараз рекомендація більшості Playwright-експертів.

Composition over inheritance: do not write class ProductPage extends BasePage. Write a class ProductPage that contains this.header = new Header(page) and this.cart = new CartWidget(page) instead. Inheritance builds a brittle hierarchy — a change in BasePage breaks everything below it. Composition stays flexible: a page pulls in only the components it actually has. This is what most Playwright practitioners recommend today.

pages/LoginPage.ts
pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitBtn: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitBtn = page.getByRole('button', { name: 'Sign in' });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitBtn.click();
    await this.page.waitForURL('**/dashboard');
  }
}

// Використання в тесті
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'pass123');
});
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');
  }
}

// Usage in a test
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'pass123');
});
🔧 TypeScript / JS патерни в цьому прикладі class ProductPage { constructor(page: Page) {} } — TypeScript клас з constructor; private readonly page: Page — модифікатори доступу (private) і незмінності (readonly); this.page — посилання на екземпляр класу; new ProductPage(page) — створення екземпляру через new.
🔧 TypeScript / JS patterns in this example class ProductPage { constructor(page: Page) {} } — a TypeScript class with a constructor; private readonly page: Page — access (private) and immutability (readonly) modifiers; this.page — a reference to the class instance; new ProductPage(page) — creating an instance with new.
🗣 Як читати вголос Оголошуємо клас LoginPage, він експортується. Три приватні readonly поля типу Locator — це просто посилання на елементи, вони ще нічого не шукають в DOM. У конструкторі отримуємо page через private readonly — це TypeScript shorthand що одночасно оголошує поле і присвоює. Ініціалізуємо локатори через page.getByLabel і page.getByRole. Метод login — асинхронний, приймає рядки, послідовно заповнює поля і клікає кнопку, потім чекає редірект. В тесті просто створюємо екземпляр через new LoginPage(page) і викликаємо методи.
🗣 How to say it out loud So we declare a class called LoginPage and export it. Three private readonly fields typed as Locator — they are just references to elements, nothing has touched the DOM yet. The constructor takes page through private readonly, which is the TypeScript shorthand that declares the field and assigns it in one go. Then we build the locators with page.getByLabel and page.getByRole. The login method is async, takes two strings, fills the fields in order, clicks the button and then waits for the redirect. In the test you just do new LoginPage(page) and call the methods.
81 Як використовувати test.step() для структурування тестів? How do you use test.step() to give a test some structure?
АрхітектураArchitectureMiddle▶

test.step() — це в першу чергу про структуру коду всередині тесту. Великий тест з 30 рядками дій перетворюється на читабельні логічні блоки — одразу видно що відбувається і в якому порядку.

test.step() is first and foremost about the shape of the code inside a test. A big test with thirty lines of actions turns into readable logical blocks — you see straight away what happens and in what order.

Бонус: ця ж структура автоматично відображається у звіті (Allure, HTML report) і в trace. При падінні одразу видно який крок впав — "Логін" чи "Оформлення замовлення" — без читання всього stack trace. Але головне — читабельність коду.

Bonus: that same structure shows up automatically in the report (Allure, HTML report) and in the trace. When something fails you see which step failed — "Login" or "Place order" — without reading the whole stack trace. But the main point is readability.

test.step() повертає значення — це часто пропускають. Те що повернув колбек, повертає і сам step:

test.step() returns a value — this one gets missed a lot. Whatever the callback returns, the step returns too:

const orderId = await test.step('Оформити замовлення', async () => {
  await page.getByRole('button', { name: 'Pay' }).click();
  return await page.locator('.order-id').textContent(); // ← повертається назовні
});
expect(orderId).toMatch(/^ORD-/); // використовуємо результат далі
const orderId = await test.step('Place order', async () => {
  await page.getByRole('button', { name: 'Pay' }).click();
  return await page.locator('.order-id').textContent(); // ← returned to the caller
});
expect(orderId).toMatch(/^ORD-/); // use the result further on

Чого test.step() НЕ робить: не впливає на виконання тесту, не створює свій таймаут, не ізолює стан, не є точкою retry. Це чисто організаційний і звітний механізм. Якщо крок впав — падає весь тест, як і без step. Поширена помилка на інтерв'ю — думати що step дає якусь ізоляцію чи окремий таймаут.

What test.step() does NOT do: it does not affect how the test runs, it does not create its own timeout, it does not isolate state, and it is not a retry point. It is a purely organisational and reporting mechanism. If a step fails, the whole test fails, exactly as it would without the step. A common interview mistake is to assume a step gives you some isolation or a timeout of its own.

TypeScript — ❌ без test.step: 30 рядків без структури
TypeScript — ❌ without test.step: 30 lines with no structure
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 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 more lines... hard to tell what's going on
});
TypeScript — ✅ з test.step: логічні блоки, одразу видно структуру
TypeScript — ✅ with test.step: logical blocks, the structure is visible at a glance
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();
  });
});

// Бонус: та сама структура у звіті при падінні:
// ✅ Логін
// ✅ Додати товар
// ❌ Оформити замовлення  ← одразу видно де впало
test('checkout flow', async ({ page }) => {
  await test.step('Log in', async () => {
    await page.goto('/login');
    await page.fill('#email', 'test@test.com');
    await page.fill('#password', '123');
    await page.click('.submit');
  });

  await test.step('Add product', async () => {
    await page.goto('/product/123');
    await page.click('.add-to-cart');
    await page.waitForURL('**/cart');
  });

  await test.step('Place order', async () => {
    await page.click('.checkout');
    await page.fill('#card', '4111111111111111');
    await page.click('.place-order');
    await expect(page.getByText('Order confirmed')).toBeVisible();
  });
});

// Bonus: the same structure in the report on failure:
// ✅ Log in
// ✅ Add product
// ❌ Place order  ← you see right away where it failed
🔧 TypeScript / JS патерни в цьому прикладі await test.step('назва', async () => {}) — test.step приймає рядок і async callback; всі дії всередині callback — це логічний крок; вкладені степи — callbacks всередині callbacks.
🔧 TypeScript / JS patterns in this example await test.step('name', async () => {}) — test.step takes a string and an async callback; everything inside the callback is one logical step; nested steps are callbacks inside callbacks.
🗣 Як читати вголос test.step() — це про структуру коду всередині тесту. Великий тест з багатьма рядками розбивається на іменовані логічні блоки — одразу видно що відбувається і в якому порядку: спочатку логін, потім додавання товару, потім оформлення. Код виконується однаково, але читати набагато простіше. Бонус: ця структура автоматично відображається у звіті і trace — при падінні одразу видно який крок впав, без читання всього stack trace.
🗣 How to say it out loud test.step() is about the shape of the code inside a test. A long test with a wall of lines breaks into named logical blocks, and you can see at a glance what happens and in what order: log in first, then add the item, then place the order. It runs exactly the same, it is just much easier to read. The bonus is that this structure shows up automatically in the report and in the trace — when something fails you can see which step failed without reading the whole stack trace.
82 Що таке data-driven тестування в Playwright? Як реалізувати? What is data-driven testing in Playwright? How do you implement it?
АрхітектураArchitectureMiddle▶

Data-driven тестування — це коли логіка тесту одна, але він запускається з різними вхідними даними. Замість копіювати один тест п'ять разів змінюючи тільки SKU або email — описуємо масив тестових випадків і генеруємо тести автоматично. Додати новий випадок = додати один рядок в масив.

Data-driven testing is when the test logic stays the same but the test runs with different inputs. Instead of copying one test five times and changing only the SKU or the email, we describe an array of cases and generate the tests automatically. Adding a new case means adding one line to the array.

TypeScript
TypeScript
const browsers = [
  { country: 'GB', currency: '£', locale: 'en-GB' },
  { country: 'DE', currency: '€', locale: 'de-DE' },
  { country: 'US', currency: '$', locale: 'en-US' },
];

for (const { country, currency, locale } of browsers) {
  test(`price displays in ${currency} for ${country}`, async ({ page }) => {
    await page.goto(`/${country.toLowerCase()}/product/123`);
    await expect(page.locator('.price'))
      .toContainText(currency);
  });
}

// Або через test.describe з параметром
const loginScenarios = [
  { desc: 'valid credentials', email: 'user@ok.com', pass: 'valid', expected: 'dashboard' },
  { desc: 'wrong password',   email: 'user@ok.com', pass: 'wrong', expected: 'error'  },
  { desc: 'unknown user',     email: 'nope@no.com', pass: 'pass',  expected: 'error'  },
];

for (const scenario of loginScenarios) {
  test(`login with ${scenario.desc}`, async ({ page }) => { /* ... */ });
}
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);
  });
}

// Or via test.describe with a parameter
const loginScenarios = [
  { desc: 'valid credentials', email: 'user@ok.com', pass: 'valid', expected: 'dashboard' },
  { desc: 'wrong password',   email: 'user@ok.com', pass: 'wrong', expected: 'error'  },
  { desc: 'unknown user',     email: 'nope@no.com', pass: 'pass',  expected: 'error'  },
];

for (const scenario of loginScenarios) {
  test(`login with ${scenario.desc}`, async ({ page }) => { /* ... */ });
}
🔧 TypeScript / JS патерни в цьому прикладі const testData = [{ ... }, { ... }] — масив об'єктів; testData.forEach(({ sku, name }) => { test(...) }) — forEach з деструктуризацією параметра callback; динамічна генерація тестів: test() викликається всередині forEach — тести створюються під час збору (collection phase).
🔧 TypeScript / JS patterns in this example const testData = [{ ... }, { ... }] — an array of objects; testData.forEach(({ sku, name }) => { test(...) }) — forEach destructuring the callback parameter; dynamic test generation: test() is called inside forEach, so the tests are created during the collection phase.
🗣 Як читати вголос Оголошуємо масив об'єктів з тестовими даними. Потім звичайний for...of цикл на TypeScript рівні — не Playwright-специфіка. Всередині циклу викликаємо test() з template literal назвою, куди підставляємо поточне значення з масиву. Playwright зареєструє стільки тестів скільки елементів у масиві — кожен з унікальною назвою. Це простіший підхід ніж параметризовані тести в інших фреймворках.
🗣 How to say it out loud We declare an array of objects with the test data. Then it is a plain for...of loop at the TypeScript level — nothing Playwright-specific about it. Inside the loop we call test() with a template literal title that drops in the current value from the array. Playwright registers as many tests as there are items, each with its own name. It is honestly simpler than the parameterised-test machinery you get in other frameworks.
83 Як спроєктувати стратегію test data governance для e-commerce платформи? How would you design a test data governance strategy for an e-commerce platform?
АрхітектураArchitectureSenior▶

Test data governance — це система правил яка гарантує що тести використовують тільки безпечні тестові дані і не впливають на production. В e-commerce це критично: тест може випадково списати реальні гроші або зіпсувати реальне замовлення якщо використає неправильний SKU або токен.

Test data governance is the set of rules that guarantees tests only touch safe test data and never affect production. In e-commerce that is critical: a test can charge real money or corrupt a real order if it picks up the wrong SKU or token.

На практиці стратегія тримається на кількох правилах. Кожен тип даних має власника: SKU, зарезервовані за suite, синтетичні покупці з окремим доменом email на кожен воркер, лише sandbox-токени платіжного провайдера. Тести перевіряють середовище до старту й відмовляються запускатися, якщо бачать production-хост або бойовий платіжний ключ. Дані створюються через API у фікстурі та прибираються після тесту, а залишки знищує нічна задача за TTL. Компроміс очевидний: спільні заздалегідь засіяні дані швидкі, але крихкі, а створення даних під кожен тест ізолює тести ціною часу виконання — тому критичні флоу отримують власні дані, а довідкові сутності лишаються спільними й заблокованими від змін.

In practice the strategy rests on a few rules. Every kind of data has an owner: SKUs reserved for a suite, synthetic customers with a separate email domain per worker, and only sandbox tokens from the payment provider. Tests check the environment before they start and refuse to run if they see a production host or a live payment key. Data is created through the API in a fixture and cleaned up after the test, and a nightly TTL job removes anything left behind. The trade-off is clear: shared pre-seeded data is fast but brittle, while creating data per test isolates tests at the cost of run time — so critical flows get their own data, and reference entities stay shared and locked against changes.

Кейс: Multi-region e-commerce — BlockedProductList у зовнішньому PIM (BUG-5521)
Case: multi-region e-commerce — BlockedProductList in an external PIM (BUG-5521)
// Проблема: тести падали, бо QA/мерчандайзери випадково
// міняли тестові товари в проді (ціна, наявність, статус)

// Рішення: ізольований allowlist-шар поверх зовнішнього PIM
type BlockedProductList = {
  sku: string;
  reservedFor: string; // suite/team, що володіє товаром
  lockedFields: (keyof ProductData)[];
};

async function assertProductIsGovernanceLocked(sku: string) {
  const entry = await getBlockedProductEntry(sku);
  if (!entry) {
    throw new Error(
      `SKU ${sku} не в BlockedProductList — додай товар в BUG-5521 перед використанням у тестах`
    );
  }
}
// Problem: tests kept failing because QA/merchandisers accidentally
// changed test products in prod (price, stock, status)

// Solution: an isolated allowlist layer on top of the external PIM
type BlockedProductList = {
  sku: string;
  reservedFor: string; // suite/team that owns the product
  lockedFields: (keyof ProductData)[];
};

async function assertProductIsGovernanceLocked(sku: string) {
  const entry = await getBlockedProductEntry(sku);
  if (!entry) {
    throw new Error(
      `SKU ${sku} is not in BlockedProductList — add the product in BUG-5521 before using it in tests`
    );
  }
}
💡 Додатково: Governance-шар запускається в pre-suite hook, а не покладається на дисципліну людей — некерована тестова data це основна причина "сьогодні працювало, завтра ні" на великих e-commerce платформах з 45 доменами.
💡 Also worth saying: the governance layer runs in a pre-suite hook rather than relying on people being disciplined — ungoverned test data is the number one cause of "it worked yesterday, it does not today" on large e-commerce platforms with 45 domains.
🔧 TypeScript / JS патерни в цьому прикладі const BLOCKED_SKUS = new Set(['sku1', 'sku2']) — Set зі списку рядків; BLOCKED_SKUS.has(sku) — O(1) пошук в Set; if (!BLOCKED_SKUS.has(sku)) { ... } — заперечення boolean; allowlist vs blocklist — патерни дозволу/заборони.
🔧 TypeScript / JS patterns in this example const BLOCKED_SKUS = new Set(['sku1', 'sku2']) — a Set built from a list of strings; BLOCKED_SKUS.has(sku) — O(1) lookup in a Set; if (!BLOCKED_SKUS.has(sku)) { ... } — negating a boolean; allowlist vs blocklist — allow/deny patterns.
🗣 Як читати вголос Корінна проблема test data governance на великих платформах — тестові дані не ізольовані від бойових процесів: мерчандайзер може змінити ціну чи прибрати товар з наявності, і тест, який про це не знає, почне падати без жодної зміни в коді. Рішення — окремий шар BlockedProductList у зовнішньому PIM: список SKU, зарезервованих за конкретними suite, з полем lockedFields, яке каже, які саме атрибути товару не можна чіпати. Функція assertProductIsGovernanceLocked запускається до того, як тест почне використовувати товар, і явно фейлить з людським повідомленням, якщо SKU не зареєстрований — це перетворює мовчазний flaky-фейл на явну й зрозумілу помилку конфігурації, яку легко виправити.
🗣 How to say it out loud The root problem with test data governance on a big platform is that test data is not isolated from the real business process — a merchandiser can change a price or take a product out of stock, and a test that knows nothing about it starts failing with no code change at all. What we did was put a separate layer on top of the external PIM, a BlockedProductList: a list of SKUs reserved for specific suites, with a lockedFields field that says which attributes of that product nobody is allowed to touch. Then assertProductIsGovernanceLocked runs before a test ever uses the product and fails loudly, with a human message, if the SKU is not registered. That turns a silent flaky failure into an obvious configuration error somebody can actually fix.
84 Як організувати стратегію тег-маркування та запускати підмножини тестів? How do you organise a tagging strategy and run subsets of the suite?
АрхітектураArchitectureSenior▶

Не всі тести треба запускати завжди. Smoke тести перевіряють найкритичніші функції за 5 хвилин, regression — повна перевірка за годину. Теги дозволяють розмітити тести за важливістю і запускати тільки потрібну підмножину.

Not every test needs to run every time. Smoke tests check the most critical functionality in five minutes; regression is the full sweep and takes an hour. Tags let you mark tests by importance and run only the subset you need.

Є два підходи для тегування — і між ними є різниця:

There are two approaches to tagging — and they are not the same:

Підхід 1 — тег в назві тесту:

Approach 1 — the tag in the test title:

test('@smoke перевірка кошика', async ({ page }) => {})
// Тег — частина рядка назви. Простіше але назва виглядає брудно.
test('@smoke cart check', async ({ page }) => {})
// The tag is part of the title string. Simpler, but the title looks messy.

Підхід 2 — тег в options (Playwright 1.42+):

Approach 2 — the tag in options (Playwright 1.42+):

test('перевірка кошика', { tag: ['@smoke', '@critical'] }, async ({ page }) => {})
// Тег зберігається окремо від назви — чистіше і семантично правильніше
test('cart check', { tag: ['@smoke', '@critical'] }, async ({ page }) => {})
// The tag is stored separately from the title — cleaner and semantically more correct

Практична різниця:

What actually differs:

  • Фільтрація — однакова: --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 тримає назву чистою
  • Filtering — identical: --grep @smoke works for both approaches
  • Reporting — approach 2 looks cleaner: the title has no tags in it, and the tags get their own column in the HTML report and in Allure
  • Programmatic access — approach 2 lets you read testInfo.tags as an array in a fixture or a reporter
  • TMS integration — tags (through testInfo.tags) can link tests to a Test Case ID in TestRail, Zephyr, Xray and other TMS tools via a custom reporter
  • Readability — approach 1 clutters the title, approach 2 keeps it clean

Коли що: підхід 1 — якщо проєкт старий і теги вже скрізь в назвах. Підхід 2 — новий проєкт або Playwright 1.42+. Чистіше і правильніше семантично.

Which one when: approach 1 if the project is old and the tags are already in every title. Approach 2 for a new project or Playwright 1.42+. Cleaner, and semantically the right place for them.

TypeScript
TypeScript
// Теги в назві тесту (офіційний підхід з Playwright 1.42+)
test('homepage loads @smoke @critical', async ({ page }) => {});
test('checkout flow @regression @payment', async () => {});
test('email validation @regression @forms', async () => {});

// Або через test options:
test('login', { tag: ['@smoke', '@auth'] }, async ({ page }) => {});

// Запуск по тегах:
// npx playwright test --grep @smoke           # smoke тести
// npx playwright test --grep "@smoke|@critical" # OR логіка
// npx playwright test --grep "(?=.*@smoke)(?=.*@auth)" # AND
// npx playwright test --grep-invert @slow    # виключити

// Стратегія для великого e-commerce проєкту:
// @smoke     → PR checks (5 хв)
// @critical  → post-deploy (15 хв)
// @regression → нічний запуск (3 год)
// Tags in the test title (official approach since Playwright 1.42+)
test('homepage loads @smoke @critical', async ({ page }) => {});
test('checkout flow @regression @payment', async () => {});
test('email validation @regression @forms', async () => {});

// Or via test options:
test('login', { tag: ['@smoke', '@auth'] }, async ({ page }) => {});

// Running by tag:
// npx playwright test --grep @smoke           # smoke tests
// npx playwright test --grep "@smoke|@critical" # OR logic
// npx playwright test --grep "(?=.*@smoke)(?=.*@auth)" # AND
// npx playwright test --grep-invert @slow    # exclude

// Strategy for a large e-commerce project:
// @smoke     → PR checks (5 min)
// @critical  → post-deploy (15 min)
// @regression → nightly run (3 h)
🔧 TypeScript / JS патерни в цьому прикладі test('@smoke перевірка', ...) — тег в рядку назви тесту; --grep '@smoke' — регулярний вираз в CLI; test.describe.configure({ tag: '@critical' }) — конфігурація рівня describe; --grep-invert — заперечення grep.
🔧 TypeScript / JS patterns in this example test('@smoke check', ...) — the tag inside the test title string; --grep '@smoke' — a regular expression on the CLI; test.describe.configure({ tag: '@critical' }) — configuration at describe level; --grep-invert — negating grep.
🗣 Як читати вголос Перший підхід: теги прямо в назві тесту через @-префікс — Playwright 1.42+ розпізнає їх автоматично. Другий підхід: третій аргумент test() — об'єкт options з полем tag як масив рядків. --grep приймає регулярний вираз: @smoke знайде всі тести з цим тегом в назві; пайп | — OR логіка; lookahead (?=.*@smoke)(?=.*@auth) — AND логіка, тест повинен мати обидва теги. --grep-invert — інвертує: запускаємо все крім позначеного.
🗣 How to say it out loud First approach: tags go straight into the test title with an @ prefix — Playwright 1.42 and up picks them up automatically. Second approach: a third argument to test(), an options object with a tag field that takes an array of strings. --grep takes a regular expression: @smoke matches every test carrying that tag; a pipe | gives you OR; a lookahead like (?=.*@smoke)(?=.*@auth) gives you AND, so the test has to carry both. And --grep-invert flips it — run everything except what is marked.
85 Що таке test.skip(), test.fixme(), test.fail() і test.slow()? Чим вони відрізняються між собою? What are test.skip(), test.fixme(), test.fail() and test.slow()? How do they differ from each other?
АрхітектураArchitectureMiddle▶

Коли є велика кодова база тестів, виникають ситуації які не вкладаються в бінарну логіку "тест проходить / тест падає": є відомий баг і тест правильно його описує але ми не хочемо його видаляти; тест нестабільний тільки на певній ОС; тест займає 3 хвилини і Playwright вбиває його раніше часу; тест описує поведінку яка ще не реалізована. Для всіх цих випадків є анотації — це не просто "вимкнути тест", кожна несе конкретну семантику і по-різному відображається у звіті Allure/HTML.

Once a test codebase gets big you hit situations that do not fit the binary "the test passes / the test fails": there is a known bug and the test describes it correctly but we do not want to delete it; a test is unstable only on one OS; a test takes three minutes and Playwright kills it early; a test describes behaviour that has not been built yet. Annotations exist for all of those — they are not just "switch the test off", each one carries a specific meaning and shows up differently in the Allure or HTML report.

  • test.skip() — тест пропускається, у звіті позначається як "skipped". Використовується коли тест тимчасово нерелевантний: фіча вимкнена для певного середовища, або тест залежить від чогось що ще не готове. Можна передати умову і причину.
  • test.fixme() — схожий на skip, але семантика інша: "цей тест зламаний і потребує виправлення". У звіті позначається окремо. Це сигнал команді що тут є технічний борг який треба вирішити.
  • test.fail() — найцікавіший і найменш очевидний. Каже Playwright: "я знаю що цей тест зараз падає, і це очікувано". Якщо тест впав — все ок, "expected failure". Але якщо тест раптом пройшов — це теж фейл, бо означає щось змінилось і анотацію треба прибрати.
  • test.slow() — не пропускає і не фейлить тест. Просто каже Playwright що цей тест повільний — і Playwright автоматично множить test timeout на 3. Не треба вручну рахувати скільки часу потрібно.
  • test.skip() — the test is skipped and marked "skipped" in the report. Use it when the test is temporarily irrelevant: the feature is off for this environment, or the test depends on something that is not ready. You can pass a condition and a reason.
  • test.fixme() — similar to skip, but the meaning is different: "this test is broken and needs fixing". It gets its own marker in the report. It is a signal to the team that there is debt here to deal with.
  • test.fail() — the most interesting and the least obvious. It tells Playwright: "I know this test fails right now, and that is expected." If the test fails, fine, that is an "expected failure". But if it suddenly passes, that is a failure too, because something changed and the annotation has to come off.
  • test.slow() — does not skip and does not fail the test. It just tells Playwright the test is slow, and Playwright triples the test timeout. No working out by hand how much time it needs.
TypeScript — test.skip(): умовне пропускання з поясненням
TypeScript — test.skip(): conditional skipping with a reason
// Простий 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 — окремий тікет');
  // ...
});
// Plain skip — always skipped
test('Apple Pay payment', async ({ page }) => {
  test.skip(); // skip — Apple Pay isn't implemented yet
  // ... test code ...
});

// Conditional skip — skipped only if the condition is true
test('drag-and-drop in the cart', async ({ page, isMobile }) => {
  test.skip(isMobile, 'drag-and-drop is not supported on mobile');
  // Runs on desktop; on mobile it's skipped with a clear reason
  await page.goto('/cart');
  // ...
});

// Skip by browserName — only for a specific browser
test('WebGL rendering', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'WebGL behaves differently in Firefox — separate ticket');
  // ...
});
TypeScript — test.fixme(): маркуємо технічний борг
TypeScript — test.fixme(): marking technical debt
// 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');
  // ...
});
// fixme — the test is broken and needs fixing
// Semantically different from skip: not "irrelevant" but "needs fixing"
test('size filter on PLP', async ({ page }) => {
  test.fixme(); // broken after the filters refactor — BUG-7742
  await page.goto('/women/dresses');
  // ...
});

// fixme also supports a condition
test('page transition animation', async ({ page, browserName }) => {
  test.fixme(browserName === 'webkit', 'animation is broken in Safari — BUG-4408');
  // ...
});
TypeScript — test.fail(): документуємо відомий баг
TypeScript — test.fail(): documenting a known bug
// 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.fail() — the most unusual annotation
// "I know this test fails. If it suddenly passes — tell me."
test('promo code applies to sale items', async ({ page }) => {
  test.fail(); // known bug BUG-3310, promo code is ignored for sale items

  await page.goto('/cart');
  await page.locator('#promo-input').fill('SAVE10');
  await page.locator('#promo-apply').click();

  // This assertion WILL FAIL (no discount applied) — and that's expected
  // If it suddenly passes — Playwright reports "unexpected pass" and fails the test
  await expect(page.locator('.discount-line')).toBeVisible();
});

// ⚠️ What happens in different scenarios with test.fail():
// test failed  → ✅ "expected failure" — all as expected
// test passed → ❌ "unexpected pass" — the bug got fixed, remove the annotation!
TypeScript — test.slow(): множимо timeout без ручних розрахунків
TypeScript — test.slow(): multiply the timeout without doing the maths
// 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.slow() — automatically triples the test timeout
// If config timeout = 30s, this test gets 90s
test('full checkout flow with 3D Secure', async ({ page }) => {
  test.slow(); // 3D Secure redirect takes up to 40 seconds

  await page.goto('/checkout');
  // ... long scenario with a redirect to the bank page ...
});

// slow also supports a condition
test('catalog loading', async ({ page, browserName }) => {
  test.slow(browserName === 'webkit', 'Safari takes twice as long to render the catalog');
  // ...
});

// Can be used at the describe level — every test in the block gets x3 timeout
test.describe('heavy e2e scenarios', () => {
  test.slow();

  test('test 1', async ({ page }) => { /* x3 timeout */ });
  test('test 2', async ({ page }) => { /* x3 timeout */ });
});
💡 Як вибрати правильну анотацію:
Тест тимчасово нерелевантний (фіча вимкнена, середовище не підтримує) → test.skip()
Тест правильний але зламаний, є тікет на виправлення → test.fixme()
Тест описує відомий баг, хочемо отримати сигнал коли його пофіксять → test.fail()
Тест правильно працює але займає багато часу → test.slow()
💡 How to pick the right annotation:
The test is temporarily irrelevant (feature off, environment does not support it) → test.skip()
The test is correct but broken, and there is a ticket for it → test.fixme()
The test describes a known bug and we want a signal when it gets fixed → test.fail()
The test works fine but takes a long time → test.slow()
🔧 TypeScript / JS патерни в цьому прикладі test.skip(condition, 'reason') — виклик з булевою умовою і рядком; test.fail() — виклик без аргументів; process.env.CI — environment variable; ({ browserName }) => browserName === 'firefox' — стрілочна функція з деструктуризацією що повертає boolean.
🔧 TypeScript / JS patterns in this example test.skip(condition, 'reason') — a call with a boolean condition and a string; test.fail() — a call with no arguments; process.env.CI — an environment variable; ({ browserName }) => browserName === 'firefox' — an arrow function with destructuring that returns a boolean.
🗣 Як читати вголос Анотації в Playwright — це спосіб передати команді контекст про стан тесту, а не просто вмикати або вимикати його. test.skip() пропускає тест і позначає його як "skipped" у звіті — він може приймати умову, наприклад isMobile або browserName === 'firefox', що робить його ситуативним а не глобальним. test.fixme() семантично відрізняється: це не "нерелевантно", а "зламано, потребує уваги" — окреме позначення у звіті нагадує команді що є технічний борг. test.fail() — найнетиповіший: він говорить "цей тест очікувано падає", і Playwright навпаки зафейлить його якщо він раптом пройде — це механізм автоматичного сповіщення коли баг пофіксили але анотацію забули прибрати, дуже корисно для документування відомих проблем. test.slow() не змінює результат тесту взагалі — просто множить test timeout на три, щоб Playwright не вбивав повільний але правильний тест раніше часу, без необхідності вручну рахувати скільки саме секунд потрібно.
🗣 How to say it out loud Annotations in Playwright are a way to tell the team what state a test is in, not just a switch that turns it on and off. test.skip() skips the test and marks it "skipped" in the report — and it takes a condition, something like isMobile or browserName === 'firefox', which makes it situational rather than a blanket off switch. test.fixme() means something different: not "irrelevant" but "broken, needs attention" — it gets its own marker in the report so the debt stays visible to the team. test.fail() is the odd one: it says this test is expected to fail, and Playwright will actually fail the run if it starts passing — which is how you find out the bug got fixed and nobody took the annotation off. It is genuinely useful for documenting known issues. And test.slow() does not change the outcome at all — it just triples the test timeout so a slow but correct test does not get killed early, and you do not have to sit there working out how many seconds it needs.
86 Як запустити тести в конкретному браузері? Як налаштувати playwright.config.ts? How do you run tests in a specific browser? How do you set up playwright.config.ts?
АрхітектураArchitectureJunior▶

Ми розібрали основні концепти Playwright — локатори, auto-waiting, fixtures, і чому ElementHandle застарів. Тепер подивимось на конфігурацію — де всі ці налаштування збираються разом.

We have been through the core Playwright concepts — locators, auto-waiting, fixtures, and why ElementHandle is legacy. Now let us look at the configuration, where all of those settings come together.

playwright.config.ts — це головний файл конфігурації де задаються налаштування для всього проєкту: де шукати тести, скільки workers запускати паралельно, які браузери використовувати, що робити при фейлі.

playwright.config.ts is the main configuration file where you set everything for the whole project: where to look for tests, how many workers run in parallel, which browsers to use, what to do on a failure.

Найважливіші поля конфігу які питають на інтерв'ю: testDir — де шукати тести, timeout — максимальний час на один тест, expect — налаштування assertions (timeout, toMatchSnapshot), reporter — формат звіту, use — глобальні налаштування для всіх тестів (baseURL, headless, trace, screenshot).

The config fields that come up in interviews: testDir — where the tests live, timeout — the maximum time for one test, expect — assertion settings (timeout, toMatchSnapshot), reporter — the report format, use — global settings for every test (baseURL, headless, trace, screenshot).

browserName vs channel — важлива різниця:

browserName vs channel — an important difference:

  • browserName: 'chromium' — використовує Playwright-специфічну збірку Chromium яка постачається разом з Playwright
  • channel: 'chrome' — використовує встановлений на машині Google Chrome (або 'msedge' для Edge). Відрізняються версією і поведінкою
  • browserName: 'chromium' — uses the Playwright-specific Chromium build that ships with Playwright
  • channel: 'chrome' — uses the Google Chrome installed on the machine (or 'msedge' for Edge). Different version, different behaviour

Ключова частина — projects. Це масив де кожен елемент — окремий браузер або девайс. Для кожного проєкту пишемо use: { ...devices['Desktop Chrome'] } — спред-оператор "розгортає" готовий профіль з viewport, userAgent, hasTouch тощо.

The key part is projects. It is an array where each entry is a browser or a device. For each project we write use: { ...devices['Desktop Chrome'] } — the spread operator "unpacks" a ready-made profile with viewport, userAgent, hasTouch and so on.

⚠️ Що насправді емулює device descriptor: viewport, userAgent, hasTouch, deviceScaleFactor, isMobile — і все. Це не справжній телефон. Не емулюються: продуктивність реального заліза, жести операційної системи, поведінка мобільного Safari чи WebView, нативна клавіатура, апаратні особливості. devices['iPhone 14'] у WebKit наближає рендеринг до Safari, але це все ще десктопний рушій з мобільним viewport. Емуляція ловить проблеми верстки і responsive — реальні девайси чи BrowserStack потрібні для решти.

⚠️ What a device descriptor actually emulates: viewport, userAgent, hasTouch, deviceScaleFactor, isMobile — and that is it. It is not a real phone. What is not emulated: the performance of real hardware, OS-level gestures, the behaviour of mobile Safari or a WebView, the native keyboard, hardware quirks. devices['iPhone 14'] under WebKit gets the rendering close to Safari, but it is still a desktop engine with a mobile viewport. Emulation catches layout and responsive problems — real devices or BrowserStack are what you need for the rest.

viewport: null — вимикає фіксований viewport, вікно браузера використовує свій реальний розмір. Зручно при локальному дебагу в headed режимі, коли хочеш розтягнути вікно вручну. У CI не використовується — там потрібен детермінований розмір.

viewport: null — turns the fixed viewport off, so the browser window uses its real size. Handy for local debugging in headed mode when you want to resize the window by hand. Not used in CI — there you want a deterministic size.

CLI команди: --grep фільтрує тести за назвою або тегом (@smoke, @critical), --ui відкриває інтерактивний режим де можна бачити тести і запускати їх вручну.

CLI commands: --grep filters tests by title or tag (@smoke, @critical), --ui opens the interactive mode where you can see the tests and run them by hand.

playwright.config.ts — з поясненням кожного поля
playwright.config.ts — with every field explained
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' } },
  ],
});
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',         // where to look for tests
  timeout: 30_000,             // max time per test (ms)
  retries: 2,                  // how many times to retry on failure
  workers: 4,                  // how many tests run in parallel
  reporter: [['html'], ['allure-playwright']],

  expect: {                     // assertion settings
    timeout: 5_000,            // timeout for web-first assertions
    toMatchSnapshot: { maxDiffPixelRatio: 0.01 },
  },

  use: {                        // global settings for all tests
    baseURL: 'https://staging.myapp.com',
    headless: true,
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },

  projects: [
    // browserName: 'chromium' — Playwright's own Chromium build
    // channel: 'chrome' — system Google Chrome (different version and behavior!)
    { 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 — arbitrary project data for reports:
    { name: 'chrome', use: { channel: 'chrome' }, metadata: { team: 'checkout' } },
    // dependencies — guarantees order: setup runs first
    { name: 'e2e', dependencies: ['setup'], use: { storageState: '.auth/user.json' } },
  ],
});
CLI команди
CLI commands
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 звіт
npx playwright test                          # all tests
npx playwright test --project=chromium       # one project
npx playwright test tests/login.spec.ts      # one file
npx playwright test --grep @smoke            # by tag
npx playwright test --grep-invert @slow      # everything except @slow
npx playwright test --ui                     # UI mode — interactive mode
npx playwright test --trace on               # record a trace for every test
npx playwright test --last-failed            # only tests that failed in the last run
npx playwright test --list                   # list tests without running them
npx playwright show-report                   # open the HTML report
🔧 TypeScript / JS патерни в цьому прикладі defineConfig({ ... }) — функція яка приймає конфігураційний об'єкт (configuration object pattern); ...devices['Desktop Chrome'] — spread operator: "розгортає" об'єкт і вставляє всі його поля; devices['Desktop Chrome'] — доступ до властивості об'єкта через квадратні дужки (bracket notation), еквівалент devices.'Desktop Chrome' але для ключів з пробілами.
🔧 TypeScript / JS patterns in this example defineConfig({ ... }) — a function that takes a configuration object (the configuration object pattern); ...devices['Desktop Chrome'] — the spread operator: it "unpacks" an object and inlines all of its fields; devices['Desktop Chrome'] — property access through square brackets (bracket notation), the equivalent of devices.'Desktop Chrome' but for keys with spaces in them.
🗣 Як читати вголос У конфігу: defineConfig — функція що приймає об'єкт налаштувань. testDir — де шукати тести. workers: 4 — скільки тестів паралельно. use — глобальні налаштування для всіх тестів: baseURL дозволяє писати goto('/') замість повного URL, headless: true — без вікна браузера. projects — масив браузерів, кожен спред-оператором бере девайс-профіль з бібліотеки Playwright. У CLI --grep фільтрує за регуляркою або тегом, --ui відкриває інтерактивний режим.
🗣 How to say it out loud In the config: defineConfig is a function that takes a settings object. testDir is where the tests live. workers: 4 is how many tests run in parallel. use holds the global settings for every test: baseURL lets you write goto('/') instead of the full URL, and headless: true means no browser window. projects is an array of browsers, and each one spreads in a device profile from Playwright's own library. On the CLI, --grep filters by regex or tag, and --ui opens the interactive mode.
87 Навіщо в конфізі іноді бачимо channel: 'chrome'? Чим це відрізняється від звичайного Chromium? Why do you sometimes see channel: 'chrome' in the config? How is that different from plain Chromium?
АрхітектураArchitectureMiddle▶

Playwright за замовчуванням завантажує власну збірку Chromium — браузерний рушій без Google-специфічних доповнень. Іноді тест потребує саме реального Google Chrome: якщо функціональність залежить від Google-акаунту, DRM або Chrome розширень.

By default Playwright downloads its own Chromium build — the browser engine without Google's additions. Sometimes a test needs actual Google Chrome: when the functionality depends on a Google account, on DRM, or on Chrome extensions.

channel: 'chrome' каже Playwright використати Chrome який вже встановлений на машині замість власного Chromium. Різниця: версія, поведінка з деякими API і доступ до Google-сервісів.

channel: 'chrome' tells Playwright to use the Chrome already installed on the machine instead of its own Chromium. What differs: the version, the behaviour of some APIs, and access to Google services.

playwright.config.ts — Chromium bundle vs реальний Chrome
playwright.config.ts — Chromium bundle vs real Chrome
// За замовчуванням: Playwright використовує власний бандл Chromium —
// окрема збірка, не той самий бінарник, що встановлений у користувача
projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },

  // ✅ Явно тестуємо РЕАЛЬНИЙ Google Chrome, не Playwright-бандл
  { name: 'chrome-stable', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },

  // Або Edge, теж через channel
  { name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
]
// By default Playwright uses its own bundled Chromium —
// a separate build, not the same binary the user has installed
projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },

  // ✅ Explicitly test REAL Google Chrome, not the Playwright bundle
  { name: 'chrome-stable', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },

  // Or Edge, also via channel
  { name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
]
💡 Коли це важливо: корпоративні білди Chrome з власними policy-розширеннями, DRM-залежний контент (Widevine), або коли треба гарантовано відтворити баг, який репортить користувач саме в "звичайному" Chrome, а не в Playwright-специфічному бандлі.
💡 When it matters: corporate Chrome builds with their own policy extensions, DRM-dependent content (Widevine), or when you need to reproduce a bug a user is reporting in "normal" Chrome specifically rather than in the Playwright bundle.
🗣 Як читати вголос За замовчуванням, коли ми пишемо просто 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.
🗣 How to say it out loud By default, when we just put chromium as the browser engine, Playwright runs its own Chromium build — the one the Playwright team tests and patches for their own purposes, not the binary a user would download from Google. Most of the time you would never notice the difference, since both sit on the same engine, but sometimes you do need the real production browser: DRM content through Widevine, which the plain Chromium bundle does not have, or a company running a corporate Chrome build with its own policy extensions where compatibility has to be checked against exactly that. For that you add channel: 'chrome' to the project config — it tells Playwright to use the stable Google Chrome actually installed on the machine instead of the bundled one, and there is channel: 'msedge' for Microsoft Edge the same way. The one catch is that the browser has to physically be on the machine running the tests, unlike the Playwright bundle, which npx playwright install puts there for you.
88 Що таке project dependencies в playwright.config.ts і як гарантувати порядок виконання? What are project dependencies in playwright.config.ts, and how do you guarantee execution order?
АрхітектураArchitectureSenior▶

Playwright запускає тести паралельно. Якщо auth.setup.ts і product.spec.ts запустяться одночасно — продуктовий тест почне виконуватись до того як логін завершився і файл збережений. Тест впаде бо user.json ще не існує.

Playwright runs tests in parallel. If auth.setup.ts and product.spec.ts start at the same time, the product test begins before the login has finished and the file has been written. The test fails because user.json does not exist yet.

Рішення — два окремих проєкти в конфізі з явним порядком через dependencies.

The fix is two separate projects in the config with an explicit order through dependencies.

Що таке проєкт в конфізі: це група тестів з певними налаштуваннями. Зазвичай це браузер — chromium, firefox, webkit. Але проєкт може бути і для інших цілей — наприклад тільки для запуску setup файлу.

What a project is in the config: a group of tests with a particular set of settings. Usually that is a browser — chromium, firefox, webkit. But a project can exist for other reasons too — for example, purely to run a setup file.

testMatch — говорить "цей проєкт запускає тільки файли що відповідають цьому паттерну".

testMatch — says "this project only runs files matching this pattern".

dependencies — говорить "не починай поки не виконається цей проєкт". Це гарантія порядку.

dependencies — says "do not start until this project has finished". That is the ordering guarantee.

playwright.config.ts — два проєкти з залежністю
playwright.config.ts — two projects with a dependency
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' → кожен тест починає вже залогіненим
projects: [
  // Project 1: login only
  {
    name: 'setup',
    testMatch: '**/auth.setup.ts', // run only this file
  },

  // Project 2: all main tests
  {
    name: 'chromium',
    dependencies: ['setup'],       // run 'setup' first, then me
    use: {
      storageState: '.auth/user.json' // reuse the saved session
    },
  },
],

// Execution order:
// 1. Playwright sees dependencies: ['setup']
// 2. Runs the 'setup' project first → auth.setup.ts → login → saves user.json
// 3. Only then runs 'chromium' → every test starts already logged in
Схема — як це виглядає в часі
Diagram — how it looks over time
// БЕЗ 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 вже є)
// WITHOUT dependencies — in parallel, product.spec.ts fails:
// auth.setup.ts   ──────────────► stored user.json
// product.spec.ts ──► failed (user.json doesn't exist yet!)

// WITH dependencies — guaranteed order:
// auth.setup.ts   ──────────────► stored user.json
//                                              ↓
// product.spec.ts                 ────────────► ok (user.json already exists)
💡 Аналогія: setup це як підготовча зміна на кухні — спочатку вони готують, потім відкривається ресторан (основні тести). dependencies — це замок на дверях ресторану який не відчиняється поки кухня не готова.
💡 Analogy: setup is the prep shift in a kitchen — they cook first, then the restaurant opens (the main tests). dependencies is the lock on the restaurant door that does not open until the kitchen is ready.
🔧 TypeScript / JS патерни в цьому прикладі dependencies: ['setup'] — масив рядків як список залежностей; testMatch: '**/auth.setup.ts' — glob паттерн: ** означає "в будь-якій папці"; конфігурація через масив об'єктів де кожен об'єкт — окремий проєкт з власними налаштуваннями.
🔧 TypeScript / JS patterns in this example dependencies: ['setup'] — an array of strings as a dependency list; testMatch: '**/auth.setup.ts' — a glob pattern where ** means "in any folder"; configuration as an array of objects, each object a separate project with its own settings.
🗣 Як читати вголос Playwright запускає тести паралельно — тому якщо просто покласти auth setup і звичайні тести в один проєкт, вони можуть стартувати одночасно і тест впаде бо файл з сесією ще не існує. Рішення: два окремих проєкти в конфізі. Перший — setup — має testMatch що вказує тільки на auth файл, тобто запускає виключно логін. Другий — chromium — має dependencies: ['setup'], що означає "не починай поки setup не завершився", і storageState що вказує на збережений JSON файл. Playwright сам дотримується цього порядку: спочатку повністю виконує setup проєкт, зберігає сесію, і тільки потім запускає основні тести — кожен з яких вже починає залогіненим.
🗣 How to say it out loud Playwright runs tests in parallel, so if you drop the auth setup and the normal tests into one project they can start at the same time and the test fails because the session file is not there yet. The fix is two separate projects in the config. The first one, setup, has a testMatch pointing only at the auth file, so it runs nothing but the login. The second one, chromium, has dependencies: ['setup'], which means do not start until setup has finished, plus storageState pointing at the saved JSON file. Playwright handles the ordering itself: it runs the setup project all the way through, saves the session, and only then starts the main tests — and every one of them begins already logged in.
89 globalSetup vs project dependencies для авторизації — який підхід зараз вважається best practice? globalSetup vs project dependencies for authentication — which one is considered best practice now?
АрхітектураArchitectureSenior▶

Є два способи виконати авторизацію один раз перед всіма тестами: історично перший — через globalSetup файл, сучасний — через dependencies між проєктами в конфізі. Важливо: globalSetup не deprecated — він досі повністю підтримується і має свої сценарії (наприклад підняти БД чи зовнішній сервіс до старту раннера). Але для авторизації Playwright Team зараз рекомендує project dependencies — вони гнучкіші і мають доступ до всіх можливостей Playwright включно з fixtures, trace і звітами.

There are two ways to authenticate once before all your tests: historically the first was a globalSetup file, and the modern one is dependencies between projects in the config. Important: globalSetup is not deprecated — it is still fully supported and it has its own use cases (spinning up a database or an external service before the runner starts, for example). But for authentication the Playwright team now recommends project dependencies — they are more flexible and they have access to everything Playwright offers, fixtures, traces and reports included.

playwright.config.ts — сучасний підхід через projects + dependencies
playwright.config.ts — the modern approach through projects + dependencies
// Історичний підхід: globalSetup — окремий файл, логін один раз перед усіма тестами
// playwright.config.ts: globalSetup: './global-setup.ts'
// Проблема: важко дебажити, нема трейсів/звітів для самого логіну,
// не масштабується на кілька ролей (admin/guest/premium)

// ✅ Сучасний підхід: окремий "setup" проєкт + dependencies
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
      dependencies: ['setup'], // чекає завершення setup-проєкту перед запуском
    },
  ],
});

// auth.setup.ts — звичайний тест, що зберігає сесію
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'test@test.com');
  await page.click('#submit');
  await page.context().storageState({ path: '.auth/user.json' });
});
// Legacy approach: globalSetup — a separate file, log in once before all tests
// playwright.config.ts: globalSetup: './global-setup.ts'
// Problem: hard to debug, no traces/reports for the login itself,
// doesn't scale to multiple roles (admin/guest/premium)

// ✅ Modern approach: a dedicated "setup" project + dependencies
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
      dependencies: ['setup'], // waits for the setup project to finish before starting
    },
  ],
});

// auth.setup.ts — a regular test that saves the session
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'test@test.com');
  await page.click('#submit');
  await page.context().storageState({ path: '.auth/user.json' });
});
💡 Чому новий підхід кращий: setup-логін тепер звичайний тест — з власним трейсом, скріншотом при фейлі, retry. Легко масштабується на кілька ролей через кілька setup-проєктів і dependencies.
💡 Why the newer approach is better: the setup login is now an ordinary test — with its own trace, a screenshot on failure, and retries. It scales easily to several roles through several setup projects and dependencies.
🔧 TypeScript / JS патерни в цьому прикладі dependencies: ['setup'] — масив рядків як залежності між проєктами; { name: 'setup', testMatch: '**/auth.setup.ts' } — project конфігурація з glob pattern; testMatch — glob для вибору файлів; відмінність від globalSetup: setup-проєкт це звичайний тест з fixtures.
🔧 TypeScript / JS patterns in this example dependencies: ['setup'] — an array of strings as dependencies between projects; { name: 'setup', testMatch: '**/auth.setup.ts' } — a project config with a glob pattern; testMatch — a glob for selecting files; the difference from globalSetup: a setup project is an ordinary test with fixtures.
🗣 Як читати вголос Історично перший підхід — globalSetup у конфізі. Це окрема функція, яка виконується один раз перед усім тестовим прогоном, поза звичайним test-раннером. Він не deprecated і досі корисний для інфраструктурних задач типу підняття бази. Але для авторизації є проблема: якщо логін там впаде — це не дає трейсу, скріншоту чи нормального звіту, як впав би звичайний тест, дебажити складно. Сучасний офіційний підхід — оголосити окремий setup проєкт у масиві projects, який матчиться за патерном файлу типу *.setup.ts, і всередині нього логін написаний як звичайний Playwright-тест: він заходить на сторінку логіну, заповнює форму, і в кінці зберігає сесію через context().storageState() у файл. Інші проєкти, наприклад chromium, вказують dependencies: ['setup'] — це означає, що Playwright гарантовано виконає setup-проєкт першим і тільки після його успішного завершення запустить основні тести, які вже підхоплюють збережений storageState через use. Перевага — це звичайний тест зі всіма інструментами дебагу, і легко масштабується: якщо потрібні кілька ролей, просто додаємо кілька setup-проєктів, наприклад admin-setup і guest-setup, кожен зі своїм storageState-файлом.
🗣 How to say it out loud Historically the first approach was globalSetup in the config. That is a separate function that runs once before the whole test run, outside the normal test runner. It is not deprecated and it is still useful for infrastructure work like bringing a database up. But for authentication it has a real problem: if the login fails in there you do not get a trace, a screenshot or a proper report the way you would from an ordinary test, so it is painful to debug. The modern official approach is to declare a separate setup project in the projects array, matched by a file pattern like *.setup.ts, and write the login inside it as a normal Playwright test: it goes to the login page, fills the form, and at the end saves the session with context().storageState() into a file. The other projects, chromium for instance, declare dependencies: ['setup'], which means Playwright is guaranteed to run the setup project first and only starts the main tests once it has passed — and they pick the saved storageState up through use. The win is that it is just a test, with all the debugging tools, and it scales easily: if you need several roles you add several setup projects, admin-setup and guest-setup say, each with its own storageState file.
▶ 10 14
90 Що таке CI runner і як GitLab запускає тести паралельно на різних машинах? What is a CI runner, and how does GitLab run tests in parallel on different machines?
ProductionSenior▶

Коли говоримо "окрема машина" в контексті CI — маємо на увазі runner. Runner — це процес (або контейнер, або віртуальна машина) який GitLab виділяє для виконання одного job.

When we say "a separate machine" in a CI context we mean a runner. A runner is a process (or a container, or a virtual machine) that GitLab allocates to execute one job.

Коли GitLab бачить parallel: 4 в конфігурації — він запускає 4 незалежних job. Кожен job виконується на своєму runner. Вони працюють паралельно і не знають один про одного.

When GitLab sees parallel: 4 in the configuration it starts 4 independent jobs. Each job runs on its own runner. They work in parallel and know nothing about each other.

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
GitLab CI → parallel: 4
├── Job 1 (Runner A) → --shard=1/4  → tests 1-750
├── Job 2 (Runner B) → --shard=2/4  → tests 751-1500
├── Job 3 (Runner C) → --shard=3/4  → tests 1501-2250
└── Job 4 (Runner D) → --shard=4/4  → tests 2251-3000
     ↑
  Each runner is an isolated process with its own memory and CPU

Що фізично є runner:

What a runner physically is:

  • Docker контейнер — найчастіше. Окремий ізольований контейнер на тому самому сервері
  • Віртуальна машина — в хмарі (AWS EC2, Google Cloud). GitLab автоматично запускає і зупиняє
  • Фізичний сервер — рідше, для специфічних вимог
  • A Docker container — most often. A separate isolated container on the same server
  • A virtual machine — in the cloud (AWS EC2, Google Cloud). GitLab starts and stops it automatically
  • A physical server — rarer, for specific requirements

Для тебе як QA — без різниці що фізично є runner. Головне що кожен job отримує ізольоване середовище з власною пам'яттю і CPU. Якщо runners Docker контейнери на одному сервері — вони все одно ізольовані і не впливають один на одного.

For you as a QA it makes no difference what a runner physically is. What matters is that each job gets an isolated environment with its own memory and CPU. If the runners are Docker containers on one server, they are still isolated and do not affect each other.

.gitlab-ci.yml — як GitLab розподіляє jobs по runners
.gitlab-ci.yml — how GitLab spreads jobs across runners
test:
  parallel: 4   # GitLab запустить 4 копії цього job одночасно
  script:
    # GitLab автоматично встановлює змінні для кожного job:
    # Job 1: CI_NODE_INDEX=1, CI_NODE_TOTAL=4
    # Job 2: CI_NODE_INDEX=2, CI_NODE_TOTAL=4
    # Job 3: CI_NODE_INDEX=3, CI_NODE_TOTAL=4
    # Job 4: CI_NODE_INDEX=4, CI_NODE_TOTAL=4
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

# Playwright сам визначає яку частину тестів запускати
# на основі номера shard і загальної кількості
test:
  parallel: 4   # GitLab runs 4 copies of this job at once
  script:
    # GitLab sets these variables for each job automatically:
    # 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 works out which slice of tests to run
# from the shard index and the total count
Схема: як виглядає pipeline в GitLab UI
Diagram: what the pipeline looks like in the GitLab UI
Pipeline #1234
├── install    ✅ 45s   (один job — встановлюємо залежності)
├── test                (4 паралельних job на різних runners)
│   ├── test 1/4  ✅ 4m  Runner A
│   ├── test 2/4  ✅ 4m  Runner B
│   ├── test 3/4  ✅ 4m  Runner C
│   └── test 4/4  ❌ 4m  Runner D  ← впав, артефакти збережені
└── merge-reports  (один job — збираємо звіти)

# Загальний час: 45s + 4m + merge = ~5 хвилин
# Замість 60 хвилин на одному runner
Pipeline #1234
├── install    ✅ 45s   (single job — install dependencies)
├── test                (4 parallel jobs on different 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  ← failed, artifacts saved
└── merge-reports  (single job — merge the reports)

# Total time: 45s + 4m + merge = ~5 minutes
# Instead of 60 minutes on a single runner
🔧 TypeScript / JS патерни в цьому прикладі $CI_NODE_INDEX і $CI_NODE_TOTAL — bash змінні оточення які GitLab встановлює автоматично; parallel: 4 — YAML конфігурація GitLab CI; кожен runner отримує окремий $CI_NODE_INDEX від 1 до N.
🔧 TypeScript / JS patterns in this example $CI_NODE_INDEX and $CI_NODE_TOTAL — bash environment variables that GitLab sets automatically; parallel: 4 — GitLab CI YAML configuration; each runner gets its own $CI_NODE_INDEX from 1 to N.
🗣 Як читати вголос Runner в GitLab CI — це ізольоване середовище де виконується один job. Зазвичай це Docker контейнер або віртуальна машина. Коли в конфігурації GitLab пишемо parallel: 4 — GitLab автоматично запускає 4 копії цього job на 4 окремих runners паралельно і встановлює для кожного змінні CI_NODE_INDEX від 1 до 4 і CI_NODE_TOTAL рівне 4. Playwright читає ці змінні через --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL і визначає яку частину тестів запускати. Для QA інженера неважливо що фізично є runner — важливо що кожен job ізольований, виконується паралельно з іншими, і якщо один впав — артефакти зберігаються і можна подивитись trace.
🗣 How to say it out loud A runner in GitLab CI is an isolated environment where one job runs. Usually that is a Docker container or a virtual machine. When we put parallel: 4 in the GitLab config, GitLab starts 4 copies of that job on 4 separate runners at the same time and sets CI_NODE_INDEX from 1 to 4 and CI_NODE_TOTAL to 4 for each of them. Playwright reads those through --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL and works out which slice of the suite to run. As a QA engineer you do not really care what a runner physically is — what matters is that each job is isolated, runs in parallel with the others, and if one fails the artifacts are kept so you can open the trace.
91 Як організувати CI/CD pipeline для Playwright тестів на GitLab CI? How do you set up a CI/CD pipeline for Playwright tests on GitLab CI?
ProductionSenior▶

CI/CD pipeline автоматично запускає тести при кожному push або merge request. Для Playwright в GitLab CI треба вирішити кілька питань: де зберігати браузери між запусками (кешування), як запускати тести паралельно (sharding), і як зберігати артефакти при падінні (trace, screenshots).

A CI/CD pipeline runs the tests automatically on every push or merge request. For Playwright on GitLab CI you have to answer a few questions: where to keep the browsers between runs (caching), how to run tests in parallel (sharding), and how to keep artifacts on failure (trace, screenshots).

.gitlab-ci.yml
.gitlab-ci.yml
stages: [install, test, report]

# Playwright Docker image вже містить Node.js v22 LTS і всі браузери
image: mcr.microsoft.com/playwright:v1.61.0-noble

variables:
  PLAYWRIGHT_BROWSERS_PATH: "/cache/playwright"

install:
  stage: install
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths: [node_modules/, /cache/playwright]
  script:
    - npm ci
    - npx playwright install --with-deps chromium

e2e:smoke:
  stage: test
  parallel: 4                   # 4 паралельні джоби
  script:
    - npx playwright test
        --grep @smoke
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [playwright-report/, test-results/]
    expire_in: 7 days

e2e:regression:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  parallel: 6
  script:
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [allure-results/]
stages: [install, test, report]

# The Playwright Docker image already ships Node.js v22 LTS and all browsers
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 parallel jobs
  script:
    - npx playwright test
        --grep @smoke
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [playwright-report/, test-results/]
    expire_in: 7 days

e2e:regression:
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  parallel: 6
  script:
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    when: always
    paths: [allure-results/]
💡 Sharding: --shard=1/4 запускає першу чверть тестів. Комбінація з parallel: 4 дає лінійне масштабування.
💡 Sharding: --shard=1/4 runs the first quarter of the suite. Combined with parallel: 4 it scales linearly.
🔧 TypeScript / JS патерни в цьому прикладі YAML синтаксис для GitLab CI. extends: .playwright-base — наслідування конфігурації; --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL — template string у bash з env variables; паралелізм через matrix strategy.
🔧 TypeScript / JS patterns in this example GitLab CI YAML syntax. extends: .playwright-base — configuration inheritance; --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL — a template string in bash with env variables; parallelism through a matrix strategy.
🗣 Як читати вголос YAML-файл конфігурації GitLab. stages — масив етапів що виконуються послідовно. variables — глобальні змінні середовища. Джоб install: cache зберігає node_modules і браузери між запусками по ключу гілки. script — список команд. Джоб e2e:smoke: parallel: 4 — GitLab запустить 4 копії цього джобу, кожна отримає змінні CI_NODE_INDEX і CI_NODE_TOTAL. Ці змінні підставляємо в --shard, тоді кожен джоб запускає свою чверть тестів. artifacts.when: always — зберігаємо звіти навіть якщо тести впали.
🗣 How to say it out loud It is a YAML config for GitLab. stages is the array of phases that run one after another. variables holds the global environment variables. In the install job, cache keeps node_modules and the browsers between runs, keyed on the branch, and script is just the list of commands. In the e2e:smoke job, parallel: 4 tells GitLab to run 4 copies of the job, and each copy gets CI_NODE_INDEX and CI_NODE_TOTAL. We feed those into --shard, so each job runs its own quarter of the suite. And artifacts.when: always means we keep the reports even when the tests fail — which is exactly when you need them.
92 Як масштабувати тести за допомогою sharding та повного паралелізму? How do you scale a suite with sharding and full parallelism?
ProductionSenior▶

Workers — це паралельність всередині однієї машини. Одна машина запускає N процесів і кожен виконує свою частину тестів.

Workers is parallelism inside a single machine. One machine starts N processes and each one runs its share of the tests.

Sharding — це паралельність між машинами. Ти розбиваєш всі тести на N рівних частин і запускаєш кожну частину на окремій машині в CI. Машини працюють незалежно і паралельно.

Sharding is parallelism across machines. You split the whole suite into N equal parts and run each part on a separate machine in CI. The machines work independently and in parallel.

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 паралельних тестів
Workers — parallelism WITHIN a single machine:
──────────────────────────────────────────────
One machine, workers: 4
  Worker 1 → test 1, test 5, test 9...
  Worker 2 → test 2, test 6, test 10...
  Worker 3 → test 3, test 7, test 11...
  Worker 4 → test 4, test 8, test 12...

Sharding — parallelism ACROSS machines in CI:
──────────────────────────────────────────────
Machine 1: --shard=1/4  → tests 1-750
Machine 2: --shard=2/4  → tests 751-1500
Machine 3: --shard=3/4  → tests 1501-2250
Machine 4: --shard=4/4  → tests 2251-3000

Combined: 4 machines × 4 workers = 16 tests in parallel

Результат: 3000 тестів замість 60 хвилин на одній машині — 4 хвилини на 4 машинах.

The result: 3000 tests that took 60 minutes on one machine come back in 4 minutes across 4 machines.

Як це працює в GitLab CI: кожен shard — це окремий CI job. Вони запускаються паралельно. Після завершення всіх jobs — збираємо звіти разом.

How that works in GitLab CI: each shard is a separate CI job. They run in parallel. Once every job has finished, we merge the reports.

Важливо: sharding і workers — незалежні рівні паралелізму. Sharding ділить тести між машинами. Workers ділять тести між процесами на одній машині. Можна використовувати разом або окремо.

Important: sharding and workers are independent levels of parallelism. Sharding splits the tests across machines. Workers split the tests across processes on one machine. Use them together or separately.

Термінал — запуск shard вручну
Terminal — running a shard by hand
# Запускаємо першу чверть всіх тестів
npx playwright test --shard=1/4

# Запускаємо другу чверть
npx playwright test --shard=2/4

# Кожна команда виконується на своїй машині паралельно
# Playwright сам рівномірно розподіляє тести між shards
# Run the first quarter of all tests
npx playwright test --shard=1/4

# Run the second quarter
npx playwright test --shard=2/4

# Each command runs on its own machine in parallel
# Playwright spreads tests evenly across shards on its own
playwright.config.ts — конфігурація для великого сьюту
playwright.config.ts — configuration for a large suite
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 паралельних тести одночасно
export default defineConfig({
  workers: process.env.CI ? 6 : 2,     // 6 workers on the CI machine, 2 locally
  fullyParallel: true,                  // each test in its own worker
  retries: process.env.CI ? 2 : 0,      // retries only in CI
});

// In CI we run 4 parallel jobs:
// 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 machines × 6 workers = 24 tests running in parallel
.gitlab-ci.yml — паралельні shard jobs
.gitlab-ci.yml — parallel shard jobs
test:
  parallel: 4              # GitLab запустить 4 копії цього job паралельно
  script:
    - npx playwright test
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
        --workers=6
  artifacts:
    when: always
    paths:
      - playwright-report/  # кожен shard зберігає свій звіт

# Після всіх 4 jobs — збираємо звіти разом:
merge-reports:
  needs: [test]
  script:
    - npx playwright merge-reports ./playwright-report
test:
  parallel: 4              # GitLab runs 4 copies of this job in parallel
  script:
    - npx playwright test
        --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
        --workers=6
  artifacts:
    when: always
    paths:
      - playwright-report/  # each shard saves its own report

# After all 4 jobs — merge the reports:
merge-reports:
  needs: [test]
  script:
    - npx playwright merge-reports ./playwright-report
🔧 TypeScript / JS патерни в цьому прикладі process.env.CI ? 6 : 2 — тернарний оператор для різних значень в CI vs локально; $CI_NODE_INDEX/$CI_NODE_TOTAL — змінні оточення GitLab CI для визначення номера поточного shard; parallel: 4 — YAML конфігурація GitLab яка автоматично встановлює CI_NODE_INDEX від 1 до 4.
🔧 TypeScript / JS patterns in this example process.env.CI ? 6 : 2 — a ternary for different values in CI vs locally; $CI_NODE_INDEX/$CI_NODE_TOTAL — GitLab CI environment variables that identify the current shard; parallel: 4 — GitLab YAML configuration that sets CI_NODE_INDEX from 1 to 4 automatically.
🗣 Як читати вголос Sharding і workers — це два незалежних рівні паралелізму. Workers — це паралельність всередині однієї машини: одна машина запускає кілька процесів одночасно. Sharding — це паралельність між машинами: ти розбиваєш всі тести на N рівних частин через прапор --shard=1/4, --shard=2/4 і так далі, і кожна частина виконується на окремій машині в CI. Playwright сам рівномірно розподіляє тести між shards. На практиці комбінуємо обидва рівні: наприклад 4 CI machines кожна з 6 workers дає 24 паралельних тести одночасно. 3000 тестів що займали 60 хвилин на одній машині виконуються за 4 хвилини. В GitLab CI це конфігурується через parallel: 4 в yaml — GitLab автоматично встановлює змінні CI_NODE_INDEX і CI_NODE_TOTAL для кожного job. Якщо просять показати це в конфігу, проговорюю так: workers — кількість паралельних процесів в межах однієї машини. fullyParallel: true — кожен окремий тест запускається в своєму воркері, без цього тести в одному файлі йдуть послідовно. test.describe.configure({ mode: 'serial' }) — переводить конкретний describe-блок назад в послідовний режим. Sharding — це розподіл між різними машинами або CI-джобами: --shard=2/4 означає "запусти другу чверть з чотирьох". merge-reports — CLI команда що об'єднує blob-звіти від усіх шардів в один HTML.
🗣 How to say it out loud Sharding and workers are two independent levels of parallelism. Workers is parallelism inside one machine: one machine running several processes at once. Sharding is parallelism between machines: you split the whole suite into N equal parts with the --shard=1/4, --shard=2/4 flags and so on, and each part runs on a separate machine in CI. Playwright does the splitting itself, evenly. In practice you combine both — say 4 CI machines with 6 workers each, which gives you 24 tests running at the same time. 3000 tests that took 60 minutes on one machine come back in about 4. In GitLab CI you set it up with parallel: 4 in the YAML, and GitLab sets CI_NODE_INDEX and CI_NODE_TOTAL for each job automatically. If they ask me to walk through the config, I put it like this: workers is the number of parallel processes on a single machine. fullyParallel: true means every individual test gets its own worker — without it, the tests inside one file run one after another. test.describe.configure({ mode: 'serial' }) puts a specific describe block back into sequential mode. Sharding is the split across machines or CI jobs: --shard=2/4 means "run the second quarter of four". And merge-reports is the CLI command that stitches the blob reports from every shard back into one HTML report.
TypeScript
TypeScript
// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 6 : 2,     // паралельних воркерів
  fullyParallel: true,                  // кожен тест в своєму воркері
});

// Для тестів що мають бути sequential:
test.describe.configure({ mode: 'serial' }); // в describe-блоці

// CLI — розбиваємо на 4 шарди між машинами/джобами:
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
// npx playwright test --shard=3/4
// npx playwright test --shard=4/4

// Об'єднати HTML звіти після sharding:
// npx playwright merge-reports --reporter html ./blob-reports

// Приклад для великого проєкту:
// 4000 тестів / 6 workers = ~15 хвилин → full regression
// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 6 : 2,     // parallel workers
  fullyParallel: true,                  // each test in its own worker
});

// For tests that must run sequentially:
test.describe.configure({ mode: 'serial' }); // inside a describe block

// CLI — split into 4 shards across machines/jobs:
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
// npx playwright test --shard=3/4
// npx playwright test --shard=4/4

// Merge HTML reports after sharding:
// npx playwright merge-reports --reporter html ./blob-reports

// Example for a large project:
// 4000 tests / 6 workers = ~15 minutes → full regression
💡 Приклад: ~4000 тестів на 6 воркерах = ~3 години без sharding. Sharding на 3 CI jobs × 6 workers = ~1 година.
💡 Example: ~4000 tests on 6 workers is about 3 hours without sharding. Sharded across 3 CI jobs × 6 workers it is about 1 hour.
🔧 TypeScript / JS патерни в цьому прикладі --shard=1/4 — CLI аргумент у форматі дробу; fullyParallel: true — булева конфігурація; test.describe.configure({ mode: 'serial' }) — виклик методу на вкладеному об'єкті.
🔧 TypeScript / JS patterns in this example --shard=1/4 — a CLI argument in fraction form; fullyParallel: true — a boolean setting; test.describe.configure({ mode: 'serial' }) — calling a method on a nested object.
93 Які репортери є в Playwright? Як налаштувати Allure і кілька репортерів одночасно? What reporters does Playwright have? How do you set up Allure and several reporters at once?
ProductionMiddle▶

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

Once the tests have run you need something to read. Not a terminal log where everything is mixed together, but a structured report where you can see what failed, why, with which screenshot and at which step. Playwright has several levels of reporting.

Вбудовані репортери з коробки:

The built-in reporters:

  • 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 машин згенерувала б окремий звіт на чверть тестів — і побачити повну картину прогону було б неможливо.
  • list — the simplest one, prints a line per test to the terminal. Good for local work where you want to watch progress.
  • html — generates a full HTML report with filters, screenshots, video and traces. You open it in a browser with npx playwright show-report. Playwright generates it automatically after a run.
  • json — writes the results out as a JSON file. Useful for integrating with other tools or parsing results in CI scripts.
  • junit — the XML format most CI systems understand (Jenkins, GitLab, GitHub Actions). They parse it automatically and show the test summary right in the pipeline UI.
  • dot — minimal: a dot for a pass, an "F" for a fail. For a quiet CI where you do not want verbose logs.
  • blob — the one that matters for sharding. Each shard writes its own .zip instead of a finished report. Then npx playwright merge-reports --reporter html ./blob-reports stitches them into a single HTML. Without blob, each of your 4 machines would produce a separate report covering a quarter of the suite — and seeing the whole run would be impossible.

Allure — зовнішній репортер з historical trends:

Allure — an external reporter with historical trends:

Allure не вбудований — підключається через пакет allure-playwright. Дає набагато більше ніж вбудований html: historical trends (як змінювався pass rate з часу в часі), розбивка по suite/feature/story, красиві графіки для стейкхолдерів. Для персистентності між прогонами (щоб history не губилась) — зберігаємо allure-results в S3 і підтягуємо перед кожним новим прогоном.

Allure is not built in — you add it through the allure-playwright package. It gives you far more than the built-in html: historical trends (how the pass rate moved over time), a breakdown by suite/feature/story, and charts that stakeholders like. To keep it persistent between runs (so the history is not lost) we store allure-results in S3 and pull it down before every new run.

Кілька репортерів одночасно — важливий нюанс:

Several reporters at once — an important detail:

Можна запускати кілька репортерів паралельно в одному прогоні. Наприклад: html для розробника, junit для CI/CD pipeline, allure для стейкхолдерів. Кожен отримує свій формат без додаткових запусків.

You can run several reporters in parallel in a single run. For example: html for the developer, junit for the CI/CD pipeline, allure for stakeholders. Each one gets its own format without any extra runs.

playwright.config.ts — вбудовані репортери і їх налаштування
playwright.config.ts — the built-in reporters and their options
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'
  },
});
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Single reporter:
  reporter: 'html',

  // Or several at once — an array of [name, options] pairs
  reporter: [
    ['list'],                                        // to the terminal during the run
    ['html', { outputFolder: 'playwright-report' }], // HTML report for developers
    ['junit', { outputFile: 'results/junit.xml' }],  // for GitLab/GitHub CI
    ['json', { outputFile: 'results/results.json' }], // for custom scripts
  ],

  use: {
    // What to capture automatically on failure
    screenshot: 'only-on-failure', // or 'on' (always) or 'off'
    video: 'retain-on-failure',     // or 'on' or 'off'
    trace: 'retain-on-failure',     // or 'on' or 'off'
  },
});
terminal — Allure: встановлення і запуск
terminal — Allure: install and run
# Встановлення
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
# Install
npm install --save-dev allure-playwright

# playwright.config.ts — add to the reporter array:
# ['allure-playwright', { outputFolder: 'allure-results' }]

# After the test run — generate and open the report
npx allure generate allure-results --clean -o allure-report
npx allure open allure-report

# Or serve right away (generates + opens in the browser)
npx allure serve allure-results
playwright.config.ts — Allure з historical trends через S3
playwright.config.ts — Allure with historical trends through S3
// Щоб history не губилась між прогонами — зберігаємо в S3
// В GitLab CI pipeline (спрощено):

// 1. Перед прогоном — підтягуємо попередню history з S3
// aws s3 sync s3://bucket/allure-history ./allure-results/history

// 2. Запускаємо тести — allure-playwright пише в allure-results/
// npx playwright test

// 3. Генеруємо звіт (вже з history всередині)
// npx allure generate allure-results -o allure-report

// 4. Зберігаємо нову history назад в S3
// aws s3 sync ./allure-report/history s3://bucket/allure-history

// Результат: кожен новий прогін бачить всі попередні → trend графіки

// playwright.config.ts:
reporter: [
  ['list'],
  ['allure-playwright', {
    outputFolder: 'allure-results',
    suiteTitle: false,
    environmentInfo: {
      Project: 'E-commerce PLP',
      Environment: process.env.ENV || 'staging',
    },
  }],
  ['junit', { outputFile: 'allure-results/junit.xml' }],
],
// To keep history from getting lost between runs — store it in S3
// In the GitLab CI pipeline (simplified):

// 1. Before the run — pull the previous history from S3
// aws s3 sync s3://bucket/allure-history ./allure-results/history

// 2. Run the tests — allure-playwright writes to allure-results/
// npx playwright test

// 3. Generate the report (history already included)
// npx allure generate allure-results -o allure-report

// 4. Push the new history back to S3
// aws s3 sync ./allure-report/history s3://bucket/allure-history

// Result: every new run sees all previous ones → trend charts

// playwright.config.ts:
reporter: [
  ['list'],
  ['allure-playwright', {
    outputFolder: 'allure-results',
    suiteTitle: false,
    environmentInfo: {
      Project: 'E-commerce PLP',
      Environment: process.env.ENV || 'staging',
    },
  }],
  ['junit', { outputFile: 'allure-results/junit.xml' }],
],
TypeScript — testInfo.attach(): прикріплюємо артефакти вручну до звіту
TypeScript — testInfo.attach(): attaching artifacts to the report by hand
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);
});
import { test, expect } from '@playwright/test';

test('checks API response and takes a screenshot at a step', async ({ page, request }, testInfo) => {
  // Attach the API JSON response — shows up right in the HTML/Allure report
  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',
  });

  // Attach a screenshot at a specific step (not only on failure)
  await page.goto('/products');
  await testInfo.attach('PLP after load', {
    body: await page.screenshot(),
    contentType: 'image/png',
  });

  // Attach a text log
  await testInfo.attach('test context', {
    body: `URL: ${page.url()}\nTimestamp: ${new Date().toISOString()}`,
    contentType: 'text/plain',
  });

  await expect(page.locator('.product-card')).toHaveCount(48);
});
💡 Яку комбінацію використовувати на практиці:
Локальна розробка → list + html
CI pipeline → dot + junit (тихо в логах, результати в GitLab test summary)
Стейкхолдери → allure-playwright з S3 history (тренди, графіки, розбивка по suite)
Дебаг конкретного падіння → trace: 'retain-on-failure' + npx playwright show-trace
💡 Which combination to use in practice:
Local development → list + html
CI pipeline → dot + junit (quiet logs, results in the GitLab test summary)
Stakeholders → allure-playwright with S3 history (trends, charts, a breakdown by suite)
Debugging a specific failure → trace: 'retain-on-failure' + npx playwright show-trace
🔧 TypeScript / JS патерни в цьому прикладі reporter: [['list'], ['html', { outputFolder }], ['junit', { outputFile }]] — масив масивів: кожен елемент це [назва, опції]; process.env.ENV || 'staging' — logical OR як default value; testInfo.attach('name', { body, contentType }) — options object.
🔧 TypeScript / JS patterns in this example reporter: [['list'], ['html', { outputFolder }], ['junit', { outputFile }]] — an array of arrays: each element is [name, options]; process.env.ENV || 'staging' — logical OR as a default value; testInfo.attach('name', { body, contentType }) — an options object.
🗣 Як читати вголос Playwright має кілька вбудованих репортерів для різних потреб: list виводить кожен тест рядком в термінал — зручно локально бачити прогрес. html генерує повноцінний статичний звіт з фільтрами, скріншотами і трейсами який відкривається через show-report. junit — XML формат який GitLab, GitHub Actions і Jenkins розуміють нативно і показують test summary прямо в інтерфейсі пайплайну без додаткових налаштувань. Важливий нюанс: репортери запускаються паралельно — можна одночасно мати list для розробника, junit для CI і allure-playwright для стейкхолдерів в одному прогоні. Allure дає historical trends — графік як змінювався pass rate з часу в часі, розбивку по suite і feature — але для цього потрібна персистентна history між прогонами, тому ми зберігаємо allure-results/history в S3 і підтягуємо її перед кожним новим прогоном. testInfo.attach() дозволяє вручну прикріпити до тесту будь-який артефакт: JSON відповідь API, скріншот на конкретному кроці, текстовий лог — і все це буде видно в HTML або Allure звіті прямо поруч з тестом, що сильно прискорює дебаг.
🗣 How to say it out loud Playwright ships several built-in reporters for different needs. list prints a line per test to the terminal — handy locally when you want to watch progress. html generates a full static report with filters, screenshots and traces, and you open it with show-report. junit is the XML format GitLab, GitHub Actions and Jenkins understand natively, so they show the test summary right in the pipeline UI with no extra setup. The bit people miss is that reporters run in parallel — you can have list for the developer, junit for CI and allure-playwright for stakeholders all in the same run. Allure gives you historical trends, a graph of how the pass rate moved over time and a breakdown by suite and feature, but that needs the history to survive between runs, so we keep allure-results/history in S3 and pull it down before every run. And testInfo.attach() lets you hang any artifact off a test by hand — an API response, a screenshot at a particular step, a text log — and it all shows up in the HTML or Allure report right next to the test, which speeds up debugging a lot.
94 Як реалізувати кастомний Reporter в Playwright? How do you implement a custom Reporter in Playwright?
ProductionSenior▶

Вбудовані репортери Playwright (html, junit, allure) покривають більшість потреб. Кастомний Reporter потрібен коли треба надсилати результати в специфічну систему — Slack, Jira, власний дашборд — або формувати звіт у нестандартному форматі.

Playwright's built-in reporters (html, junit, allure) cover most needs. You want a custom Reporter when results have to go into a specific system — Slack, Jira, an in-house dashboard — or when the report has to be in a non-standard format.

Reporter — це клас який імплементує інтерфейс Reporter з методами-хуками: onBegin, onTestEnd, onEnd. Playwright викликає їх автоматично.

A Reporter is a class that implements the Reporter interface with hook methods: onBegin, onTestEnd, onEnd. Playwright calls them for you.

reporters/SlackReporter.ts
reporters/SlackReporter.ts
import { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';

export default class SlackReporter implements Reporter {
  private failed: string[] = [];
  private passed = 0;

  onTestEnd(test: TestCase, result: TestResult) {
    if (result.status === 'failed') {
      this.failed.push(`❌ ${test.title()}`);
    } else if (result.status === 'passed') {
      this.passed++;
    }
  }

  async onEnd(result: FullResult) {
    const total = this.passed + this.failed.length;
    const emoji = result.status === 'passed' ? '✅' : '🔴';

    await fetch(process.env.SLACK_WEBHOOK!, {
      method: 'POST',
      body: JSON.stringify({
        text: `${emoji} E2E Results: ${this.passed}/${total} passed`,
        blocks: this.failed.length > 0 ? [{
          type: 'section',
          text: { type: 'mrkdwn', text: this.failed.join('\n') }
        }] : [],
      }),
    });
  }
}
playwright.config.ts
playwright.config.ts
reporter: [
  ['html'],
  ['allure-playwright'],
  ['./reporters/SlackReporter'],
]
🔧 TypeScript / JS патерни в цьому прикладі class MyReporter implements Reporter {} — implements: TypeScript interface implementation; onTestEnd(test: TestCase, result: TestResult): void — метод з типізованими параметрами і void return type; result.status === 'failed' — порівняння рядків.
🔧 TypeScript / JS patterns in this example class MyReporter implements Reporter {} — implements: implementing a TypeScript interface; onTestEnd(test: TestCase, result: TestResult): void — a method with typed parameters and a void return type; result.status === 'failed' — string comparison.
🗣 Як читати вголос Клас SlackReporter реалізує інтерфейс Reporter з Playwright. Два приватних поля: масив failed і лічильник passed. Метод onTestEnd — Playwright викликає його після кожного тесту, передає об'єкти TestCase і TestResult. Перевіряємо result.status і додаємо назву тесту в масив або інкрементуємо лічильник. Метод onEnd — після всіх тестів, отримує FullResult зі загальним статусом. Робимо fetch на Slack webhook, передаємо JSON з текстом і блоками. В конфігу додаємо шлях до нашого файлу в масив reporter.
🗣 How to say it out loud SlackReporter is a class that implements Playwright's Reporter interface. Two private fields: a failed array and a passed counter. onTestEnd is called by Playwright after every test and hands you a TestCase and a TestResult — we look at result.status and either push the title into the array or bump the counter. onEnd runs after everything and gets a FullResult with the overall status; there we just fetch the Slack webhook and post JSON with the text and the blocks. Then in the config you add the path to your file into the reporter array.
95 Як реалізувати visual regression testing в Playwright? How do you implement visual regression testing in Playwright?
ProductionMiddle▶

Visual regression testing — перевірка що UI не змінився візуально між версіями. Playwright має вбудований toHaveScreenshot() — він робить скріншот і порівнює з еталоном. Якщо є відмінність — тест падає і показує diff.

Visual regression testing checks that the UI has not changed visually between versions. Playwright has toHaveScreenshot() built in — it takes a screenshot and compares it against a baseline. If there is a difference the test fails and shows you the diff.

Важливо: toHaveScreenshot() — це web-first assertion. Багато хто думає що скріншот робиться один раз — насправді Playwright робить скріншот, порівнює, і якщо не збігається — повторює спробу до таймауту. Це рятує від flaky через анімації чи пізній рендер. Тест падає тільки якщо за весь таймаут жоден скріншот не збігся.

Important: toHaveScreenshot() is a web-first assertion. A lot of people assume the screenshot is taken once — in fact Playwright takes it, compares, and if it does not match it tries again until the timeout. That is what saves you from flakiness caused by animations or a late render. The test only fails if no screenshot matched for the whole timeout.

Три параметри порівняння — не плутати:

Three comparison options — do not mix them up:

  • maxDiffPixels: 50 — абсолютна кількість: допускаємо до 50 різних пікселів
  • maxDiffPixelRatio: 0.01 — частка: допускаємо до 1% від усіх пікселів
  • threshold: 0.2 — чутливість до різниці кольору одного пікселя (0 до 1). Наскільки колір має відрізнятись щоб піксель вважався "різним"
  • maxDiffPixels: 50 — an absolute count: up to 50 differing pixels are allowed
  • maxDiffPixelRatio: 0.01 — a fraction: up to 1% of all pixels are allowed
  • threshold: 0.2 — sensitivity to the colour difference of a single pixel (0 to 1). How different the colour has to be before the pixel counts as "different"

Де живуть baseline: поруч з тестом у папці <назва-тесту>.spec.ts-snapshots/, з іменем що містить назву, платформу і браузер — наприклад homepage-chromium-darwin.png. Тому скріншоти з macOS не збігаються з Linux у CI (різний рендеринг шрифтів) — baseline генерують у Docker образі того самого середовища. npx playwright test --update-snapshots перезаписує еталони новими; після цього їх треба переглянути в git diff перед комітом — інакше можна закомітити реальний баг як новий еталон.

Where the baselines live: next to the test in a <test-name>.spec.ts-snapshots/ folder, with a filename that carries the name, the platform and the browser — homepage-chromium-darwin.png, for example. That is why screenshots taken on macOS do not match Linux in CI (font rendering differs) — you generate the baselines in the Docker image of that same environment. npx playwright test --update-snapshots overwrites the baselines with the new images; after that you have to review them in git diff before committing — otherwise you can commit a real bug as the new baseline.

Головна складність: динамічні елементи (дати, анімації, live chat) завжди відрізнятимуться. Їх треба маскувати або стабілізувати.

The main difficulty is dynamic content: dates, animations, live chat will always differ. You have to mask them or stabilise them.

TypeScript — pixel comparison
TypeScript — pixel comparison
// Вбудований snapshot comparison
await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixels: 50,             // допускаємо до 50 пікселів різниці
  threshold: 0.1,               // 10% різниця на піксель
  animations: 'disabled',       // зупиняємо CSS анімації
});

// Snapshot окремого компонента
await expect(
  page.locator('.product-card').first()
).toHaveScreenshot('product-card.png');

// Оновити базові знімки:
// npx playwright test --update-snapshots

// ✅ mask передається в тесті — page тут доступний
await expect(page).toHaveScreenshot('homepage.png', {
  mask: [
    page.locator('.timestamp'),   // маскуємо динамічні дати
    page.locator('.ad-banner'),    // маскуємо рекламу
  ],
  maxDiffPixels: 50,
});

// ❌ НЕ ПРАВИЛЬНО — page.locator() недоступний в defineConfig()
// Глобально в конфізі можна задати тільки числові пороги:
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,     // ✅ числовий поріг — глобально ок
      threshold: 0.1,        // ✅ числовий поріг — глобально ок
      animations: 'disabled', // ✅ рядкове значення — глобально ок
      // mask: [] ← ❌ page недоступний в defineConfig
    },
  },
});
// Built-in snapshot comparison
await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixels: 50,             // allow up to 50 differing pixels
  threshold: 0.1,               // 10% difference per pixel
  animations: 'disabled',       // freeze CSS animations
});

// Snapshot of a single component
await expect(
  page.locator('.product-card').first()
).toHaveScreenshot('product-card.png');

// Update baseline snapshots:
// npx playwright test --update-snapshots

// ✅ mask is passed in the test — page is available here
await expect(page).toHaveScreenshot('homepage.png', {
  mask: [
    page.locator('.timestamp'),   // mask dynamic dates
    page.locator('.ad-banner'),    // mask ads
  ],
  maxDiffPixels: 50,
});

// ❌ WRONG — page.locator() is not available in defineConfig()
// Globally in the config you can only set numeric thresholds:
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,     // ✅ numeric threshold — fine globally
      threshold: 0.1,        // ✅ numeric threshold — fine globally
      animations: 'disabled', // ✅ string value — fine globally
      // mask: [] ← ❌ page is not available in defineConfig
    },
  },
});
🔧 TypeScript / JS патерни в цьому прикладі await expect(page).toHaveScreenshot('name.png') — snapshot assertion; { mask: [page.locator('.dynamic')] } — масив Locator об'єктів як опція маскування; maxDiffPixels — числовий threshold.
🔧 TypeScript / JS patterns in this example await expect(page).toHaveScreenshot('name.png') — a snapshot assertion; { mask: [page.locator('.dynamic')] } — an array of Locator objects as the masking option; maxDiffPixels — a numeric threshold.
🗣 Як читати вголос expect(page).toHaveScreenshot('homepage.png', { ... }) — при першому запуску створює базовий знімок, при наступних — порівнює піксель за пікселем. maxDiffPixels: 50 — допускаємо абсолютну різницю до 50 пікселів. threshold: 0.1 — допустима різниця кольору на піксель від 0 до 1. animations: 'disabled' — Playwright зупинить CSS-анімації перед знімком. mask в конфігу — масив локаторів що будуть зафарбовані чорним перед порівнянням, це для динамічних зон на кшталт дати або реклами. Оновити еталони: --update-snapshots.
🗣 How to say it out loud expect(page).toHaveScreenshot('homepage.png', { ... }) creates the baseline on the first run and compares pixel by pixel on every run after that. maxDiffPixels: 50 allows an absolute difference of up to 50 pixels. threshold: 0.1 is how different a single pixel's colour has to be, on a scale of 0 to 1. animations: 'disabled' makes Playwright stop CSS animations before it takes the shot. mask takes an array of locators that get painted over before the comparison — that is for the dynamic areas like a timestamp or an ad slot. And you refresh the baselines with --update-snapshots.
96 Як тестувати доступність (a11y) в Playwright? How do you test accessibility (a11y) in Playwright?
ProductionMiddle▶

Accessibility (a11y) тестування перевіряє що сайт доступний для людей з обмеженими можливостями — screen readers, клавіатурна навігація, достатній контраст. Playwright інтегрується з бібліотекою axe-core яка автоматично знаходить порушення WCAG стандарту.

Accessibility (a11y) testing checks that a site works for people with disabilities — screen readers, keyboard navigation, sufficient contrast. Playwright integrates with the axe-core library, which finds WCAG violations automatically.

Сучасний підхід — Locator Assertions замість page.accessibility.snapshot(). Старий API page.accessibility.snapshot() ще існує, але після появи a11y-матчерів використовується рідко. Для точкових перевірок зараз рекомендують:

The modern approach is Locator Assertions rather than page.accessibility.snapshot(). The old page.accessibility.snapshot() API still exists, but since the a11y matchers arrived it is rarely used. For targeted checks the current recommendation is:

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 його не побачить.

Why getByRole() tests a11y on its own: it works through the browser's Accessibility Tree, not through DOM attributes. The browser builds that tree from the semantics of the HTML — which is why a <button> has the role button even without an explicit role="button". That is also why getByRole sometimes finds elements that carry no role attribute — and the other way round: if getByRole cannot see an element, a screen reader will not see it either.

TypeScript — @axe-core/playwright integration
TypeScript — @axe-core/playwright integration
import { AxeBuilder } from '@axe-core/playwright'; // npm i @axe-core/playwright

test('product page is accessible', async ({ page }) => {
  await page.goto('/product/123');

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa']) // перевіряємо тільки ці стандарти
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]); // нуль порушень
});

// Вбудована перевірка через ARIA tree
// getByRole() сам тестує a11y — якщо елемент не видно в tree,
// значить він не доступний для screen reader

// Snapshot ARIA tree
await expect(page.locator('nav')).toMatchAriaSnapshot(`
  - navigation:
    - link "Home"
    - link "Products"
    - link "Cart [2]"
`);
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']) // check only these standards
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]); // zero violations
});

// Built-in check via the ARIA tree
// getByRole() tests a11y by itself — if an element isn't in the tree,
// it isn't accessible to screen readers

// Snapshot ARIA tree
await expect(page.locator('nav')).toMatchAriaSnapshot(`
  - navigation:
    - link "Home"
    - link "Products"
    - link "Cart [2]"
`);
🔧 TypeScript / JS патерни в цьому прикладі new AxeBuilder({ page }).withTags([...]).analyze() — builder pattern: кожен метод повертає той самий об'єкт для chaining; await тільки перед analyze() — він повертає Promise; { violations } — деструктуризація результату; violations.length — властивість масиву.
🔧 TypeScript / JS patterns in this example new AxeBuilder({ page }).withTags([...]).analyze() — the builder pattern: every method returns the same object so calls can be chained; await only in front of analyze() — that is the one returning a Promise; { violations } — destructuring the result; violations.length — an array property.
🗣 Як читати вголос Для a11y тестування використовуємо офіційний пакет @axe-core/playwright. Створюємо екземпляр AxeBuilder передаючи page, потім через builder pattern налаштовуємо: withTags(['wcag2a', 'wcag2aa']) — перевіряємо тільки ці стандарти WCAG. analyze() — запускає перевірку і повертає результат. Перевіряємо що масив violations порожній. toMatchAriaSnapshot — вбудований в Playwright метод: знімає ARIA-дерево елементу і порівнює з template literal, де дефіси це рівні вкладеності ролей.
🗣 How to say it out loud For accessibility testing we use the official @axe-core/playwright package. We create an AxeBuilder instance and pass it page, then configure it with the builder pattern: withTags(['wcag2a', 'wcag2aa']) narrows the scan to those WCAG levels. analyze() runs the scan and returns the result, and we assert that the violations array is empty. toMatchAriaSnapshot is built into Playwright — it takes the ARIA tree of an element and compares it against a template literal, where the dashes are nesting levels of roles.
97 Як аналізувати performance під час тестів через Playwright? How do you analyse performance during tests with Playwright?
ProductionSenior▶

Playwright дає доступ до браузерних performance метрик через Chrome DevTools Protocol. Можна вимірювати Web Vitals — LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), FID — прямо в тестах і перевіряти що вони не погіршуються між релізами.

Playwright gives you access to the browser's performance metrics through the Chrome DevTools Protocol. You can measure Web Vitals — LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), FID — right inside a test and assert that they do not get worse between releases.

Важливі застереження. FID з березня 2024 року замінено на INP (Interaction to Next Paint), тож у нових тестах варто міряти INP. Лабораторні метрики в CI шумні, тому я фіксую умови через CDP — обмеження мережі та сповільнення CPU, — запускаю вимір кілька разів і беру медіану, а поріг задаю як бюджет відносно базової лінії, а не як жорстке абсолютне число. CDP працює лише в Chromium, тому такі тести живуть в окремому проєкті. І це не заміна навантажувальному тестуванню (k6, JMeter) чи моніторингу реальних користувачів — це раннє попередження про регресію на конкретній сторінці.

Some important caveats. Since March 2024 FID has been replaced by INP (Interaction to Next Paint), so new tests should measure INP. Lab metrics in CI are noisy, so I pin the conditions through CDP — network throttling and CPU slowdown — take several measurements and use the median, and set the threshold as a budget relative to a baseline rather than a hard absolute number. CDP only works in Chromium, so these tests live in a separate project. And it is not a replacement for load testing (k6, JMeter) or real-user monitoring — it is an early warning about a regression on a specific page.

TypeScript — Web Vitals + CDP
TypeScript — Web Vitals + CDP
test('PDP meets Core Web Vitals thresholds', async ({ page }) => {
  // Підключаємо CDP для performance
  const client = await page.context().newCDPSession(page);
  await client.send('Performance.enable');

  await page.goto('/product/123');

  // Web Vitals через PerformanceObserver
  const vitals = await page.evaluate(() =>
    new Promise(resolve => {
      const result: Record<string, number> = {};
      new PerformanceObserver(list => {
        list.getEntries().forEach(entry => {
          if (entry.entryType === 'largest-contentful-paint')
            result.LCP = entry.startTime;
          if (entry.entryType === 'layout-shift')
            result.CLS = (result.CLS || 0) + (entry as any).value;
        });
        setTimeout(() => resolve(result), 1000);
      }).observe({ type: 'largest-contentful-paint', buffered: true });
    })
  );

  // Assert thresholds (Google рекомендує LCP < 2500ms)
  expect(vitals.LCP).toBeLessThan(2500);
  expect(vitals.CLS).toBeLessThan(0.1);
});
test('PDP meets Core Web Vitals thresholds', async ({ page }) => {
  // Attach CDP for performance
  const client = await page.context().newCDPSession(page);
  await client.send('Performance.enable');

  await page.goto('/product/123');

  // Web Vitals via 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 recommends LCP < 2500ms)
  expect(vitals.LCP).toBeLessThan(2500);
  expect(vitals.CLS).toBeLessThan(0.1);
});
💡 Додатково: Playwright Trace Viewer показує network waterfall та timeline. Combine з Allure для трендового моніторингу performance.
💡 Also worth saying: the Playwright Trace Viewer shows the network waterfall and the timeline. Combine it with Allure to watch performance trends over time.
🔧 TypeScript / JS патерни в цьому прикладі await page.context().newCDPSession(page) — ланцюжок методів; await client.send('Performance.enable') — виклик з рядковим аргументом (CDP command); new Promise((resolve) => { new PerformanceObserver((list) => { resolve(data) }) }) — Promise constructor з PerformanceObserver callback.
🔧 TypeScript / JS patterns in this example await page.context().newCDPSession(page) — a method chain; await client.send('Performance.enable') — a call with a string argument (a CDP command); new Promise((resolve) => { new PerformanceObserver((list) => { resolve(data) }) }) — a Promise constructor wrapping a 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 — отримаємо і вже минулі події. Після секунди резолвимо проміс з зібраними метриками і перевіряємо порогові значення через звичайні матчери.
🗣 How to say it out loud page.context().newCDPSession(page) opens a Chrome DevTools Protocol session for that specific tab, and client.send('Performance.enable') turns the Performance domain on. Then page.evaluate runs code inside the browser and returns a new Promise. In the page we create a PerformanceObserver, subscribe to largest-contentful-paint, and in the callback we take startTime as LCP and accumulate the layout-shift values into CLS. buffered: true means we also get the events that already happened before we subscribed. After a second we resolve the promise with whatever we collected and assert the thresholds with ordinary matchers.
98 Як використовувати toMatchSnapshot() для JSON і тексту, а не тільки для скріншотів? How do you use toMatchSnapshot() for JSON and text, not just for screenshots?
ProductionMiddle▶

Більшість знають toHaveScreenshot() для візуальних регресій. Але є ще toMatchSnapshot() — для будь-яких даних: JSON, текст, HTML рядки.

Most people know toHaveScreenshot() for visual regressions. But there is also toMatchSnapshot() — for any data at all: JSON, text, HTML strings.

Ідея проста: перший запуск — Playwright зберігає "еталон" у файл поруч з тестом. Кожен наступний запуск — порівнює з еталоном. Якщо відрізняється — тест падає і показує diff де саме.

The idea is simple: on the first run Playwright saves a "baseline" to a file next to the test. Every run after that compares against the baseline. If it differs, the test fails and shows you exactly where.

Навіщо для JSON: API повертає складний об'єкт або структуру dataLayer події. Замість двадцяти expect(event.field).toBe(value) — зберігаємо весь об'єкт як еталон і порівнюємо цілком. Якщо хтось випадково змінить структуру — тест одразу покаже що саме змінилось.

Why you would use it for JSON: an API returns a complex object, or a dataLayer event has a complex structure. Instead of twenty expect(event.field).toBe(value) lines, save the whole object as a baseline and compare it in one go. If someone changes the structure by accident, the test shows you immediately what moved.

  • toEqual() — ти вручну описуєш очікуване значення прямо в коді тесту
  • toMatchSnapshot() — очікуване значення зберігається у зовнішньому файлі. Playwright генерує його сам при першому запуску
  • toEqual() — you write the expected value out by hand, right there in the test
  • toMatchSnapshot() — the expected value lives in an external file. Playwright generates it for you on the first run

Оновлення еталону: коли структура навмисно змінилась — запускаєш з --update-snapshots і Playwright перезаписує файл еталону.

Updating the baseline: when the structure has changed on purpose, run with --update-snapshots and Playwright rewrites the baseline file.

TypeScript — snapshot для GTM dataLayer події
TypeScript — a snapshot of a GTM dataLayer event
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('add_to_cart event structure matches baseline', 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')
  );

  // ❌ Without snapshot — you have to describe the whole structure by hand
  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 more fields

  // ✅ With snapshot — store the whole object as the baseline
  expect(JSON.stringify(event, null, 2)).toMatchSnapshot('add-to-cart-event.txt');
  // First run: creates add-to-cart-event.txt with the current JSON
  // Subsequent runs: compares against the file, shows a diff if anything changed
});
TypeScript — snapshot для тексту і HTML
TypeScript — snapshots for text and HTML
test('SEO мета-теги відповідають еталону', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // Збираємо всі мета-теги як текст
  const metaTags = await page.evaluate(() => {
    const tags = document.querySelectorAll('meta');
    return [...tags].map(tag => ({
      name: tag.getAttribute('name'),
      property: tag.getAttribute('property'),
      content: tag.getAttribute('content'),
    }));
  });

  // Snapshot для всієї структури — якщо хтось змінить og:title або description,
  // тест одразу покаже diff
  expect(JSON.stringify(metaTags, null, 2)).toMatchSnapshot('pdp-meta-tags.txt');
});

test('email шаблон відповідає еталону', async ({ page }) => {
  await page.goto('/email-preview/order-confirmation');

  // Snapshot для HTML — виявляє ненавмисні зміни в шаблоні листа
  const emailHtml = await page.locator('.email-body').innerHTML();
  expect(emailHtml).toMatchSnapshot('order-confirmation-email.html');
});
test('SEO meta tags match baseline', async ({ page }) => {
  await page.goto('/product/trench-coat');

  // Collect all meta tags as text
  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 the whole structure — if someone changes og:title or description,
  // the test shows the diff right away
  expect(JSON.stringify(metaTags, null, 2)).toMatchSnapshot('pdp-meta-tags.txt');
});

test('email template matches baseline', async ({ page }) => {
  await page.goto('/email-preview/order-confirmation');

  // HTML snapshot — catches unintended changes in the email template
  const emailHtml = await page.locator('.email-body').innerHTML();
  expect(emailHtml).toMatchSnapshot('order-confirmation-email.html');
});
Термінал — перший запуск, фейл зі змінами і оновлення еталону
Terminal — the first run, a failure with changes, and updating the baseline
# Перший запуск — 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
# First run — Playwright creates the baseline file
npx playwright test product.spec.ts

# ✓ add_to_cart event structure matches baseline
#   → snapshot written: tests/__snapshots__/product.spec.ts/add-to-cart-event.txt

# ─────────────────────────────────────────────────────────────────
# Next run — someone changed the event structure
npx playwright test product.spec.ts

# ✗ add_to_cart event structure matches baseline
#   Snapshot comparison failed:
#   - Expected  + Received
#   {
#     "event": "add_to_cart",
#     "ecommerce": {
#       "items": [{
# -       "item_name": "Trench Coat",
# +       "item_name": "Classic Trench Coat",  ← someone changed the name
# -       "currency": "GBP"
# +       "currency": "USD"                    ← the domain changed
#       }]
#     }
#   }

# ─────────────────────────────────────────────────────────────────
# The change is intentional — update the baseline and commit
npx playwright test product.spec.ts --update-snapshots

# ✓ add_to_cart event structure matches baseline
#   → snapshot updated: tests/__snapshots__/product.spec.ts/add-to-cart-event.txt
Git — snapshot файли обов'язково комітити в репозиторій
Git — snapshot files must be committed to the repository
// 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 бачить новий еталон → тест проходить
// Snapshot files are part of the codebase, not build artifacts
// Without them CI has nothing to compare against → tests either fail or become meaningless

// File structure:
// tests/
//   product.spec.ts
//   __snapshots__/          ← THIS FOLDER GOES INTO GIT
//     product.spec.ts/
//       add-to-cart-event.txt
//       pdp-meta-tags.txt

// .gitignore — make sure __snapshots__/ is NOT ignored
// (it sometimes gets added by accident along with other artifacts)

// Correct workflow:
// 1. Wrote a test → ran it locally → snapshot file created
// 2. git add tests/__snapshots__/ → committed together with the test
// 3. CI runs → compares against the same file that's in git
// 4. Someone intentionally changed the structure → --update-snapshots locally
// 5. git add tests/__snapshots__/ → committed the updated baseline
// 6. CI sees the new baseline → test passes
💡 Коли toMatchSnapshot() корисний а коли ні:
Корисний: складна структура з багатьма полями (GTM події, API відповіді, мета-теги), HTML шаблони листів або PDF прев'ю, конфігураційні JSON де важлива кожна деталь
Не корисний: прості значення де ліпше toEqual(), дані що часто змінюються навмисно (ціни, дати), дані з id або timestamp які різні кожного разу — спершу треба їх відфільтрувати
💡 When toMatchSnapshot() helps and when it does not:
It helps: a complex structure with many fields (GTM events, API responses, meta tags), HTML email templates or PDF previews, configuration JSON where every detail matters
It does not: simple values where toEqual() is better, data that changes on purpose all the time (prices, dates), data with ids or timestamps that differ on every run — you have to filter those out first
🔧 TypeScript / JS патерни в цьому прикладі JSON.stringify(obj, null, 2) — серіалізація з відступами: null (replacer не потрібен), 2 (відступ 2 пробіли); .toMatchSnapshot('name.txt') — snapshot assertion зберігає рядок у файл; --update-snapshots — CLI флаг для оновлення еталонів.
🔧 TypeScript / JS patterns in this example JSON.stringify(obj, null, 2) — serialisation with indentation: null (no replacer needed), 2 (a two-space indent); .toMatchSnapshot('name.txt') — a snapshot assertion that stores a string in a file; --update-snapshots — the CLI flag that refreshes the baselines.
🗣 Як читати вголос toMatchSnapshot() для тексту і JSON працює так само як для скріншотів — при першому запуску Playwright зберігає поточне значення у файл-еталон поруч з тестом, і кожен наступний запуск порівнює з цим файлом, показуючи diff якщо щось змінилось. Це особливо зручно для складних структур типу GTM dataLayer подій де є десятки полів — замість того щоб писати двадцять окремих assertions, ми серіалізуємо весь об'єкт через JSON.stringify і порівнюємо з еталоном одним рядком. Якщо хтось випадково змінить назву товару або валюту в події — тест впаде і покаже точно яке поле змінилось і з якого значення на яке. Головне що треба памятати: дані з динамічними значеннями типу timestamp або id треба відфільтровувати перед snapshot, інакше тест буде падати щоразу через різні значення. Коли структура навмисно змінилась — оновлюємо еталон через --update-snapshots.
🗣 How to say it out loud toMatchSnapshot() for text and JSON works the same way it does for screenshots — on the first run Playwright writes the current value into a baseline file next to the test, and every run after that compares against that file and shows a diff if anything moved. It is especially handy for complex structures like GTM dataLayer events with dozens of fields: instead of writing twenty separate assertions we serialise the whole object with JSON.stringify and compare it against the baseline in one line. If someone accidentally changes a product name or a currency in the event, the test fails and shows you exactly which field changed and from what to what. The thing to remember is that dynamic values like timestamps or ids have to be filtered out before you snapshot, otherwise the test fails on every run for no real reason. And when the structure has changed on purpose, you refresh the baseline with --update-snapshots.
99 Як тестувати локалізацію і коректний routing на платформі з десятками country domains? How do you test localisation and correct routing on a platform with dozens of country domains?
ProductionSenior▶

Платформа з 45 країновими доменами — це 45 варіантів мови, валюти, формату дат і URL структури. Тестувати кожен домен вручну нереально. Data-driven підхід: описуємо конфігурацію кожного домену в масиві і генеруємо тести автоматично.

A platform with 45 country domains means 45 variants of language, currency, date format and URL structure. Testing every domain by hand is not realistic. The data-driven approach: describe the configuration of each domain in an array and generate the tests automatically.

Для кожного домену тест перевіряє не лише переклад: атрибут lang, формат ціни й дати, який я рахую через Intl.NumberFormat з тим самим locale, а не хардкоджу, коректні hreflang і canonical, а також редиректи при зміні мови. Щоб матриця не з'їла pipeline, на pull request біжить критичний сценарій для кількох репрезентативних доменів (різні валюти, письмо справа наліво, якщо є), а повна матриця з 45 доменів — уночі. Конфігурацію доменів краще брати з того самого джерела, що й застосунок, інакше тестовий масив тихо розходиться з реальністю, коли додають нову країну.

For every domain the test checks more than the translation: the lang attribute, the price and date format, which I compute with Intl.NumberFormat for the same locale instead of hard-coding it, correct hreflang and canonical tags, and the redirects when the language changes. So the matrix does not eat the pipeline, a pull request runs the critical scenario on a few representative domains (different currencies, a right-to-left script if there is one), and the full 45-domain matrix runs overnight. The domain configuration is best read from the same source as the application, otherwise the test array quietly drifts from reality when a new country is added.

Кейс: Multi-domain e-commerce — 45 country domains, 9 мов
Case: multi-domain e-commerce — 45 country domains, 9 languages
// 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);
  });
}
// Data-driven approach: one test, a domain matrix via playwright.config.ts projects
type DomainConfig = {
  domain: string;
  locale: string;
  currency: string;
  expectedLangAttr: string; // html lang= for SEO/a11y checks
};

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 is correct for domain ${domain}`, async ({ browser }) => {
    const context = await browser.newContext({ locale });
    const page = await context.newPage();
    await page.goto(`https://${domain}.myshop.com/product/123`);

    await expect(page.locator('html')).toHaveAttribute('lang', expectedLangAttr);
    await expect(page.locator('.price')).toContainText(currency);
  });
}
💡 Що ще тестується окремо: hreflang-теги (SEO), редиректи між доменами при зміні мови, geo-IP based default routing, форматування цін/дат за locale (не лише currency symbol, а й розрядність — 1,000.00 vs 1.000,00), переклад GTM dataLayer подій (event names мають лишатись англійською, а content — локалізований).
💡 What else gets tested separately: hreflang tags (SEO), redirects between domains when the language changes, geo-IP based default routing, price and date formatting per locale (not just the currency symbol but the separators too — 1,000.00 vs 1.000,00), and the localisation of GTM dataLayer events (event names have to stay in English while the content is localised).
🔧 TypeScript / JS патерни в цьому прикладі const domains: DomainConfig[] = [{ ... }, { ... }] — TypeScript масив з типом; domains.forEach(({ country, locale, currency, domain }) => { test(...) }) — forEach з деструктуризацією вкладеного об'єкту; data-driven pattern: дані окремо від логіки тесту.
🔧 TypeScript / JS patterns in this example const domains: DomainConfig[] = [{ ... }, { ... }] — a typed TypeScript array; domains.forEach(({ country, locale, currency, domain }) => { test(...) }) — forEach destructuring a nested object; the data-driven pattern: data kept separate from test logic.
🗣 Як читати вголос На платформі з 45 доменами і 9 мовами неможливо й не потрібно дублювати кожен тест 45 разів вручну — натомість я виношу конфігурацію домену, locale, валюти й очікуваного lang-атрибута в окремий масив DomainConfig і прогоняю один і той самий сценарій data-driven циклом, де для кожного домену створюється власний BrowserContext з потрібним locale, бо locale впливає на формат дат і чисел, які повертає браузер. У кожній ітерації перевіряю не тільки видимий контент, а й технічні речі, важливі для SEO та a11y: атрибут lang у тегу html має відповідати мові домену, бо це впливає на screen readers і пошукову індексацію. Окремо це покриває валюту — текст ціни має містити правильний currency code чи символ для регіону. За межами цього прикладу команда ще окремо перевіряє hreflang-теги для SEO, коректність geo-IP редиректів при заході з різних країн, і що важливо для нашої GTM-інтеграції — event names в dataLayer завжди лишаються англійською незалежно від домену, а локалізується тільки контент, який бачить користувач, бо аналітика має бути уніфікованою для всіх ринків.
🗣 How to say it out loud With 45 domains and 9 languages you cannot — and should not — copy every test 45 times by hand. What I do instead is pull the domain, the locale, the currency and the expected lang attribute out into a DomainConfig array, and run the same scenario over it in a data-driven loop, creating a separate BrowserContext per domain with the right locale, because locale is what drives the date and number formats the browser gives you back. In each iteration I check more than the visible content — the lang attribute on the html tag has to match the language of the domain, since that is what screen readers and search indexing rely on. And the price text has to carry the right currency code or symbol for that market. Beyond this example the team also checks hreflang tags for SEO separately, whether the geo-IP redirects behave when you come in from different countries, and — this one matters for our GTM integration — that the dataLayer event names always stay in English regardless of the domain, with only the user-facing content localised, because the analytics has to be uniform across every market.
100 Що таке Playwright Component Testing і чим воно відрізняється від E2E? What is Playwright Component Testing and how is it different from E2E?
ProductionSenior▶

Component Testing — можливість монтувати окремий React/Vue/Svelte компонент у справжньому браузері і тестувати його ізольовано, без запуску всього застосунку. Тобто це не E2E і не unit — щось посередині.

Component Testing is the ability to mount a single React/Vue/Svelte component in a real browser and test it in isolation, without starting the whole application. So it is neither E2E nor unit — it sits in between.

Unit (Jest + jsdom)   → компонент у ФЕЙКОВОМУ DOM, дуже швидко,
                         але jsdom != браузер: немає layout, CSS, реальних подій

Component (Playwright) → компонент у СПРАВЖНЬОМУ браузері, ізольовано,
                         є CSS, layout, реальні кліки — але без бекенду і роутингу

E2E (Playwright)      → весь застосунок цілком, реальний бекенд,
                         найповільніше, але найближче до юзера
Unit (Jest + jsdom)   → component in a FAKE DOM, very fast,
                         but jsdom != browser: no layout, CSS, or real events

Component (Playwright) → component in a REAL browser, isolated,
                         has CSS, layout, real clicks — but no backend or routing

E2E (Playwright)      → the whole app end to end, real backend,
                         slowest, but closest to the user

Навіщо це існує: уяви компонент <SizeSelector> з 12 станами — sold out, one left, preorder, різні країни. Тестувати всі 12 через E2E — треба 12 різних товарів у базі і 12 повільних прогонів. У component test ти просто передаєш props і перевіряєш рендер за секунду. При цьому це справжній браузер — CSS і layout працюють, на відміну від jsdom.

Why it exists: picture a <SizeSelector> component with 12 states — sold out, one left, preorder, different countries. Testing all 12 through E2E means 12 different products in the database and 12 slow runs. In a component test you just pass props and check the render in a second. And it is a real browser — CSS and layout work, unlike jsdom.

⚠️ Важливо для інтерв'ю: Component Testing у Playwright досі має статус experimental — API може змінитись. Потрібен окремий пакет @playwright/experimental-ct-react і окремий конфіг playwright-ct.config.ts. Тому на питання "чи використовували б ви це в production" чесна відповідь: для критичних UI-компонентів зі складною матрицею станів — так, але з розумінням що це experimental. Основне покриття все одно E2E.

⚠️ Important for interviews: Component Testing in Playwright is still experimental — the API can change. It needs a separate @playwright/experimental-ct-react package and a separate playwright-ct.config.ts. So the honest answer to "would you use this in production" is: for critical UI components with a complex matrix of states, yes, but knowing it is experimental. The bulk of the coverage stays E2E either way.

playwright-ct.config.ts + приклад component test
playwright-ct.config.ts + a component test example
// 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 прокидає виклик через міст
});
// npm init playwright@latest -- --ct
// Creates playwright-ct.config.ts and playwright/index.tsx

// SizeSelector.spec.tsx — note the .tsx extension
import { test, expect } from '@playwright/experimental-ct-react';
import { SizeSelector } from './SizeSelector';

test('shows Sold out and blocks selecting an unavailable size', async ({ mount }) => {
  // mount is a special fixture: mounts the component in the browser
  // Returns a Locator for the mounted component
  const component = await mount(
    <SizeSelector
      sizes={[
        { label: 'S', available: true },
        { label: 'M', available: false },
      ]}
      onSelect={() => {}}
    />
  );

  // From here on — regular locators and web-first assertions
  await expect(component.getByRole('button', { name: 'M' })).toBeDisabled();
  await expect(component.getByText('Sold out')).toBeVisible();

  // Real browser — you can check CSS, which jsdom can't do
  await expect(component.getByRole('button', { name: 'M' }))
    .toHaveCSS('text-decoration-line', 'line-through');
});

// Mocking callback props — verify the component calls them
test('calls onSelect with the selected size', 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']);
  // ↑ the callback lives in Node.js, Playwright forwards the call over a bridge
});
🔧 TypeScript / JS патерни в цьому прикладі @playwright/experimental-ct-react — окремий пакет, не @playwright/test; mount — fixture що повертає Locator на змонтований компонент; JSX прямо в тесті — тому файл має розширення .tsx; onSelect={(size) => selected.push(size)} — колбек визначений у Node.js але викликається з браузера: Playwright серіалізує виклик через свій міст (те саме обмеження серіалізації що й у evaluate).
🔧 TypeScript / JS patterns in this example @playwright/experimental-ct-react — a separate package, not @playwright/test; mount — a fixture that returns a Locator for the mounted component; JSX right in the test — which is why the file has a .tsx extension; onSelect={(size) => selected.push(size)} — a callback defined in Node.js but called from the browser: Playwright serialises the call across its bridge (the same serialisation limits as evaluate).
🗣 Як читати вголос Component Testing у Playwright дозволяє змонтувати окремий React або Vue компонент у справжньому браузері і протестувати його ізольовано. Це проміжний рівень між unit і E2E: на відміну від Jest з jsdom, тут справжній браузерний рушій — працює CSS, layout, реальні події. На відміну від E2E — немає бекенду і роутингу, компонент отримує props напряму. Головний сценарій: компонент зі складною матрицею станів. Замість дванадцяти повільних E2E з дванадцятьма товарами в базі — дванадцять швидких component-тестів де ти просто передаєш різні props. Технічно потрібен окремий пакет experimental-ct-react, окремий конфіг playwright-ct.config.ts, і файли тестів мають розширення tsx бо всередині JSX. Ключова fixture — mount: вона монтує компонент і повертає звичайний Locator, далі працюєш як у будь-якому Playwright тесті. Важливий нюанс для інтерв'ю: це досі experimental, API може змінитись, тому основне покриття все одно E2E.
🗣 How to say it out loud Component Testing in Playwright lets you mount a single React or Vue component in a real browser and test it in isolation. It sits between unit and E2E: unlike Jest with jsdom you get a real browser engine, so CSS, layout and real events all work. Unlike E2E there is no backend and no routing — the component takes its props directly. The case where it earns its keep is a component with a complex matrix of states. Instead of twelve slow E2E runs with twelve products in the database, you write twelve fast component tests and just pass different props. Practically you need the separate experimental-ct-react package, a separate playwright-ct.config.ts, and the test files end in tsx because there is JSX inside them. The key fixture is mount: it mounts the component and hands you back an ordinary Locator, so from there it is the same as any Playwright test. One caveat worth saying in an interview: it is still experimental, the API can change, so the bulk of the coverage stays E2E.
101 Як ти дебажила CI pipeline після переходу на Node.js v24? How did you debug the CI pipeline after moving to Node.js v24?
ProductionSenior▶

Реальний кейс: після оновлення Node.js з v20 до v24 CI pipeline почав падати з незрозумілими помилками. Проблема виявилась у несумісності ES modules і CommonJS в helper скриптах, плюс зміна в обробці environment variables. Вирішення — поетапне: спочатку ізолювати причину, потім конвертувати скрипти.

A real case: after upgrading Node.js from v20 to v24 the CI pipeline started failing with confusing errors. The cause turned out to be an incompatibility between ES modules and CommonJS in the helper scripts, plus a change in how environment variables were handled. The fix was staged: isolate the cause first, then convert the scripts.

Кейс: branch ta/PROJ-1102 — GitLab CI compatibility fix
Case: branch ta/PROJ-1102 — GitLab CI compatibility fix
// Проблема 1: TS helper-скрипти падали на require() у Node 24 (ESM-first)
// ❌ import { getDefectLabel } from './helpers/labels'; // top-level await issues
// ✅ Конвертація в CommonJS для helper-скриптів CI (не для тестів)
const { getDefectLabel } = require('./helpers/labels.cjs');

// Проблема 2: токен не співпадав через зміну формату GitLab CI variable masking
// ❌ const token = process.env.CI_JOB_TOKEN; // маскувався частково в логах, ламав auth header
// ✅ Явне зчитування з protected variable + перевірка перед використанням
const token = process.env.GITLAB_API_TOKEN;
if (!token) throw new Error('GITLAB_API_TOKEN відсутній у CI variables');

// Проблема 3: label-names не співпадали з Jira sync (PROJ-1102)
// ❌ labels: ['defect'] // GitLab створював, але Jira-sync шукав 'bug-auto'
// ✅ Уніфікований label-mapping на обидва боки інтеграції
const LABEL_MAP = { defect: 'bug-auto' } as const;
// Problem 1: TS helper scripts crashed on require() in Node 24 (ESM-first)
// ❌ import { getDefectLabel } from './helpers/labels'; // top-level await issues
// ✅ Converted CI helper scripts to CommonJS (not the tests)
const { getDefectLabel } = require('./helpers/labels.cjs');

// Problem 2: token mismatch due to a change in GitLab CI variable masking format
// ❌ const token = process.env.CI_JOB_TOKEN; // partially masked in logs, broke the auth header
// ✅ Read explicitly from a protected variable + check before use
const token = process.env.GITLAB_API_TOKEN;
if (!token) throw new Error('GITLAB_API_TOKEN is missing from CI variables');

// Problem 3: label-names didn't match the Jira sync (PROJ-1102)
// ❌ labels: ['defect'] // GitLab created it, but Jira-sync looked for 'bug-auto'
// ✅ Unified label-mapping on both sides of the integration
const LABEL_MAP = { defect: 'bug-auto' } as const;
💡 Чому це гарний приклад: три різні класи проблем (модульна система, секрети, конфігурація інтеграції) в одному переході версії — показує, що CI debugging рідко зводиться до однієї причини.
💡 Why this is a good example: three different classes of problem (the module system, secrets, integration configuration) in one version bump — it shows that CI debugging rarely comes down to a single cause.
🔧 TypeScript / JS патерни в цьому прикладі module.exports = { ... } — CommonJS export (не ES modules); require('path') — CommonJS import; різниця від ES modules: import/export vs require/module.exports; process.env.VARIABLE — environment variable в Node.js.
🔧 TypeScript / JS patterns in this example module.exports = { ... } — a CommonJS export (not ES modules); require('path') — a CommonJS import; the difference from ES modules: import/export vs require/module.exports; process.env.VARIABLE — an environment variable in Node.js.
🗣 Як читати вголос Перехід на Node.js 24 зламав одразу декілька речей. По-перше, helper-скрипти, написані під TypeScript з ESM-синтаксисом, почали конфліктувати з тим, як новий Node резолвить модулі — я перевела саме CI-скрипти (не самі тести) на CommonJS через require(), оскільки для разових helper-задач це простіше й надійніше за дебаг ESM top-level await. По-друге, формат маскування GitLab CI variables змінився, і токен, який раніше читався з CI_JOB_TOKEN, почав приходити в неправильному форматі — перейшла на явну protected variable з перевіркою на undefined перед використанням, щоб помилка падала одразу з зрозумілим повідомленням, а не на кроці автентифікації. По-третє, в інтеграції PROJ-1102 — автостворення GitLab issues з синком у Jira — назви лейблів розійшлись між системами, тому я зробила єдиний LABEL_MAP як shared mapping-шар, щоб зміна назви в одному місці не ламала інтеграцію.
🗣 How to say it out loud Moving to Node.js 24 broke several things at once. First, the helper scripts written in TypeScript with ESM syntax started fighting with the way the new Node resolves modules — I moved the CI scripts specifically, not the tests themselves, over to CommonJS with require(), because for one-off helper work that is simpler and more predictable than debugging ESM top-level await. Second, GitLab's variable masking format changed and the token we used to read from CI_JOB_TOKEN started coming through in the wrong shape, so I switched to an explicit protected variable with an undefined check before use — that way it fails straight away with a readable message instead of somewhere down in the auth step. Third, in the PROJ-1102 integration, the one that auto-creates GitLab issues and syncs them to Jira, the label names had drifted apart between the two systems, so I put in a single LABEL_MAP as a shared mapping layer, so renaming a label in one place cannot quietly break the integration.
102 Як ти, як QA Lead, балансуєш швидкість релізів і стабільність тестів? As a QA Lead, how do you balance release speed against test stability?
ProductionSenior▶

Це баланс між швидкістю доставки нових фіч і впевненістю що релізи стабільні. Надто багато тестів уповільнюють pipeline і блокують розробку. Надто мало — пропускають баги в production. Стратегія: тести за рівнями ризику з різними тригерами запуску.

It is a balance between shipping features quickly and being confident the releases are stable. Too many tests slow the pipeline down and block development. Too few let bugs through to production. The strategy: tests grouped by risk level, with different triggers.

Щоб цей баланс не тримався на відчуттях, я закріплюю правила. У pipeline є бюджет часу: перевірки на pull request — до 10 хвилин, інакше розробники почнуть їх обходити. Нестабільний тест не блокує реліз, але й не ігнорується: він іде в карантин з власником і терміном виправлення, а частка flaky-тестів — це окрема метрика команди. Критерії релізу записані заздалегідь: зелений критичний набір, жодних відкритих блокерів, відомі ризики погоджені з продуктом. Коли тиск релізу конфліктує з якістю, я не кажу просто «ні» — пропоную варіанти: випустити за фіче-флагом, звузити обсяг релізу або прийняти ризик письмово з власником рішення.

To keep this balance from running on gut feeling, I set explicit rules. The pipeline has a time budget: pull request checks stay under 10 minutes, otherwise developers start working around them. A flaky test does not block a release, but it is not ignored either: it goes into quarantine with an owner and a deadline for the fix, and the flaky rate is a team metric of its own. Release criteria are written down in advance: a green critical suite, no open blockers, known risks agreed with the product owner. When release pressure conflicts with quality I do not just say no — I offer options: ship behind a feature flag, narrow the scope of the release, or accept the risk in writing with a named owner.

Контекст: e-commerce проєкт — 2-тижневий реліз-цикл, ~4000 тестів, 6 паралельних воркерів
Context: e-commerce project — a two-week release cycle, ~4000 tests, 6 parallel workers
// Рівні захисту замість "запускати все на кожен 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 команди
};
// Tiers of protection instead of "run everything on every PR"
const pipelineStrategy = {
  onPR:        { tags: ['@smoke'],      duration: '~5 min',  workers: 6 },
  onMergeToMain: { tags: ['@critical'], duration: '~15 min', workers: 6 },
  nightly:     { tags: ['@regression'], duration: '<3 h', workers: 6 },
};

// A metric I track weekly, not just pass rate:
const healthMetrics = {
  passRate: '96-100%',
  criticalProdDefects: 0,
  flakyTestsTrend: 'via Allure history (S3, 14 suite folders)',
  aira: 4.7, // team automation health score
};
💡 Лідерський акцент: на цьому питанні чекають не технічну відповідь, а процесну — як приймаєш рішення, коли тиск релізу і якість тестів конфліктують.
💡 The leadership angle: this question is not looking for a technical answer but a process one — how you make the call when release pressure and test quality are in conflict.
🔧 TypeScript / JS патерни в цьому прикладі Архітектурний кейс. Ключові патерни: risk-based testing — пріоритизація за ризиком; pyramid vs trophy — моделі розподілу тестів; @smoke @critical @regression — tag-based strategy; метрики: AIRA score, pass rate, MTTR.
🔧 TypeScript / JS patterns in this example An architectural case. The key patterns: risk-based testing — prioritising by risk; pyramid vs trophy — models for distributing tests; @smoke @critical @regression — a tag-based strategy; the metrics: AIRA score, pass rate, MTTR.
🗣 Як читати вголос Моя стратегія — не запускати весь regression-набір на кожен PR, а розподілити перевірки за рівнями ризику: на PR — тільки smoke-тести, секунди-хвилини, швидкий фідбек розробнику. На merge в main — critical-флоу, які покривають гроші-критичні шляхи типу checkout. Повний regression на 4000 тестів і 6 паралельних воркерів — нічний запуск, без тиску на швидкість релізу. Це дає 0 критичних production-дефектів при 2-тижневому циклі. Окремо я слідкую не тільки за pass rate, а за трендом flaky-тестів через Allure history, яка персистить у S3 — якщо тест періодично падає без зміни коду, це сигнал розслідувати причину до того, як він почне блокувати релізи. AIRA score 4.7 — це наша внутрішня метрика здоров'я автоматизації, яку я звітую стейкхолдерам, щоб рішення "сповільнити реліз заради стабільності" приймались на даних, а не на відчуттях.
🗣 How to say it out loud My strategy is not to run the whole regression suite on every PR, but to spread the checks across risk levels: on a PR, smoke tests only, seconds to minutes, so the developer gets fast feedback. On merge to main, the critical flows — the money-critical paths like checkout. The full regression, 4000 tests on 6 parallel workers, runs overnight, where nothing is waiting on it. That is what gets us zero critical production defects on a two-week cycle. And I track more than the pass rate — I watch the flaky trend through Allure history, which persists in S3, because a test that fails now and then with no code change is a signal to go and investigate before it starts blocking releases. The AIRA score, 4.7, is our internal automation health metric, and I report it to stakeholders so that a decision like "slow the release down for the sake of stability" gets made on data rather than on a feeling.
103 Як ти онбордиш нового QA engineer у команду та підтримуєш стандарти коду? How do you onboard a new QA engineer and keep code standards up?
ProductionSenior▶

Онбординг QA engineer — це не тільки "покажи де лежить код". Треба передати розуміння архітектури тестів, домену (e-commerce), команди і процесів. Хороший онбординг скорочує час до першого корисного PR з місяця до тижня.

Onboarding a QA engineer is not just "here is where the code lives". You have to hand over an understanding of the test architecture, the domain (e-commerce), the team and the processes. Good onboarding cuts the time to a first useful PR from a month to a week.

Лідерський підхід
The leadership approach
// Чеклист 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 проставлені коректно',
];
// PR review checklist for the QA automation team
const prReviewChecklist = [
  'Locator priority: semantic (getByRole/getByLabel) before CSS/XPath',
  'Page Object: new method — is there already a similar one in POFactory?',
  'Assertions: web-first (expect(locator)) instead of assert(await ...)',
  'Test data: SKU from BlockedProductList, not hardcoded from prod',
  'Tags: @smoke/@critical/@regression set correctly',
];
💡 Чому питають це senior/lead: технічна експертиза без здатності масштабувати її на команду не дає того ж impact. Інтерв'юер перевіряє, чи ти думаєш про систему, а не тільки про свій код.
💡 Why they ask this at senior/lead level: technical expertise you cannot scale onto a team does not deliver the same impact. The interviewer is checking whether you think about the system, not just about your own code.
🔧 TypeScript / JS патерни в цьому прикладі const config: TestConfig = { ... } — TypeScript interface для конфігурації; interface TestConfig { maxLines: number; requirePatterns: string[] } — оголошення interface; PR checklist як structured object; string[] — масив рядків.
🔧 TypeScript / JS patterns in this example const config: TestConfig = { ... } — a TypeScript interface for configuration; interface TestConfig { maxLines: number; requirePatterns: string[] } — declaring an interface; a PR checklist as a structured object; string[] — an array of strings.
🗣 Як читати вголос Онбординг я будую навколо реальної кодової бази, а не абстрактної документації: новий інженер з першого дня працює з нашим POFactory і дивиться, як вже реалізовані схожі сторінки, перш ніж писати нову. Стандарти коду підтримую через PR review checklist — це не формальність, а конкретні пункти: чи використано семантичні локатори замість крихких CSS-селекторів, чи перевірив автор, що схожий Page Object метод вже не існує перед тим як дублювати логіку, чи стоять web-first assertions замість ручних await-перевірок, і чи тестові дані беруться з governance-списку, а не захардкоджені з продакшну. Я свідомо роблю чеклист коротким і конкретним, тому що довгий список ніхто не виконує — краще п'ять пунктів, які реально перевіряють на кожному PR, ніж двадцять, які ігнорують.
🗣 How to say it out loud I build onboarding around the real codebase rather than abstract documentation: from day one a new engineer works with our POFactory and looks at how similar pages are already implemented before writing a new one. Standards I hold through a PR review checklist — not as a formality but as concrete points: are the locators semantic rather than brittle CSS selectors, did the author check that a similar Page Object method does not already exist before duplicating the logic, are the assertions web-first instead of manual awaits, and does the test data come from the governance list rather than hardcoded from production. I deliberately keep the checklist short and specific, because nobody follows a long one — five points people actually check on every PR beat twenty that get ignored.