Уяви, що у тебе є фізичний комп'ютер з браузером. Ти відкриваєш 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.
Коли ти пишеш 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.
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.
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
protocol — набір правил обміну даними між програмами; HTTP request — одноразовий запит-відповідь; WebSocket — постійне з'єднання (детально в Q2-Q3); headless — браузер без графічного інтерфейсу, стандарт для CI.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 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.tstop to bottom like any other JS file. Everything at the top level runs straight away: imports, variables, and everytest(...)call. - Registers — this happens during that import, the moment execution reaches the line
test('name', callback). Thetest()call itself does NOT runcallback(the test body withgoto,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.
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.
import { chromium } from 'playwright' — імпорт з базової бібліотеки (не з @playwright/test); test('...', async () => {}) — виклик функції що реєструє callback, а не виконує його одразу; сам callback — це той самий патерн callback з попередньої картки, тільки викликає його не браузерна подія, а Test Runner коли настає час.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.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, localStorageNode.js — середовище де є файлова система, мережа, процеси ОС, npm пакети
Один і той самий JavaScript — два різних середовища виконання
window, document, the DOM, localStorageNode.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 процес (твій тест) Браузер (окремий процес)
// ══════════════════════════════════════════════════════════════════
//
// 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
async function — функція яка може "чекати" (await) інші асинхронні операції; async/await — синтаксичний цукор над Promise, робить асинхронний код схожим на синхронний; { page } — деструктуризація об'єкта (object destructuring): витягуємо конкретне поле з об'єкта; стрілочна функція () => {} — скорочений синтаксис функції.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.page, функції expect. Браузер — це окремий процес де живе JavaScript сторінки: window, document, React, dataLayer. Це два різних середовища які не бачать одне одного напряму. Playwright з'єднує їх через WebSocket — це постійне двостороннє з'єднання яке відкривається один раз на початку тесту і тримається відкритим до кінця. Через цей канал Node.js надсилає браузеру команди і отримує результати назад. Коли ти передаєш функцію в page.waitForFunction() або page.evaluate() — Playwright бере текст цієї функції, відправляє через WebSocket в браузер, і браузер виконує її у себе. Але змінні з Node.js туди не переїжджають — це як скопіювати текст функції на інший комп'ютер, змінні з першого комп'ютера там не з'являться. Тому closure не працює через цю межу, і змінні треба явно передавати як аргументи — тоді Playwright серіалізує їх в JSON і відправить разом з функцією.
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.Щоб зрозуміти 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 — нове з'єднання на кожен запит ─────────────────────────
//
// Клієнт Сервер
// ─────── ──────
// "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
// Ти пишеш звичайний код тесту:
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
// Окрема тема: 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');
});
1. WebSocket між Playwright і браузером — внутрішній, завжди є, через нього Playwright надсилає команди. Це Chrome DevTools Protocol (CDP). Ти його не бачиш, він просто існує під капотом.
2. WebSocket на самій сторінці — якщо сайт використовує WebSocket для реалтайм даних (чат, стрімінг цін). Цей можна слухати через
page.on('websocket').
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').page.on('event', callback) — патерн підписки на події (Observer/EventEmitter): передаємо callback-функцію яка викличеться коли подія відбудеться; ws => — стрілочна функція як callback; ws.on('framereceived', event => {}) — вкладені підписки на події; new DataTransfer() — створення екземпляру класу через new.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.page.on('websocket') і перевіряти вміст повідомлень які приходять від сервера.
page.on('websocket') and assert on the frames coming back from the server.Щоб зрозуміти 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.
// Коли ти пишеш звичайний 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
// Зазвичай 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,
});
page.context().newCDPSession(page) — ланцюжок методів, повертає Promise з CDP client; client.send('Domain.method', params) — надсилає CDP команду, перший аргумент — рядок у форматі "Domain.method", другий — об'єкт параметрів; деструктуризація const { metrics } = await client.send(...) — беремо поле metrics з результату.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.Ми щойно розібрались як 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.
// Ієрархія, яку будує 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
});
{ page }: { page: Page } — деструктуризація з TypeScript анотацією типу; async ({ page }) => {} — стрілочна async функція з деструктуризацією параметра; // коментарі — документують що Playwright робить "за лаштунками"; const — оголошення константи (значення не можна перепризначити).{ 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).Browser — це сам процес браузера, важкий об'єкт, який дорого створювати, тому Playwright переважно перевикористовує один на весь тестовий запуск. Всередині нього BrowserContext — це ізольована сесія, схожа на окреме інкогніто-вікно: у неї свої cookies, localStorage, кеш, дозволи. І вже всередині контексту — Page, конкретна вкладка, з якою ми безпосередньо взаємодіємо через goto, click, fill і так далі. Коли ми пишемо async ({ page }) у тесті, ми не створюємо цю вкладку самі — Playwright робить це автоматично: для кожного тесту піднімає новий BrowserContext, відкриває в ньому одну Page і підставляє її як fixture-аргумент. Анотація типу : Page — це просто TypeScript, який каже компілятору, що це об'єкт класу Page, а не any, щоб автодоповнення й перевірка типів працювали. Саме тому в кожному тесті, кожному Page Object, кожному helper-методі ти бачиш page — це не глобальна змінна, а свіжий ізольований об'єкт, який 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.Фікстура — це готовий ресурс який 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.
// 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 у деструктуризації) — і не думаєш про те, звідки вона взялась і хто її помиє.page in the destructuring) and never think about where it came from or who washes it.async ({}, use) => {} — стрілочна async функція з двома параметрами, перший — порожня деструктуризація (нам не потрібні інші fixtures); await use(value) — use це callback-функція яку ми викликаємо щоб "передати" ресурс в тест і "призупинити" fixture; все що до use() — setup, після — teardown.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.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 }) => {...} і не думає про логін взагалі.
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.page, browser, context — це фікстури: ти їх не створюєш і не знищуєш, Playwright робить це сам.page, browser and context are fixtures: you neither create nor destroy them, Playwright does it for you.// ✅ З фікстурами — ти просто називаєш що потрібно
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
async ({ page }, use, testInfo) => {} — функція отримує три аргументи: інші fixtures, callback use(), і метадані тесту; testInfo.title — доступ до властивості об'єкта через крапку; Date.now() — виклик статичного методу класу; template literal `текст ${змінна}` — рядок з вбудованим виразом.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.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, свій чистий стан.
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.Ми вже знаємо що таке 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.
import { test, expect } from '@playwright/test';
test('should display homepage title', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
const heading = page.locator('h1');
await expect(heading).toBeVisible();
await expect(heading).toHaveText('Example Domain');
});
import { test, expect } from '@playwright/test' — ES modules import з деструктуризацією: беремо конкретні exports з пакету; async ({ page }) => {} — стрілочна async функція; await перед кожною дією — чекаємо завершення асинхронної операції; expect(locator).toBeVisible() — ланцюжок методів (method chaining).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 — і перевіряємо що він видимий і має потрібний текст.
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.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:
- Знайди виклик функції — відкриваючу дужку
( - Шукай всередині дужок
=>або ключове словоfunction - Все що після
=>до відповідної закриваючої)— це callback і його тіло
- Find the function call — the opening parenthesis
( - Look inside the parentheses for
=>or the keywordfunction - Everything from
=>to the matching closing)is the callback and its body
() => {} — стрілочна функція як callback (передається як аргумент, не викликається одразу); async () => {} — async стрілочна функція-callback; setTimeout(fn, ms) — вбудована функція яка приймає callback; forEach(callback) — метод масиву з callback; { page } — деструктуризація параметра callback.() => {} — 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.page.route() другим аргументом іде стрілочна функція — це callback, Playwright викличе її кожного разу коли браузер зробить запит що відповідає паттерну. У page.on('dialog', ...) — Playwright викличе callback коли з'явиться діалог. У test('назва', async ({page}) => {}) — сама функція тесту це теж callback, Playwright викликає її коли запускає тест. Ключова ознака callback: функція передається як аргумент всередині дужок іншої функції і не викликається одразу через круглі дужки після неї. Найпростіший приклад поза Playwright: setTimeout(() => console.log('привіт'), 3000) — ти передала функцію setTimeout, і він сам викличе її через 3 секунди.
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.Твій тест і браузер — це два окремі процеси. Тест виконується в 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).
// 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"
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():
// Пишеш:
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:
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.
(Для простоти поки що вважатимемо, що черга одна. У наступній картці побачимо, що насправді їх дві.)
(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();:
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.
У попередній картці ми для простоти уявляли, що черга одна. Насправді їх дві. Питання не в тому, чим вони відрізняються. Питання в тому, чому однієї виявилось недостатньо.
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.
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:
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
awaitand 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
setTimeoutandsetIntervallive.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.)
Оскільки виклик асинхронної функції завжди синхронний і миттєвий (відправляє команду і одразу звільняє стек) — можна відправити кілька незалежних запитів одночасно, замість того щоб чекати кожен по черзі:
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:
// ❌ послідовно — чекаємо кожен окремо, довше
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.
// ✅ Правильний патерн 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.
- Об'єкт в JavaScript живе в оперативній пам'яті конкретного процесу як структура з адресами —
{ name → адреса 0x7f4b → 'Iuliia' }. Ці адреси мають сенс тільки всередині цього одного процесу. - Мережа і файли передають байти. Якщо відправити адресу
0x7f4b— на іншому комп'ютері вона вкаже на іншу ділянку пам'яті або нікуди. Об'єкт прив'язаний до конкретної пам'яті процесу. - Серіалізація — перетворення об'єкта з "адрес в пам'яті" в текст де значення записані явно. Текст не залежить від конкретної машини.
- Десеріалізація — зворотній процес: текст назад в об'єкт.
- JSON — найпоширеніший формат серіалізації у вебі. Виглядає майже як JavaScript об'єкт, розуміє будь-яка мова програмування і будь-який сервер.
- У Playwright серіалізація відбувається коли передаємо аргументи в
evaluate()абоwaitForFunction()— Playwright перетворює JavaScript об'єкт в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад в об'єкт.
- 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. - Networks and files carry bytes. Send the address
0x7f4band on another machine it points at a different chunk of memory, or at nothing. The object is tied to one process's memory. - 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.
- Deserialisation is the way back: text into an object again.
- 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.
- In Playwright, serialisation happens when we pass arguments into
evaluate()orwaitForFunction()— 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.
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 }
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!
);
JSON.stringify(obj) — статичний метод класу JSON, серіалізує об'єкт в рядок; JSON.parse(text) — десеріалізує рядок назад в об'єкт; { name: 'Iuliia', age: 30 } — об'єктний літерал; серіалізація обмежена: функції, undefined, і посилання на DOM елементи серіалізувати не можна.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.stringify() перетворює об'єкт в рядок, JSON.parse() — назад. У Playwright це важливо при передачі аргументів в evaluate() або waitForFunction(): Playwright серіалізує значення в JSON, відправляє через WebSocket в браузер, браузер десеріалізує назад. Саме тому closure не працює через межу процесів — функція переїжджає в браузер, але змінні з Node.js залишаються в Node.js, і їх треба явно передавати як аргументи через серіалізацію.
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.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.
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]
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
// 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 перезаписує наше)
{ ...defaults, price: 200 } → price is 200 (our value overwrites){ price: 200, ...defaults } → price comes from defaults (defaults overwrites ours)...arr — spread масиву: вставляє елементи; ...obj — spread об'єкта: вставляє поля; { ...obj1, ...obj2 } — злиття об'єктів (merge); { ...obj, key: value } — копіювання об'єкта з додаванням або перезаписом поля; spread не мутує оригінальний об'єкт — створює новий....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.[...arr1, ...arr2] — замість масиву всередині масиву отримуємо плоский масив з усіма елементами. З об'єктами: { ...obj1, ...obj2 } — зливаємо два об'єкти в один, якщо є однакові поля — перемагає той що стоїть правіше. В Playwright найчастіше бачимо в конфігурації: { ...devices['iPhone 14'], locale: 'uk-UA' } — розгортаємо готовий профіль девайсу з усіма його полями і додаємо своє. Це набагато коротше ніж копіювати viewport, userAgent і isMobile вручну.
[...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.В 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.
static methodName() {} — ключове слово static робить метод статичним в TypeScript/JS класі; ClassName.method() — виклик статичного методу на класі; instance.method() — виклик звичайного методу на екземплярі; new ClassName() — створення екземпляру.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. Простий спосіб визначити: якщо перед методом стоїть назва класу з великої літери — статичний. Якщо змінна — звичайний метод екземпляру.
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.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.
// Без 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'); // ✅
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.
}
// 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
try { } catch (e) { } — синтаксис обробки помилок; e.message — рядок з описом помилки; e.name — назва класу помилки (наприклад 'TimeoutError'); finally — блок що виконується завжди незалежно від результату; throw e — перекинути помилку далі (деталі в наступній картці).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 — в наступній картці.
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.Локатор — це спосіб знайти елемент на сторінці. Це не сам елемент, а інструкція "знайди мені ось це". 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.
// ✅ Семантичні — шукають за змістом і роллю, стійкі до змін верстки
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.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.page.getByRole('button', { name: 'Submit' }) — передача об'єкта як другого аргументу (options object pattern); { name: 'Submit' } — об'єктний літерал (object literal) як конфігурація.
page.getByRole('button', { name: 'Submit' }) — passing an object as the second argument (the options object pattern); { name: 'Submit' } — an object literal used as configuration.getByRole шукає за ARIA роллю — наприклад 'button' або 'textbox' — і опційним ім'ям. getByLabel шукає input пов'язаний з лейблом — в HTML це зв'язок через атрибут for у лейблі який вказує на id інпута. getByTestId шукає за атрибутом data-testid який розробники додають спеціально для тестів. CSS і XPath допустимі але крихкіші — якщо верстка зміниться локатор впаде.
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.Коли локатор знаходить більше одного елемента і ти намагаєшся виконати дію — 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.
// ❌ Якщо на сторінці кілька .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();
handle?. — optional chaining: викликаємо метод тільки якщо handle не null/undefined; page.$() повертає ElementHandle | null — union type в TypeScript; const locator = page.locator() — зверни увагу: БЕЗ await, бо locator синхронний; await з'являється тільки на .click().
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().click чи fill, тест одразу падає з явною помилкою, а не мовчки бере перший елемент, як це робив би Selenium. Це навмисний дизайн-вибір: краще явний фейл на етапі написання тесту, ніж прихований баг, коли тест технічно "зелений", але клікає не на той елемент, який малося на увазі. Щоб обійти strict mode правильно, є кілька варіантів: .first() чи .nth(index), якщо порядок елементів стабільний і важливий; .filter({ hasText: ... }), якщо потрібен конкретний елемент за вмістом, що набагато надійніше за індекс; або .count(), якщо нам взагалі потрібна кількість, а не дія над одним елементом. Головний меседж — strict mode це не перешкода, а сигнал, що локатор недостатньо специфічний для того, що ми хочемо зробити.
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.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.// ── 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
// ❌ Старий стиль (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
await page.$() або await page.$$() — це legacy API (документація Playwright називає його discouraged, не deprecated), треба рефакторити на page.locator(). Бачиш await page.locator() — людина переплутала: locator() синхронний, await тут зайвий.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.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.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 і ще не перебудувалась, або написала старий код який треба рефакторити.
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.Перед кожною взаємодією з елементом 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.
// ✅ 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
async ({ page }) => {} — деструктуризація fixtures; { state: 'visible' } — options object pattern; waitForFunction(() => {}) — передача стрілочної функції як аргумент (функція першого класу / first-class function); (window as any) — type assertion в TypeScript: говоримо компілятору "довіряй мені, це any".
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".waitForURL — явно чекаємо поки URL відповідає патерну, зірочки це wildcard. waitForLoadState('networkidle') — чекаємо поки мережа затихне, тобто немає активних запитів більше 500мс. В конфігу timeout: 60_000 — це глобальний ліміт на весь тест у мілісекундах, expect.timeout — окремий ліміт на кожен expect.
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.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.
// ❌ Антипатерн: фіксована пауза "на удачу"
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
await page.waitForTimeout(3000) — фіксована пауза в мілісекундах — АНТИПАТЕРН; await expect(locator).toBeVisible() — правильна альтернатива з auto-waiting; різниця: waitForTimeout завжди чекає 3 секунди, expect чекає рівно стільки скільки потрібно (до таймауту).
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-стану.
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.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.
// ❌ Здається надійним, але офіційно НЕ рекомендовано для 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
]);
networkidle для сучасних SPA — рекомендується чекати конкретний елемент чи мережевий запит, а не "тишу в мережі" загалом.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.waitForLoadState('networkidle') — рядкове enum значення; умова networkidle: жодного мережевого запиту 500мс; проблема: analytics, websocket, polling — постійні запити що не дають "idle" настати; альтернатива: waitForURL() або waitForSelector().
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. Це і швидше, бо не чекає зайвого, і надійніше, бо привʼязано до того, що нам справді потрібно для тесту.
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.Більшість людей думають що в 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 throughuse: { 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 throughuse: { navigationTimeout }. People often miss it — and it is exactly the one to blame whengoto()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_000in 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.
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
},
});
// 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 }) => { /* ... */ });
});
// ── Приклад 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 тільки для нього.
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.
timeout: 60_000 — числовий літерал з underscore separator (для читабельності, ідентичний 60000); expect: { timeout: 5_000 } — вкладений об'єкт в конфігурації; { timeout: 10000 } — options object для перевизначення таймауту локально.
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.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 як першої реакції — це майже завжди неправильно, бо це маскує реальну проблему замість її вирішення.
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.Звичайна перевірка в 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.
// ❌ Одноразова перевірка: 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
// ── 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
expect() передається Locator напряму (expect(locator).toBeVisible()) — це web-first, він сам чекає. Якщо передається вже обчислене значення (expect(await locator.isVisible()).toBe(true)) — це звичайний one-shot assert, ретраїв немає.expect.configure() — дозволяє змінити поведінку expect для конкретного тесту або блоку:
expect.configure({ timeout: 10000 }) встановлює таймаут 10с для всіх assertions після цього рядку. Корисно коли знаєш що певна операція повільна.Soft assertions (
expect.soft()) — перевірка яка не зупиняє тест при падінні: тест продовжується і всі soft-помилки збираються і виводяться разом в кінці. Детальніше в окремій картці.expect() 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.expect(locator).toBeVisible() vs expect(await locator.textContent()) — різниця: перший передає Locator об'єкт (lazy), другий передає результат await (значення вже отримане); await locator.textContent() — спочатку виконується await, потім результат передається в expect.
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-тестів, де причина нестабільності — не логічна помилка, а просто різниця в мілісекундах між тим, коли елемент реально зʼявився, і коли ми його перевірили.
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.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
});
expect.soft(locator) — виклик методу на об'єкті expect (не функції); expect.soft повертає той самий expect API — це fluent interface; test.info().errors — доступ до вкладеної властивості через ланцюжок крапок.
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-помилки виводяться разом в кінці тесту в одному звіті.
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.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 thatdataLayeris 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
// 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', ... }]
});
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');
});
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');
});
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' }
);
// Передаємо аргумент з 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
);
// Для простих умов є зручніша альтернатива — 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()
A condition about a DOM element →
expect(locator).toBeVisible() or another web-first assertionA 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()await page.waitForFunction(() => {}, argValue, { timeout, polling }) — три аргументи: функція, аргумент для передачі в браузер, options; (window as any).dataLayer — type assertion; Array.isArray() — статичний метод; dl.some((e: any) => condition) — Array.some() з предикатом.
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 запит поки не прийде потрібний статус.
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.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 towindowanddocument. It is not a replacement forexpect.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.
// Чекаємо поки 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);
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 всередині.
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).
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).Вбудовані 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.
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('€');
expect.extend({ toBeValidProduct(received: any) {} }) — метод extend приймає об'єкт де ключ це назва matcher, значення — функція; return { pass, message: () => string } — функція повертає об'єкт з методом message; () => string — функція що повертає рядок (lazy evaluation).
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 підказувала наш матчер з автодоповненням.
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.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.
// 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'));
{ mode: 'closed' }) Playwright теж не бачить, як і будь-який інший інструмент — це навмисне обмеження браузера, а не Playwright.{ 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.page.locator('.shadow-host .inner-element') — CSS selector через Shadow DOM; Playwright автоматично пронизує open shadow root; shadowRoot.querySelector() — браузерний API для явного доступу; { force: true } — options object для примусової дії.
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..stars, і Playwright сам розуміє, що шукати треба не тільки в основному документі, а й усередині будь-яких shadow roots на сторінці. Це особливо актуально для сучасних e-commerce платформ, які часто будуються на Web Components чи дизайн-системах із shadow DOM для інкапсуляції стилів. Єдиний виняток — closed shadow root, коли компонент явно створений з { mode: 'closed' }: тоді JavaScript у принципі не має доступу до цього shadow root, і це обмеження самого браузера, а не якесь упущення Playwright — жоден інструмент автоматизації не зможе туди зазирнути.
.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.Ми вже розібрали що таке 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.
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();
});
async ({ page, context, request }) => {} — деструктуризація кількох fixtures одночасно; [{ name: 'session', value: 'abc', url: '/' }] — масив об'єктів (array of objects); await expect(res).toBeOK() — ланцюжок: спочатку отримуємо Response, потім перевіряємо.
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.
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.
Проблема: коли в кожному тесті треба робити одне і те саме — наприклад, відкрити сторінку і залогінитись — ці рядки дублюються скрізь. Кастомна 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.
async ({ page }, use) => {} — стрілочна async функція з деструктуризацією і use як callback; await use(value) — Playwright викличе тест з нашим значенням; ім'я в extend() = ім'я в деструктуризації тесту.
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.
use(), тест всередині, і teardown після. use() — розділювач: Playwright зупиняється на ньому, запускає тест, повертається і виконує teardown навіть якщо тест впав. В use() передаємо що підготували — page, об'єкт, рядок. В тесті пишемо ім'я fixture в деструктуризації — воно збігається з назвою в extend(). Fixture краща за beforeEach коли є teardown або коли треба передати значення в тест. Краща за глобальну змінну бо при паралельному виконанні кожен тест отримує свій власний екземпляр ресурсу.
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.
Кастомні 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); }
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 };
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
});
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).
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 і тепер тести імпортують його замість стандартного.
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.
У @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.
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
});
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
}
},
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
await use(), і він виконується навіть для впалого тесту. try/finally додавай, коли може впасти сам setup або коли ресурсів кілька. yield у фікстурах — це pytest, не Playwright.
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.
base.extend<T>() — типізоване розширення test новими фікстурами; try/finally — блок finally виконується і після return, і після throw; вкладені try/finally прибирають ресурси у зворотному порядку створення.
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.
use. Усе до await use(value) — setup, сам use — це момент, коли виконується тест, усе після нього — teardown. Важливо, що use повертається й тоді, коли тест упав, тож прибирання після нього спрацює в будь-якому разі. try/finally я додаю в двох випадках: коли може впасти сам setup і треба прибрати вже створене, і коли ресурсів кілька — тоді вкладені finally закривають їх у зворотному порядку, навіть якщо закриття одного кинуло помилку. І якщо на співбесіді питають про yield-фікстури, варто одразу уточнити, що це pytest: у Playwright для TypeScript такого синтаксису немає.
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.
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.
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);
});
scope: 'worker' — рядкове значення enum; fixture з scope — один екземпляр на весь worker-процес замість кожного тесту; async ({}, use) => {} — порожня деструктуризація коли інші fixtures не потрібні; await db.close() — cleanup після yield/use.
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 і всі тести в ньому отримують той самий екземпляр.
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.
Звичайна фікстура виконується тільки якщо ти явно вказала її в параметрах тесту:
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.
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"
});
// 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
});
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..."
});
// 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
});
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
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
});
// ❌ Якщо імпортувати з @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();
});
beforeEach → простий setup в межах одного файлу, не потрібен teardownauto: true fixture → setup + teardown в одному місці, застосовується до всіх тестів які використовують цей test.extend(), не треба повторювати в кожному файлі
beforeEach → simple setup inside a single file, no teardown neededauto: true fixture → setup + teardown in one place, applied to every test that uses this test.extend(), no need to repeat it in each file
base.extend({ autoLogger: [async ({ page }, use, testInfo) => {}, { auto: true }] }) — масив де перший елемент — async функція, другий — конфігураційний об'єкт; { auto: true } — об'єктний літерал як конфігурація; page.on('load', async () => {}) — async callback для події.
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 напряму.
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.
Ми вже знаємо як виконати одну дію в тесті. Але коли тестів багато — кожен з них потребує схожого підготовчого кроку: відкрити певну сторінку, залогінитись, підготувати тестові дані. Писати це в кожному тесті окремо — дублювання. Саме для цього існують хуки.
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.
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');
});
});
test.beforeEach(async ({ page }) => {}) — hook: функція що виконується перед кожним тестом; test.beforeAll(async () => {}) — hook без деструктуризації (нам не потрібна page); хуки — це callbacks які Playwright викликає автоматично у визначений момент.
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 — прибираємо тестові дані після всього блоку.
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.
Коли є один 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
beforeAllruns for every test, nested ones included — even when the setup is only needed by the outer tests - Problem 2: if the outer
beforeEachsets some state and the inner one overrides it, the outer one still runs first - Problem 3: the outer describe's
afterAllwaits for EVERY nested describe to finish, their ownafterAllincluded
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
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();
});
});
});
// ❌ Типова помилка: думаємо що зовнішній 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 — один раз на весь describebeforeEach і afterEach — для кожного тесту окремо, але той самий принцип зовнішній→внутрішній
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 describebeforeEach and afterEach — once per test, but the same outer→inner principle
test.beforeAll(() => console.log('1. outer beforeAll')) — стрілочна функція без async (якщо не потрібен await); console.log() — виклик методу глобального об'єкту console; числа в рядках для демонстрації порядку виконання.
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.
beforeEach це означає що зовнішній виконується першим, потім внутрішній, потім тест, потім внутрішній afterEach, і тільки потім зовнішній afterEach. Для beforeAll важлива пастка: він виконується один раз але для ВСІХ тестів в describe включно з вкладеними — тобто якщо ти зробила дорогий setup в зовнішньому beforeAll, він спрацює і перед тестами вкладеного describe, навіть якщо вони цього setup не потребують. Рішення: якщо setup потрібен тільки для певної групи тестів — перенести його у beforeAll того конкретного вкладеного describe, а не тримати у зовнішньому. afterAll зовнішнього describe чекає поки завершаться абсолютно всі вкладені describe включно з їх власними afterAll, тому teardown в зовнішньому afterAll виконується дійсно в самому кінці.
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.
Природний рефлекс: зберегти спільне значення в глобальну змінну. В 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".
// ❌ Очікування: лічильник рахує всі тести по черзі
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 }));
fullyParallel: false (за замовчуванням) — один файл = один worker, тести всередині файлу послідовно, різні файли паралельно в різних workers.fullyParallel: true — кожен тест може отримати свій worker, навіть тести з одного файлу.Worker — це окремий Node.js процес (не потік!), тому змінні між workers не шаряться.
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.
let sharedState = 0 — змінна в module scope; sharedState++ — increment оператор; кожен тест — окремий Node.js worker process: змінна існує тільки в межах свого процесу; workerInfo.workerIndex — доступ до індексу worker через об'єкт testInfo.
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.
let testCounter = 0, в кожному воркері існує як власна, ізольована копія в пам'яті того процесу: зміна цієї змінної в одному файлі ніяк не видна іншому файлу, навіть якщо обидва імпортують той самий модуль. Це не баг, а навмисний дизайн — ізоляція процесів означає, що один тест не може випадково зламати стан іншого, навіть якщо в коді є помилка з мутацією спільних об'єктів. Якщо реально потрібен спільний стан між тестами — наприклад, лічильник чи прапорець "цей ресурс вже створено" — треба використовувати щось зовнішнє відносно Node.js процесу: файл на диску, запис у базі даних, чи API-виклик до сервісу, тому що тільки зовнішнє сховище буде дійсно одним і тим самим для всіх воркерів одночасно.
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.
Для більшості випадків плоска структура з одним 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.
// ✅ Один 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');
});
});
// Спільний 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 }) => { /* ... */ });
});
});
// ❌ Без вкладеного 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 */ });
});
Є 2+ тести які потребують однакового setup з кількох кроків → вкладений describe
Setup відрізняється для різних груп тестів → окремий вкладений describe для кожної групи
Тільки один тест у групі → не варто, просто напиши все в тесті
Setup з одного кроку → теж зазвичай не варто, напиши прямо в тесті
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
test.describe('outer', () => { test.describe('inner', () => {}) }) — вкладені виклики функцій; test.beforeEach(async ({ page }) => {}) — hook з async callback; scope вкладеності: внутрішній beforeEach "бачить" зовнішній через closure.
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.
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.
// За замовчуванням: тести незалежні, порядок не гарантований,
// фейл одного НЕ впливає на інші
// ✅ 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
});
});
test.describe.serial('назва', () => {}) — виклик методу на вкладеному об'єкті describe; .serial — property access на test.describe; serial mode: тести виконуються послідовно і якщо один впав — наступні автоматично skip; виняток з правила ізоляції [Якір 1].
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].
test.describe.serial() — це навмисний, явний виняток із цього правила: всередині такого блоку тести виконуються строго по порядку, в одному й тому самому worker, і якщо один тест впаде — всі наступні в цьому ж serial-блоці автоматично позначаються як skipped, а не запускаються взагалі. Це корисно, коли кроки логічно є частинами одного бізнес-флоу, наприклад покроковий checkout, де немає сенсу перевіряти "підтвердження замовлення", якщо саме замовлення не було створено — простіше явно показати залежність через serial, ніж штучно ділити це на один величезний тест. Але це варто використовувати рідко й усвідомлено, бо кожен serial-блок втрачає головні переваги звичайних тестів — паралелізм і незалежність, тому якщо serial зустрічається часто в кодовій базі, це зазвичай сигнал, що тести варто перепроєктувати на менш зв'язані одиниці.
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.
За замовчуванням 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).
// Без 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)
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:
// Якщо 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 }) => { /* ... */ });
// Приклад: ~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
Вмикай якщо: тести добре ізольовані (окремі fixtures, немає спільного стану), файли великі і нерівномірні за розміром, хочеш максимально завантажити всі workers.
Не вмикай якщо: є багато
test.describe.serial() блоків, або тести залежать від порядку виконання — fullyParallel не зламає їх але й не пришвидшить.
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.
fullyParallel: true — булева конфігурація; workers: process.env.CI ? 4 : undefined — тернарний оператор; undefined — означає "використай значення за замовчуванням"; test.describe.configure({ mode: 'serial' }) — виклик на вкладеному об'єкті.
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.
fullyParallel: true прибирає це обмеження: тепер кожен окремий тест незалежно від файлу може піти на вільний worker, як задачі в черзі — workers беруть наступну задачу як тільки звільняються. Кількість workers залишається тою самою — змінюється тільки гранулярність розподілу. Якщо тести правильно ізольовані, як і має бути за Якорем 1, fullyParallel дає відчутне прискорення особливо коли файли нерівномірні за розміром. Але якщо є залежні тести в файлі — для них треба явно вказати test.describe.configure({ mode: 'serial' }), тоді цей конкретний блок буде захищений від паралельного виконання навіть при глобальному fullyParallel.
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.
В 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.
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();
});
// В одному файлі тестуємо і адміна і звичайного юзера
// Без 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();
});
});
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('¥');
});
});
// 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 settingsA 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
test.use({ locale: 'fr-FR', timezoneId: 'Europe/Paris' }) — виклик методу з options object всередині describe; { ...devices['iPhone 14'] } — spread operator для розгортання профілю; test.use() на верхньому рівні describe — важливо: НЕ всередині test().
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 залишиться глобальним.
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.
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.
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();
});
browser.newContext({ storageState, viewport, locale }) — factory method: метод що створює новий об'єкт; options object з кількома полями; await context.close() — явне закриття ресурсу (важливо для уникнення memory leak).
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(), інакше вони залишаться в пам'яті.
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.
Якщо кожен тест починає з логіну — це повільно і крихко. Логін займає 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 intests/auth.setup.ts, then__dirnameis the project'stestsfolder. You never type it by hand, Node.js knows it...— means "step one folder up". From the project'stestsfolder that gets you to the project root.path.join()— glues the pieces of a path together: the project'stests+..+.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.
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 });
});
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'],
},
]
await context.storageState({ path: '.auth/user.json' }) — зберігаємо стан в файл; use: { storageState: '.auth/user.json' } — конфігурація через рядковий шлях; test.use({ storageState }) — виклик методу на об'єкті test з об'єктом конфігурації.
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, потім вже основні тести зі вже встановленою сесією.
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.
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.
// Встановити значення
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());
// Встановити значення — без 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();
page.localStorage — об'єкт з методами setItem/getItem/removeItem/clear/keys (Playwright 1.61+); page.evaluate(() => localStorage.setItem(...)) — старий спосіб через виконання коду в браузері; обидва підходи асинхронні — потребують await.
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.
page.evaluate() де ми запускаємо код всередині браузера. Playwright 1.61 додав прямий API page.localStorage з тими самими методами що й нативний localStorage: setItem, getItem, removeItem, clear. Тепер не треба писати evaluate — просто await page.localStorage.setItem('key', 'value'). Обидва підходи асинхронні.
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.
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.
// Підпишись і забудь — код не зупиняється, нова вкладка в 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
// 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 встановлено ДО кліку — інакше подія може прийти раніше і ти її пропустиш.
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.
const [newPage] = await Promise.all([context.waitForEvent('page'), page.click(...)]) — Promise.all: виконуємо два Promise паралельно і чекаємо обидва; деструктуризація масиву [newPage] — беремо перший елемент результату.
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 з усіма методами.
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.
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.
// Через 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();
page.frameLocator('iframe') повертає FrameLocator — рекомендований сучасний API з auto-waiting і chaining, як звичайний Locator. page.frame({ name: 'payment' }) — старіший API, повертає Frame | null (потребує null-check), не має auto-waiting, але дає прямий доступ до Frame для низькорівневих операцій. У більшості тестів використовуй frameLocator().
page.frameLocator('iframe') 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 один за одним.
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.
Після того як ми розібрали хуки 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.
// Обробник ТРЕБА додати ДО дії що викликає діалог
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();
page.on('dialog', async (dialog) => {}) — підписка на подію з async callback; важливо: підписка ДО goto(), бо подія може спрацювати під час завантаження; dialog.accept() / dialog.dismiss() — методи об'єкта.
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. Тільки після того як обробник встановлено — клікаємо кнопку що викличе діалог.
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.
Форми — один з найчастіших сценаріїв в 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.
// Форма
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');
page.locator('#promo').fill('CODE') — method chaining; page.setInputFiles('[type=file]', 'path/file.pdf') — рядковий селектор + шлях як рядок; ['file1.pdf', 'file2.pdf'] — масив рядків для множинного завантаження.
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-селектори: звідки перетягнути і куди.
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.
До цього ми вивчали як чекати стан елемента на сторінці — 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
Pagethat didn't exist a moment ago; no locator will find it. - dialog — a native
alert(),confirm()orprompt()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.
Інстинктивно хочеться написати: спочатку клікнути, потім чекати подію. Але клік може спрацювати так швидко що download вже почався і завершився ще до того як ти підписався на
waitForEvent — і Playwright її просто пропустить.Правильний патерн — завжди навпаки: спочатку підписуємось на очікування події (Promise), потім виконуємо дію яка її тригерить. Playwright починає "слухати" ще до кліку і нічого не пропустить.
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.
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!
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();
});
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();
});
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 результату, а не навпаки.
waitForEvent('download') → a file starts downloadingcontext.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 opensAnd always: subscribe → act → await the result, never the other way round.
const [download] = await Promise.all([page.waitForEvent('download'), page.click(...)]) — деструктуризація масиву результату Promise.all; Promise.all() — виконує масив Promise паралельно; race condition патерн: спочатку підписка, потім дія.
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() замість реального вибору файлу в системному діалозі.
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.
У попередньому питанні ми говорили про 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 everyconsole.log,console.warnandconsole.errorfrom 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, anythrowthat notry/catchcaught. 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')andpage.on('response')— catches every network request and response. Different frompage.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.
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);
});
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);
});
}
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') — пасивне спостереження. Бачимо запит, але не можемо його змінити чи заблокувати.page.route() — активне втручання. Можемо перехопити запит і повернути мок-відповідь, змінити headers, або заблокувати запит взагалі.Правило: якщо треба перевірити що запит відбувся →
page.on. Якщо треба замінити відповідь → page.route.
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.
page.on('console', (msg) => {}) — підписка до goto(); msg.type() — виклик методу на об'єкті події; const errors: Error[] = [] — TypeScript масив з типом; errors.push(error) — додавання до масиву всередині closure.
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(), бо події що відбулись до підписки вже не повернути.
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.
У 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.
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.
// 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
// 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() і підключити власний апарат прямо до лінії.
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.
new Promise(resolve => {}) — створення Promise вручну: resolve це функція яку викликаємо коли хочемо "зрезолвити" Promise; підміна методу: obj.method = newFn — замінюємо оригінальну функцію своєю; orig(...args) — виклик оригінальної функції з усіма аргументами через spread; args[0]?.event — optional chaining на елементі масиву.
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.
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 повідомляє тест коли прийшла потрібна подія.
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.
Всі класи 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.
// ── 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
// ── 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
// ── 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
// ── 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
// ── 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)
From fixtures, automatically:
page, context, browser, requestFrom 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)
async method(): Promise — метод що повертає Promise; string[] — масив рядків; boolean — булевий тип.
Browser це сам процес, дорогий і один на прогін; BrowserContext це ізольований профіль зі своїми cookies і дозволами, новий для кожного тесту; Page це вкладка, основний об'єкт з яким ти працюєш. Друга — взаємодія: Locator це рецепт пошуку з auto-waiting, а не посилання на DOM-вузол; Frame це iframe всередині сторінки, доступний через frameLocator(). Третя — мережа: Request і Response зʼявляються через page.on() і page.route(); APIRequestContext це HTTP клієнт без браузера, доступний через fixture request; Download зʼявляється через waitForEvent('download'). Четверта — низькорівневий ввід: Keyboard і Mouse через page.keyboard і page.mouse — коли звичайний click() і fill() недостатньо, наприклад для drag-and-drop або keyboard shortcuts. П'ята — браузерні події: Dialog, ConsoleMessage і Worker — всі зʼявляються як аргументи в page.on(). Головне що треба памʼятати: більшість класів не створюються через new вручну — вони приходять або як fixtures, або як результати методів і подій.
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.
Коли 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.
// Напрямок: 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);
// Напрямок: Браузер → 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');
// Напрямок: 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() — ти приходиш і переналаштовуєш браузер ще до того як сторінка увімкнулась. Одноразово, але дуже рано.
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.
await page.evaluate((arg) => { return window.fn(arg) }, nodeValue) — функція з аргументом що серіалізується через межу процесів; await page.exposeFunction('name', async (arg) => {}) — exposeFunction приймає рядок (ім'я) і async функцію; closure не працює через межу процесів.
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.
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 — сторінка завантажується вже з нашими підміненими значеннями і ніколи не бачить "оригінальних".
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.
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 testaddStyleTag()— 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.
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' });
// Замість повторювати 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 }],
});
// ⚠️ Важливо: для більшості завдань 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();
});
// ── 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 }' })
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 }' })
await page.addStyleTag({ content: `...` }) — template literal (backtick) для багаторядкового рядка CSS; await page.addScriptTag({ content: `window.fn = function() {}` }) — ін'єкція JS через template literal; !important — CSS специфічність, не JS.
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 щоб вимикання анімацій відбувалось автоматично для всіх тестів без повторення в кожному.
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.
В 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.
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/);
});
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');
});
// 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({ colorScheme: 'dark' })Тестуєш print-версію сторінки →
emulateMedia({ media: 'print' })Хочеш стабільніші тести без анімацій →
emulateMedia({ reducedMotion: 'reduce' })Глобально для всього проєкту →
use: { colorScheme: 'dark' } в playwright.config.ts
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
await page.emulateMedia({ colorScheme: 'dark' }) — options object з рядковим enum; await page.evaluate(() => getComputedStyle(el).opacity) — стрілочна функція що виконується в браузері і повертає значення; getComputedStyle() — браузерний API.
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 для всього проєкту.
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.
Уяви що тестуєш сайт який показує "Доставка доступна у вашому місті" — і визначає місто через 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).
// Сценарій: сайт показує "Доставка доступна у Лондоні" якщо юзер у Лондоні
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();
});
// Без 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
});
// Сценарій: акція "знижка 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
await context.setGeolocation({ latitude: 51.5, longitude: -0.1 }) — options object з числовими полями; await context.grantPermissions(['geolocation']) — масив рядків; timezoneId: 'Europe/Kyiv' — IANA timezone string; всі налаштування на рівні context.
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.
BrowserContext дозволяє встановити все це явно при створенні: передаємо geolocation з конкретними координатами, permissions з масивом дозволів які вже "видані", і timezoneId у форматі IANA назви timezone. Важливо розуміти чому саме context, а не page: context — це "профіль браузерного вікна", одне вікно має одну геолокацію і один timezone для всіх своїх вкладок. Якщо треба протестувати два різних міста паралельно — створюємо два різних context, кожен зі своєю page, і вони абсолютно ізольовані одне від одного, бо це Якір 1 — ізоляція через context. На практиці для великих multi-domain e-commerce проєктів зручніше встановити timezoneId і locale в playwright.config.ts на рівні всього проєкту — тоді результати тестів однакові незалежно від того де фізично запущений CI-сервер.
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.
Уяви що тестуєш функцію "сесія закінчується через 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.
// Сценарій: сесія закінчується через 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');
});
// Сценарій: банер "Акція діє ще 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');
});
// Є три способи керувати часом, і вони різні:
// 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 виклики що відбулись під час завантаження сторінки вже пройшли з реальним часом, і мокання буде неповним.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.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.
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.
page.clock вирішує це елегантно: він повністю замінює годинник всередині браузера на підконтрольний нам. Ми встановлюємо фіксований час через clock.install() до того як заходимо на сторінку, і після цього браузер думає що зараз саме цей час — new Date() повертає його, всі setTimeout і setInterval відраховують від нього. Потім ми викликаємо fastForward('31:00') — і Playwright миттєво просуває час на 31 хвилину, виконуючи всі таймери які мали б спрацювати за цей період, без жодного реального очікування. Є три методи керування: fastForward і tick — обидва просувають час і виконують таймери, різниця лише в форматі аргументу; setSystemTime просто зсуває годинник без виконання таймерів — корисно коли треба лише щоб Date.now() повертав інше значення. Ключовий нюанс — install() завжди до goto(), бо інакше частина ініціалізаційного коду сторінки вже виконалась з реальним часом.
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.
У браузері насправді два незалежні годинники. 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 onepage.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.clockdoesn'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.
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"
// ❌ Варіант 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');
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.
// 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();
const seenHrefs = new Set() — TypeScript generic: Set означає "Set який містить тільки string"; seenHrefs.add(href) і seenHrefs.size — методи і властивості Set; while (seenHrefs.size < totalCount) {} — цикл while з умовою.
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.
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.
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.
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',
},
});
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();
});
// Якщо треба ТЕСТУВАТИ саму 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()
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 wayTesting the PWA behaviour itself (offline, caching, push) → leave it on
'allow' and work through page.on('worker') and context.setOffline()
serviceWorkers: 'block' — рядкове enum значення в конфігурації; page.on('worker', worker => {}) — підписка на появу worker; navigator.serviceWorker.ready.then(() => true) — Promise chain у браузерному коді; context.setOffline(true) — булевий аргумент.
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.
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 коректно відповідає з кешу.
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.
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.
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();
});
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();
});
context.setOffline(true) — метод на рівні контексту (не page); await page.reload() — перезавантаження сторінки; page.request.get(url) — HTTP запит через Playwright без браузера; manifestUrl! — non-null assertion в TypeScript: кажемо компілятору що значення точно не null.
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.
context.setOffline(true) для симуляції офлайн режиму — завантажуємо сторінку онлайн щоб Service Worker скешував ресурси, потім вимикаємо мережу і перевіряємо що сторінка все одно показує контент. Також можна перевірити наявність і вміст Web App Manifest через звичайний HTTP запит і DOM перевірку наявності link[rel="manifest"].
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"].
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 calledroute.continue()— send the request on, but you can change the headers or the body firstroute.abort()— block the request entirelyroute.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.
// Замінити відповідь 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());
});
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: ** означає "будь-яка кількість символів включно з /".
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', ...) — лише спостереження без зміни запиту.
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.
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().
// Реальний сервер не викликається — відповідь повністю вигадана
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
// Запит іде на реальний сервер, але з нашими зміненими 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',
},
});
});
// "Людина посередині": запит пішов на сервер, відповідь прийшла до нас,
// ми її змінили і віддали браузеру
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
});
});
// Блокуємо всі запити до аналітики — тести не залежать від сторонніх сервісів
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' })
);
// Різні правила для різних 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()
You need stable test data →
route.fulfill() — a full mockYou need to add a header or a token →
route.continue({ headers }) — modify the requestYou need to change the response but keep the real data →
route.fetch() + route.fulfill()You need external dependencies gone (analytics, ads) →
route.abort()
await page.route('**/api/**', async route => {}) — async callback для route handler; const response = await route.fetch() — await на fetch всередині route handler; const body = await response.json() — другий await; body.items.push({ ... }) — мутація масиву в об'єкті.
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 який збігається.
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.
Без перехоплення відповідь іде напряму від сервера в браузер — ти її не бачиш і не можеш змінити. Щоб змінити відповідь — треба спочатку "забрати" її собі, а потім явно віддати браузеру.
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.
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
});
});
// Реальний кейс: сервер не завжди повертає тестовий продукт
// Якщо є — пропускаємо без змін. Якщо немає — підміняємо моком.
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().
await route.fetch() — async виклик що повертає Response об'єкт; await response.json() — другий await для читання тіла (воно теж асинхронне); body.items.push() — мутація масиву всередині об'єкта; JSON.stringify(body) — серіалізація назад в рядок перед передачею.
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 перехопив запит але так і не дав відповідь. Думай про це як "людина посередині": запит пішов на сервер, відповідь прийшла до нас, ми її прочитали і змінили, і тільки потім передали браузеру.
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.
Якщо одна і та сама логіка перехоплення потрібна в багатьох тестах — не треба повторювати її в кожному. Виноситься в 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.
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
});
// ✅ Варіант 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 — логіка потрібна тільки в конкретних тестах або файлах
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
base.extend({ fixtureName: [async fn, { auto: true }] }) — масив де перший елемент функція, другий конфігурація; await use(undefined) — передаємо undefined бо fixture нічого не повертає в тест; ?.length — optional chaining: якщо items не існує — повертає undefined а не кидає помилку.
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 в параметрах тих тестів де він потрібен.
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.
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.
// Цей тест запускаємо ОДИН РАЗ щоб записати реальні відповіді
// 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
});
// Тепер 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();
});
// За замовчуванням якщо запит не знайдений в 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
// 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');
});
1. Запускаєш тест з
update: true один раз — HAR файл записаний2. Комітиш HAR файл у репозиторій разом з тестами
3. В CI тести використовують HAR — жодних реальних запитів до зовнішніх API
4. Коли API змінилось — перезаписуєш HAR: знову запускаєш з
update: trueHAR файл — це "знімок API" який живе в git разом з кодом тестів.
1. Run the test with
update: true once — the HAR file is recorded2. 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 againA HAR file is a "snapshot of the API" that lives in git next to the test code.
await page.routeFromHAR('file.har', { update: true, url: '**/api/**' }) — options object; { update: true } — булевий прапор; notFound: 'fallback' — рядкове enum; glob pattern **/api/** — wildcard для URL matching.
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.
page.routeFromHAR() має два режими. З update: true — це запис: Playwright робить реальні мережеві запити і паралельно зберігає їх у файл. Цей режим запускається один раз. Без update: true — це відтворення: Playwright перехоплює мережеві запити і замість реальних викликів повертає збережені відповіді з файлу, тобто зовнішнє API взагалі не викликається. Головна перевага перед ручним page.route() з fulfill() в тому що не треба описувати мок-відповідь вручну — HAR автоматично зберігає реальну відповідь з усіма деталями. Особливо цінно для складних API типу платіжних шлюзів де структура відповіді складна і важко відтворити її руками. Workflow простий: записуєш HAR один раз, комітиш у репозиторій, і тепер CI-тести не залежать від зовнішніх сервісів. Коли API змінилось — перезаписуєш HAR знову з update: true.
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.
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+.
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');
});
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.
page.on('websocket') — watch the real WebSocket, the server answers with real datapage.routeWebSocket() — mock the WebSocket completely, no real server needed. Useful for testing real-time UI without depending on the backend.
page.on('websocket', ws => {}) — підписка на подію; ws.on('framereceived', event => {}) — вкладена підписка; page.routeWebSocket(url, handler) — перехоплення WebSocket з'єднання (Playwright 1.48+); ws.onMessage() — слухаємо повідомлення від браузера; ws.send() — надсилаємо повідомлення браузеру.
page.on('websocket', ws => {}) — 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.
page.on('websocket'): підписуємось на події ДО навігації, отримуємо об'єкт ws і слухаємо framereceived — кадри від сервера. Другий підхід — page.routeWebSocket() доданий в Playwright 1.48: перехоплює WebSocket з'єднання до вказаного URL і дає повний контроль — можна слухати повідомлення від браузера через ws.onMessage() і надсилати мок-відповіді через ws.send(). Реальний сервер не потрібен. Також доступно на рівні context через context.routeWebSocket() — спрацьовує для всіх сторінок в контексті.
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.
Не все треба тестувати через браузер. Якщо потрібно перевірити що 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 independentAPIRequestContext. 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.requestis 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.
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! },
});
const response = await request.get('/api/products') — await на методі що повертає Promise; await response.json() — другий await для читання тіла відповіді; expect(response).toBeOK() — перевірка статус коду через matcher; await response.text() — альтернативний метод для текстової відповіді.
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 є числом будь-якого значення.
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.
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.
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' }] }
});
});
// Використовується коли потрібно перехопити подію ДО того як 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' });
});
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() повертає перший елемент що відповідає умові.
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 спрацює.
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.
Тест падає в 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 passedtrace: '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 runtrace: 'off'— вимкненоtrace: 'off'— off
// 1. Локальна репродукція: запускаємо headless, як в CI (не headed!)
// npx playwright test login.spec.ts --headed=false --workers=1
// 2. Вмикаємо trace ЗАВЖДИ при retry (типове налаштування для CI)
// playwright.config.ts:
export default defineConfig({
use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' },
});
// 3. Точкова зупинка прямо в коді тесту (працює тільки headed/--debug)
await page.pause(); // відкриває Playwright Inspector, можна крокувати вручну
// 4. Аналіз trace.zip з CI артефактів — без повторного запуску
// npx playwright show-trace trace.zip
// показує DOM-снепшоти, мережу, консоль і timeline КОЖНОЇ дії
// 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: записує твої дії в браузері як код ────────────────
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.
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();
});
PWDEBUG=1 npx playwright test — встановлення environment variable перед командою; --headed / --slowmo — CLI флаги; trace: 'on' — рядкове enum значення в конфіг об'єкті.
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.trace: 'retain-on-failure' зберігає Playwright trace тільки для тестів, що впали, і його можна відкрити локально командою show-trace — це дає повний timeline, мережеві запити, DOM-снепшот до і після кожної дії, консольні логи, без потреби перезапускати щось у CI. Якщо я підозрюю конкретний момент у тесті, додаю page.pause() — це відкриває Playwright Inspector і дозволяє покроково виконувати дії вручну, дивлячись, що саме відбувається на сторінці. Найчастіші причини розбіжності CI проти локального — це швидкість рендеру, бо CI-контейнер повільніший, інший viewport за замовчуванням, або відсутні env-змінні, які локально підхоплюються з .env, а в CI мають бути явно прописані як secrets.
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.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.
// Скриншот вручну
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
{ path: 'screenshot.png', fullPage: true } — options object з булевим полем; await page.locator('.hero').screenshot() — method chaining: спочатку знаходимо елемент, потім робимо screenshot; use: { screenshot: 'only-on-failure' } — конфігурація через рядкові enum-значення.
{ 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.
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.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.
// Глобально в 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 () => { /* */ });
retries: process.env.CI ? 2 : 0 — тернарний оператор condition ? valueIfTrue : valueIfFalse; process.env.CI — доступ до змінної оточення (environment variable) через глобальний об'єкт Node.js; test.fail() — виклик методу без аргументів.
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.
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.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.
// ❌ 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
// ❌ 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
]);
Як уникати в Playwright:
— Замість
await locator.textContent() → await expect(locator).toHaveText()— Замість "клік потім слухай" →
Promise.all([слухай, клікай])— Замість
waitForTimeout(1000) → waitForSelector() або web-first assertion
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 assertionPromise.all([p1, p2]) — запускає два Promise одночасно і чекає обидва; порядок в масиві визначає порядок підписки але не порядок виконання; const [newPage] = await Promise.all([...]) — деструктуризація масиву результатів; race condition — це про непередбачуваний порядок асинхронних операцій.
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.Коли тест падає — 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 onclick(), the expect timeout ontoBeVisible(), 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.
// 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?
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();
});
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);
});
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
}
});
// ❌ Антипатерн 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 — запитай себе: "чи це легітимна опціональна поведінка або я намагаюсь обійти нестабільний тест?" Якщо друге — треба фіксити тест, а не ловити помилку. Якщо перше — лови тільки конкретний клас і завжди перекидай решту.
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.
try { ... } catch (e) { if (e instanceof errors.TimeoutError) { ... } else { throw e; } } — try/catch з instanceof перевіркою типу; instanceof — оператор перевірки типу об'єкту; throw e — повторне кидання помилки; new Error(`message`) — створення Error з template literal.
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.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.
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.Реальний кейс з 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.
// ❌ Було: рахуємо елементи в 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
flaky — нестабільний тест (то проходить то падає); virtualization — рендеринг тільки видимих елементів; dataLayer — масив подій Google Tag Manager; source of truth — єдине надійне джерело даних.
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..product-tile елементи напряму, був недетермінованим — результат залежав від того, скільки встигло відрендеритись. Рішення: замість перевірки DOM я перехоплюю dataLayer.push через page.addInitScript ще до завантаження сторінки, підміняючи метод push так, щоб кожна подія додатково передавалась у Playwright через exposeFunction. GTM фіксує бізнес-подію plp_view з повним масивом items одразу при завантаженні, незалежно від того, що зараз у viewport. Це дало стабільний source of truth і прибрало клас flaky-тестів повністю, а не одну симптоматичну дефектну точку.
.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.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
rootlocator 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.
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');
});
class ProductPage { constructor(page: Page) {} } — TypeScript клас з constructor; private readonly page: Page — модифікатори доступу (private) і незмінності (readonly); this.page — посилання на екземпляр класу; new ProductPage(page) — створення екземпляру через new.
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) і викликаємо методи.
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.
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.
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
});
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
await test.step('назва', async () => {}) — test.step приймає рядок і async callback; всі дії всередині callback — це логічний крок; вкладені степи — callbacks всередині callbacks.
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.
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.
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.
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 }) => { /* ... */ });
}
const testData = [{ ... }, { ... }] — масив об'єктів; testData.forEach(({ sku, name }) => { test(...) }) — forEach з деструктуризацією параметра callback; динамічна генерація тестів: test() викликається всередині forEach — тести створюються під час збору (collection phase).
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 зареєструє стільки тестів скільки елементів у масиві — кожен з унікальною назвою. Це простіший підхід ніж параметризовані тести в інших фреймворках.
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.
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.
// Проблема: тести падали, бо 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`
);
}
}
const BLOCKED_SKUS = new Set(['sku1', 'sku2']) — Set зі списку рядків; BLOCKED_SKUS.has(sku) — O(1) пошук в Set; if (!BLOCKED_SKUS.has(sku)) { ... } — заперечення boolean; allowlist vs blocklist — патерни дозволу/заборони.
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.
BlockedProductList у зовнішньому PIM: список SKU, зарезервованих за конкретними suite, з полем lockedFields, яке каже, які саме атрибути товару не можна чіпати. Функція assertProductIsGovernanceLocked запускається до того, як тест почне використовувати товар, і явно фейлить з людським повідомленням, якщо SKU не зареєстрований — це перетворює мовчазний flaky-фейл на явну й зрозумілу помилку конфігурації, яку легко виправити.
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.
Не всі тести треба запускати завжди. 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 @smokeworks 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.tagsas 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.
// Теги в назві тесту (офіційний підхід з 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)
test('@smoke перевірка', ...) — тег в рядку назви тесту; --grep '@smoke' — регулярний вираз в CLI; test.describe.configure({ tag: '@critical' }) — конфігурація рівня describe; --grep-invert — заперечення grep.
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 — інвертує: запускаємо все крім позначеного.
@ 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.
Коли є велика кодова база тестів, виникають ситуації які не вкладаються в бінарну логіку "тест проходить / тест падає": є відомий баг і тест правильно його описує але ми не хочемо його видаляти; тест нестабільний тільки на певній ОС; тест займає 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.
// Простий 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');
// ...
});
// 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');
// ...
});
// 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!
// 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()
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()
test.skip(condition, 'reason') — виклик з булевою умовою і рядком; test.fail() — виклик без аргументів; process.env.CI — environment variable; ({ browserName }) => browserName === 'firefox' — стрілочна функція з деструктуризацією що повертає boolean.
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.
test.skip() пропускає тест і позначає його як "skipped" у звіті — він може приймати умову, наприклад isMobile або browserName === 'firefox', що робить його ситуативним а не глобальним. test.fixme() семантично відрізняється: це не "нерелевантно", а "зламано, потребує уваги" — окреме позначення у звіті нагадує команді що є технічний борг. test.fail() — найнетиповіший: він говорить "цей тест очікувано падає", і Playwright навпаки зафейлить його якщо він раптом пройде — це механізм автоматичного сповіщення коли баг пофіксили але анотацію забули прибрати, дуже корисно для документування відомих проблем. test.slow() не змінює результат тесту взагалі — просто множить test timeout на три, щоб Playwright не вбивав повільний але правильний тест раніше часу, без необхідності вручну рахувати скільки саме секунд потрібно.
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.
Ми розібрали основні концепти 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 яка постачається разом з Playwrightchannel: 'chrome'— використовує встановлений на машині Google Chrome (або'msedge'для Edge). Відрізняються версією і поведінкою
browserName: 'chromium'— uses the Playwright-specific Chromium build that ships with Playwrightchannel: '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.
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' } },
],
});
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
defineConfig({ ... }) — функція яка приймає конфігураційний об'єкт (configuration object pattern); ...devices['Desktop Chrome'] — spread operator: "розгортає" об'єкт і вставляє всі його поля; devices['Desktop Chrome'] — доступ до властивості об'єкта через квадратні дужки (bracket notation), еквівалент devices.'Desktop Chrome' але для ключів з пробілами.
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 відкриває інтерактивний режим.
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.
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 використовує власний бандл 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' } },
]
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.
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.
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.
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
// БЕЗ 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 — це замок на дверях ресторану який не відчиняється поки кухня не готова.
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.
dependencies: ['setup'] — масив рядків як список залежностей; testMatch: '**/auth.setup.ts' — glob паттерн: ** означає "в будь-якій папці"; конфігурація через масив об'єктів де кожен об'єкт — окремий проєкт з власними налаштуваннями.
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.
setup — має testMatch що вказує тільки на auth файл, тобто запускає виключно логін. Другий — chromium — має dependencies: ['setup'], що означає "не починай поки setup не завершився", і storageState що вказує на збережений JSON файл. Playwright сам дотримується цього порядку: спочатку повністю виконує setup проєкт, зберігає сесію, і тільки потім запускає основні тести — кожен з яких вже починає залогіненим.
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.
Є два способи виконати авторизацію один раз перед всіма тестами: історично перший — через 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.
// Історичний підхід: 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' });
});
dependencies: ['setup'] — масив рядків як залежності між проєктами; { name: 'setup', testMatch: '**/auth.setup.ts' } — project конфігурація з glob pattern; testMatch — glob для вибору файлів; відмінність від globalSetup: setup-проєкт це звичайний тест з fixtures.
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-файлом.
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.
Коли говоримо "окрема машина" в контексті 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.
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 #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
$CI_NODE_INDEX і $CI_NODE_TOTAL — bash змінні оточення які GitLab встановлює автоматично; parallel: 4 — YAML конфігурація GitLab CI; кожен runner отримує окремий $CI_NODE_INDEX від 1 до N.
$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.
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.
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.
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).
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/]
extends: .playwright-base — наслідування конфігурації; --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL — template string у bash з env variables; паралелізм через matrix strategy.
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.
stages — масив етапів що виконуються послідовно. variables — глобальні змінні середовища. Джоб install: cache зберігає node_modules і браузери між запусками по ключу гілки. script — список команд. Джоб e2e:smoke: parallel: 4 — GitLab запустить 4 копії цього джобу, кожна отримає змінні CI_NODE_INDEX і CI_NODE_TOTAL. Ці змінні підставляємо в --shard, тоді кожен джоб запускає свою чверть тестів. artifacts.when: always — зберігаємо звіти навіть якщо тести впали.
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.
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.
# Запускаємо першу чверть всіх тестів
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
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
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
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.
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.
--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.
--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.
// 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
--shard=1/4 — CLI аргумент у форматі дробу; fullyParallel: true — булева конфігурація; test.describe.configure({ mode: 'serial' }) — виклик методу на вкладеному об'єкті.
--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.
Після того як тести виконались — потрібно щось читати. Не лог в терміналі де все змішалось, а структурований звіт де можна побачити що впало, чому, з яким скріншотом і на якому кроці. 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 withnpx 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.zipinstead of a finished report. Thennpx playwright merge-reports --reporter html ./blob-reportsstitches 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.
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'
},
});
# Встановлення
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
// Щоб 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' }],
],
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 + htmlCI pipeline →
dot + junit (тихо в логах, результати в GitLab test summary)Стейкхолдери →
allure-playwright з S3 history (тренди, графіки, розбивка по suite)Дебаг конкретного падіння →
trace: 'retain-on-failure' + npx playwright show-trace
Local development →
list + htmlCI 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
reporter: [['list'], ['html', { outputFolder }], ['junit', { outputFile }]] — масив масивів: кожен елемент це [назва, опції]; process.env.ENV || 'staging' — logical OR як default value; testInfo.attach('name', { body, contentType }) — options object.
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.
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 звіті прямо поруч з тестом, що сильно прискорює дебаг.
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.
Вбудовані репортери 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.
import { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
export default class SlackReporter implements Reporter {
private failed: string[] = [];
private passed = 0;
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'failed') {
this.failed.push(`❌ ${test.title()}`);
} else if (result.status === 'passed') {
this.passed++;
}
}
async onEnd(result: FullResult) {
const total = this.passed + this.failed.length;
const emoji = result.status === 'passed' ? '✅' : '🔴';
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({
text: `${emoji} E2E Results: ${this.passed}/${total} passed`,
blocks: this.failed.length > 0 ? [{
type: 'section',
text: { type: 'mrkdwn', text: this.failed.join('\n') }
}] : [],
}),
});
}
}
reporter: [
['html'],
['allure-playwright'],
['./reporters/SlackReporter'],
]
class MyReporter implements Reporter {} — implements: TypeScript interface implementation; onTestEnd(test: TestCase, result: TestResult): void — метод з типізованими параметрами і void return type; result.status === 'failed' — порівняння рядків.
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.
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.
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 allowedmaxDiffPixelRatio: 0.01— a fraction: up to 1% of all pixels are allowedthreshold: 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.
// Вбудований 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
},
},
});
await expect(page).toHaveScreenshot('name.png') — snapshot assertion; { mask: [page.locator('.dynamic')] } — масив Locator об'єктів як опція маскування; maxDiffPixels — числовий threshold.
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.
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.
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.
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]"
`);
new AxeBuilder({ page }).withTags([...]).analyze() — builder pattern: кожен метод повертає той самий об'єкт для chaining; await тільки перед analyze() — він повертає Promise; { violations } — деструктуризація результату; violations.length — властивість масиву.
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.
@axe-core/playwright. Створюємо екземпляр AxeBuilder передаючи page, потім через builder pattern налаштовуємо: withTags(['wcag2a', 'wcag2aa']) — перевіряємо тільки ці стандарти WCAG. analyze() — запускає перевірку і повертає результат. Перевіряємо що масив violations порожній. toMatchAriaSnapshot — вбудований в Playwright метод: знімає ARIA-дерево елементу і порівнює з template literal, де дефіси це рівні вкладеності ролей.
@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.
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.
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);
});
await page.context().newCDPSession(page) — ланцюжок методів; await client.send('Performance.enable') — виклик з рядковим аргументом (CDP command); new Promise((resolve) => { new PerformanceObserver((list) => { resolve(data) }) }) — Promise constructor з PerformanceObserver callback.
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 — отримаємо і вже минулі події. Після секунди резолвимо проміс з зібраними метриками і перевіряємо порогові значення через звичайні матчери.
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.
Більшість знають 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 testtoMatchSnapshot()— 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.
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
});
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');
});
# Перший запуск — 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
// 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
Корисний: складна структура з багатьма полями (GTM події, API відповіді, мета-теги), HTML шаблони листів або PDF прев'ю, конфігураційні JSON де важлива кожна деталь
Не корисний: прості значення де ліпше toEqual(), дані що часто змінюються навмисно (ціни, дати), дані з id або timestamp які різні кожного разу — спершу треба їх відфільтрувати
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
JSON.stringify(obj, null, 2) — серіалізація з відступами: null (replacer не потрібен), 2 (відступ 2 пробіли); .toMatchSnapshot('name.txt') — snapshot assertion зберігає рядок у файл; --update-snapshots — CLI флаг для оновлення еталонів.
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.
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.
Платформа з 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.
// 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);
});
}
const domains: DomainConfig[] = [{ ... }, { ... }] — TypeScript масив з типом; domains.forEach(({ country, locale, currency, domain }) => { test(...) }) — forEach з деструктуризацією вкладеного об'єкту; data-driven pattern: дані окремо від логіки тесту.
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.
lang-атрибута в окремий масив DomainConfig і прогоняю один і той самий сценарій data-driven циклом, де для кожного домену створюється власний BrowserContext з потрібним locale, бо locale впливає на формат дат і чисел, які повертає браузер. У кожній ітерації перевіряю не тільки видимий контент, а й технічні речі, важливі для SEO та a11y: атрибут lang у тегу html має відповідати мові домену, бо це впливає на screen readers і пошукову індексацію. Окремо це покриває валюту — текст ціни має містити правильний currency code чи символ для регіону. За межами цього прикладу команда ще окремо перевіряє hreflang-теги для SEO, коректність geo-IP редиректів при заході з різних країн, і що важливо для нашої GTM-інтеграції — event names в dataLayer завжди лишаються англійською незалежно від домену, а локалізується тільки контент, який бачить користувач, бо аналітика має бути уніфікованою для всіх ринків.
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.
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.
// 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
});
@playwright/experimental-ct-react — окремий пакет, не @playwright/test; mount — fixture що повертає Locator на змонтований компонент; JSX прямо в тесті — тому файл має розширення .tsx; onSelect={(size) => selected.push(size)} — колбек визначений у Node.js але викликається з браузера: Playwright серіалізує виклик через свій міст (те саме обмеження серіалізації що й у evaluate).
@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).
Реальний кейс: після оновлення 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.
// Проблема 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;
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.
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.
require(), оскільки для разових helper-задач це простіше й надійніше за дебаг ESM top-level await. По-друге, формат маскування GitLab CI variables змінився, і токен, який раніше читався з CI_JOB_TOKEN, почав приходити в неправильному форматі — перейшла на явну protected variable з перевіркою на undefined перед використанням, щоб помилка падала одразу з зрозумілим повідомленням, а не на кроці автентифікації. По-третє, в інтеграції PROJ-1102 — автостворення GitLab issues з синком у Jira — назви лейблів розійшлись між системами, тому я зробила єдиний LABEL_MAP як shared mapping-шар, щоб зміна назви в одному місці не ламала інтеграцію.
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.
Це баланс між швидкістю доставки нових фіч і впевненістю що релізи стабільні. Надто багато тестів уповільнюють 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.
// Рівні захисту замість "запускати все на кожен 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
};
@smoke @critical @regression — tag-based strategy; метрики: AIRA score, pass rate, MTTR.
@smoke @critical @regression — a tag-based strategy; the metrics: AIRA score, pass rate, MTTR.
Онбординг 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.
// Чеклист 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',
];
const config: TestConfig = { ... } — TypeScript interface для конфігурації; interface TestConfig { maxLines: number; requirePatterns: string[] } — оголошення interface; PR checklist як structured object; string[] — масив рядків.
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.