Практичні сценарії AQA: флак і синхронізація
Зміст
Флак (flakiness) — це коли той самий тест на тому самому коді іноді зелений, а іноді червоний, хоча ти нічого не змінював. Це не «магія» й не «поганий день у CI». За кожним флаком стоїть конкретна причина: тест і застосунок живуть у різному часі. Браузер працює асинхронно — довантажує розмітку, виконує JavaScript, ходить у мережу, малює анімації — а тест припускає, що все вже сталося. Розрив між «сторінка ще думає» і «тест уже клікає» і є джерелом більшості нестабільних падінь.
Синхронізація (synchronization) — це вміння поставити тест і застосунок «в один такт»: діяти рівно тоді, коли застосунок готовий, і не раніше. Нижче — типові сценарії, з якими стикається кожен AQA, і механіка того, чому вони ламаються.
Клік по кнопці, якої ще немає
Найпоширеніший випадок. Тест відкриває сторінку й одразу шукає кнопку «Зберегти», а вона зʼявляється лише після того, як відпрацює асинхронний запит і фреймворк домалює розмітку.
Наївний код:
// Погано: елемент може ще не існувати
const btn = document.querySelector('#save');
btn.click(); // TypeError: Cannot read properties of null
У Selenium без очікування це дасть NoSuchElementException, бо пошук елемента виконується миттєво в момент виклику. Правильна відповідь — очікування (wait) на появу елемента:
// Playwright: локатор лінивий, дія сама дочекається появи
await page.locator('#save').click();
Тут працює авто-очікування (auto-wait): Playwright не шукає елемент одразу, а повторює спроби до таймауту, поки той не зʼявиться в DOM і не стане придатним до дії. У Selenium той самий ефект дає явне очікування (explicit wait):
// Selenium (WebDriverWait): чекаємо появу до 10 секунд
const btn = await driver.wait(
until.elementLocated(By.css('#save')),
10000
);
await btn.click();
Ключова думка: локатор (locator) — це не сам елемент, а рецепт, як його знайти. Знаходити треба в момент дії, а не наперед, і давати застосунку час доробити своє.
Елемент є в DOM, але обробник ще не навісився
Підступніший сценарій. Кнопка вже в розмітці, видима, активна — а клік «нічого не робить». Причина: JavaScript ще не привʼязав обробник події (event handler) до елемента. Так буває при гідратації (hydration) у React/Vue/Svelte, коли сервер віддає готовий HTML, а інтерактивність «оживає» лише після завантаження й виконання клієнтського бандлу.
Це варто усвідомити чесно: авто-очікування тут не рятує. Playwright перевіряє, що елемент прикріплений, видимий, стабільний і приймає події, — але він фізично не може дізнатися, чи навішений onClick у коді застосунку. Для браузера кнопка вже «клікабельна».
Правильний підхід — чекати на прикладний сигнал готовності (application-level signal), а не на сам факт наявності елемента:
// Варіант 1: чекаємо зникнення індикатора завантаження
await page.getByRole('button', { name: 'Save' }).waitFor();
await page.locator('.spinner').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Save' }).click();
// Варіант 2: застосунок сам позначає готовність атрибутом
await page.locator('[data-hydrated="true"]').waitFor();
Нюанс. Атрибут-маркер готовності (
data-hydrated,aria-busy="false"тощо) — річ конкретного застосунку, а не стандарт. Узгодь із розробниками, який сигнал вважати «інтерактивно готово», і чекай саме його.
Мораль: домовляйтеся з командою про явний маркер готовності. Тест, який мовчки клікає в порожнечу, гірший за той, що чесно падає.
AJAX-сабміт і правильне очікування результату
Форма з асинхронною відправкою (AJAX submit) — класична пастка. Тест заповнює поля, тисне «Надіслати» й одразу перевіряє повідомлення про успіх. Але запит ще в дорозі.
Хибний ланцюжок:
await page.getByLabel('Email').fill('a@b.com');
await page.getByRole('button', { name: 'Submit' }).click();
// Погано: перевіряємо до того, як прийшла відповідь
await expect(page.getByText('Готово')).toBeVisible(); // флак
Тут частково рятує авто-очікування toBeVisible: web-first-твердження саме перечікує до таймауту, повторно опитуючи умову. Але надійніше синхронізуватися безпосередньо з тією подією, на яку ти чекаєш. Є два рівні.
// Рівень 1: чекаємо саме мережеву відповідь
const respPromise = page.waitForResponse(
r => r.url().includes('/api/subscribe') && r.status() === 200
);
await page.getByRole('button', { name: 'Submit' }).click();
await respPromise;
await expect(page.getByText('Готово')).toBeVisible();
Зверни увагу: проміс відповіді створюють до кліку. Інакше між кліком і викликом waitForResponse є вікно, в яке швидка відповідь може прослизнути непоміченою, — це знову гонка.
// Рівень 2: чекаємо видимий наслідок у UI (найближче до користувача)
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('alert')).toHaveText('Готово');
Що обрати? Правило таке: перевіряй те, що бачить користувач (UI-наслідок), а на мережеву відповідь синхронізуйся тоді, коли зміна в UI непомітна або надто «тонка», щоб на неї надійно зачепитися. Іноді доречно поєднати: дочекатися відповіді, щоб зняти гонку, і окремо стверджувати результат в інтерфейсі.
Клік не спрацьовує: елемент перекрито модалкою або тултіпом
Елемент є, він видимий, обробник навішений — а клік летить «повз». Причина в тому, що поверх нього лежить інший елемент: модальне вікно (modal), напівпрозорий оверлей (overlay), тултіп (tooltip), «липкий» хедер чи cookie-банер.
Браузер відправляє клік у точку екрана, і подію ловить верхній за z-порядком елемент, а не той, у який ти цілився. Playwright робить перевірку «отримує події» (receives events) — так званий hit-test: перевіряє, що саме цільовий елемент є верхнім у точці кліку. Якщо ні — повторює спроби й падає з повідомленням у стилі:
<div class="overlay">…</div> intercepts pointer events
Це насправді корисне падіння: тест зловив реальну проблему UX або власну помилку. Що робити:
| Причина перекриття | Правильна реакція |
|---|---|
| Модалка ще не закрилась | Дочекатися її зникнення: .waitFor({ state: 'hidden' }) |
| Cookie-банер / оверлей | Закрити його в передумові (precondition), а не «пробивати» |
| Тултіп зʼявляється при наведенні | Прибрати фокус / навести деінде або клікнути інший якір |
| Анімація ще їде | Дочекатися стабільності (див. розділ про анімації) |
Чого не робити — не бити «силовим» кліком наосліп:
// Погано: обходимо перевірки й ховаємо реальний баг
await page.locator('#save').click({ force: true });
force: true вимикає неосновні перевірки актуальності (зокрема «отримує події») й імітує клік попри перекриття. Це доречно у виняткових випадках (наприклад, свідомо тестуєш поведінку під оверлеєм), але за замовчуванням це спосіб зробити тест зеленим, приховавши дефект. Спершу спитай себе: а живий користувач узагалі зміг би сюди клікнути?
Анімації заважають кліку і скріншоту
Анімації й переходи (transitions) псують дві речі. Перше — клік: елемент їде, і його координати змінюються між моментом наведення й моментом натискання. Playwright тут вимагає стабільності (stable) — щоб bounding box не змінювався щонайменше два послідовні кадри анімації; поки елемент їде, дія перечікується. Друге — скріншоти: візуальне порівняння (visual regression) ловить проміжний кадр анімації, і дифф червоніє щоразу по-різному.
Найнадійніше — глушити анімації на час тесту:
// Вимикаємо анімації через CSS-інʼєкцію
await page.addStyleTag({ content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`});
Playwright у скріншотах має вбудований варіант:
// Стабілізує анімації на час знімка
await expect(page).toHaveScreenshot({ animations: 'disabled' });
Значення 'disabled' (воно ж дефолтне для toHaveScreenshot) зупиняє CSS-анімації, CSS-переходи й анімації Web Animations API: скінченні анімації промотуються до кінцевого стану, нескінченні — скидаються до початкового й програються далі після знімка. Але воно не чіпає анімації, намальовані вручну через requestAnimationFrame чи на <canvas>, — їх, якщо вони заважають, доводиться глушити на рівні самого застосунку.
Проміжна сторінка під час редіректу
Логін часто працює так: сабміт форми → сервер відповідає редіректом → браузер вантажить проміжну сторінку («Зачекайте…») → фінальний редірект на дашборд. Тест, який одразу після кліку перевіряє URL дашборда, ловить проміжний стан і падає.
Коротко про коди редіректів, бо вони визначають поведінку браузера:
| Код | Назва | Метод при переході |
|---|---|---|
| 301 | Moved Permanently | Історично часто змінюють на GET |
| 302 | Found | Історично часто змінюють на GET |
| 303 | See Other | Завжди GET |
| 307 | Temporary Redirect | Зберігає метод (POST лишається POST) |
| 308 | Permanent Redirect | Зберігає метод |
Практичний висновок для тесту: не привʼязуйся до проміжних URL і не роби припущень про кількість «стрибків». Чекай саме на фінальний стан:
// Чекаємо кінцевий URL за патерном, ігноруючи проміжні
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('**/dashboard');
await expect(page.getByRole('heading', { name: 'Огляд' })).toBeVisible();
Ще одна тонкість — стан завантаження (load state). Подія domcontentloaded настає, коли розібрано HTML, але підресурси й дані ще можуть вантажитися; load — коли завантажено й підресурси. Не бий у сторінку між редіректами: якщо синхронізуватися на domcontentloaded проміжної сторінки, вона за мить зникне, і твій локатор відвалиться разом із нею. Надійніше — очікувати саме кінцевий видимий елемент дашборда, а не абстрактне «сторінка завантажилась».
Залежність від нестабільного зовнішнього API
Тест, який реально ходить у сторонній сервіс (платіжний шлюз, погода, карти, OAuth-провайдер), успадковує всю його нестабільність: таймаути, ліміти запитів (rate limits), зміни у відповідях, падіння в неробочі години. Такий тест червоніє не тому, що зламався твій застосунок, а тому, що «застудився» чужий.
Розділяй наміри. Якщо ти перевіряєш свою логіку, зовнішній сервіс треба ізолювати через мок (mock) або стаб (stub) на рівні мережі:
// Перехоплюємо запит і віддаємо детерміновану відповідь
await page.route('**/api.exchange.com/rates', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ USD: 41.2, EUR: 44.8 })
})
);
Це прибирає флак і дозволяє легко перевірити крайні випадки: помилку 500, порожню відповідь, повільну відповідь. Аналог у Cypress — cy.intercept.
Окремо варто мати контрактні або end-to-end тести, які таки бʼються об реальний сервіс, — але їх виносять в окрему повільну категорію, ганяють рідше й не блокують ними основний пайплайн. Змішувати одне з іншим — прямий шлях до «червоного, якому ніхто не вірить».
Чому hard sleep — погано
Жорстка пауза (hard sleep) — це sleep(3) замість очікування конкретної умови. Її ставлять «щоб напевно». Насправді вона програє двічі:
- Занадто мало — на повільному CI трьох секунд не вистачить, тест флакне.
- Занадто багато — на швидкій машині ти щоразу марно чекаєш повний інтервал, і сотні таких пауз перетворюють десятихвилинний прогін на годинний.
Головна проблема: sleep синхронізується з годинником, а не з застосунком. Він не знає, готова система чи ні. Розумне очікування опитує реальну умову й завершується в ту саму мілісекунду, коли вона стала істинною.
| Критерій | Hard sleep | Розумне очікування |
|---|---|---|
| На що чекає | Фіксований час | Конкретну умову |
| На швидкій машині | Марно гає час | Завершується одразу |
| На повільному CI | Може не дочекатись | Дочекається (в межах таймауту) |
| Ловить проблему рано | Ні | Так, падає з причиною |
Єдиний умовно-виправданий sleep — коли треба спостерегти відсутність зміни (наприклад, «після дії нічого не має статися»), бо чекати на «нічого» нема як. Але й тут краще підтвердити стан через прикладний сигнал, а не голий таймер.
Explicit wait проти auto-wait
Два підходи до синхронізації, які часто плутають.
- Явне очікування (explicit wait) — ти сам пишеш умову й таймаут: «чекай, поки цей елемент стане видимим, до 10 секунд». Класика Selenium (
WebDriverWait+expected_conditions). - Авто-очікування (auto-wait) — інструмент перед кожною дією сам перевіряє набір умов актуальності (actionability) і перечікує до таймауту. Так працює Playwright і, частково, Cypress.
Що перевіряє авто-очікування Playwright перед кліком:
| Перевірка | Що означає |
|---|---|
| Attached | Елемент є в DOM |
| Visible | Має непорожній bounding box і не visibility:hidden (елемент з opacity:0 вважається видимим) |
| Stable | Не змінює bounding box два послідовні кадри анімації |
| Receives events | Є верхнім елементом у точці дії (hit-test) |
| Enabled | Не має атрибута disabled |
Для дій уведення (наприклад, fill) додається ще перевірка Editable — елемент не має бути readonly.
Порівняння підходів:
| Explicit wait | Auto-wait | |
|---|---|---|
| Хто пише умову | Інженер вручну | Інструмент за замовчуванням |
| Ризик забути очікування | Високий | Низький |
| Гнучкість під нетипову умову | Висока | Треба доповнювати вручну |
| Типовий інструмент | Selenium | Playwright |
Є ще третій, історичний механізм — неявне очікування (implicit wait) у Selenium: глобальний таймаут, протягом якого драйвер повторює пошук елемента. Він вузький (реагує лише на «елемент не знайдено», не вміє чекати на видимість чи клікабельність), а головне — змішування неявного й явного очікування дає непередбачувані сумарні таймаути. Тому загальна рекомендація — обрати щось одне; на практиці це явне очікування.
Важлива чесність: авто-очікування не всесильне. Воно закриває «елемент зʼявився / став клікабельним», але не знає про прикладні стани — навішений обробник, завершений AJAX, оновлені дані. Там усе одно потрібне явне очікування на прикладний сигнал (див. розділи вище). Найкращі набори тестів комбінують: покладаються на авто-очікування для рутини й додають явні очікування там, де готовність визначає сам застосунок. І в жодному разі не використовуй waitForTimeout/sleep як заміну умові.
Тест зелений локально, але флакне на CI
Це найчастіше питання на співбесідах і найбільший біль на практиці. Локальна машина й CI — різні світи. Найтиповіші причини такі.
1. Ресурси й швидкість. CI-раннер зазвичай слабший і завантаженіший: менше CPU, повільніший диск, сусідні джоби борються за ядра. Те, що локально стається за 200 мс, на CI займає 2 с — і жорсткі таймаути/паузи не витримують. Лікування: розумні очікування замість sleep, адекватні (не занадто короткі) таймаути, менше воркерів на слабкому раннері.
2. Headless проти headed і розмір вікна. Локально ти дивишся тест у видимому браузері певної роздільності; CI гонить headless із дефолтним, часто меншим, viewport. Елемент, який локально в зоні видимості, на CI опиняється за межами видимої області (below the fold) або зʼїжджає в мобільний лейаут — і клік не проходить. Лікування: фіксуй viewport у конфізі й ганяй локально в тому самому режимі, що й CI.
3. Часовий пояс, локаль і дані середовища. CI нерідко в UTC та англійській локалі, а локальна машина — у Києві й українською. Тест, що звіряє відформатовану дату, суму чи текст, ламається на розбіжності TZ/LANG. Плюс порожня чи інакше засіяна база на CI. Лікування: фіксуй TZ, локаль і Accept-Language, не покладайся на «сьогоднішню» дату без мокання часу.
Інші часті винуватці: гонки при паралельному прогоні (див. нижче), відсутні системні шрифти (ламають візуальні тести), різні версії браузера, мережеві обмеження CI-мережі. Практичний прийом — відтворити умови CI локально: той самий Docker-образ, headless, той самий viewport, TZ=UTC, обмежені CPU. Флак, який «не ловиться локально», майже завжди ловиться, щойно локальне середовище зробити схожим на CI.
Протікання даних між тестами й ізоляція
Протікання стану (state leakage) — це коли один тест лишає по собі слід, а інший на цей слід спотикається. Класика: тест A створює користувача test@x.com, тест B теж намагається його створити й падає на «вже існує». Або тести залежать від порядку виконання — і варто змінити порядок чи ввімкнути паралель, як усе сиплеться.
Спільний стан протікає через: базу даних, localStorage/cookies/сесію, кеш, зовнішні черги, глобальні змінні в самому тест-фреймворку.
Принципи ізоляції (isolation):
- Свіжий контекст на тест. У Playwright кожен тест за замовчуванням отримує чистий browser context — окремі cookies й storage, нема витоку сесії між тестами.
- Унікальні дані. Не хардкодь
test@x.com. Генеруй унікальні значення на кожен запуск:
// Унікальний email рятує від колізій між тестами й прогонами
const email = `user_${Date.now()}_${crypto.randomUUID()}@example.com`;
- Кожен тест сам створює свої передумови й прибирає за собою — не спирайся на дані, які «мав лишити попередній тест».
- Прибирання (teardown) роби в
afterEach/фікстурі, а не наприкінці «щасливого» шляху, бо при падінні дія до прибирання не дійде.
Нюанс. На спільному стенді (shared environment) очищення тестових даних може зачепити чужі — стратегію teardown там узгоджуй окремо; синтетичні дані завжди безпечніші за реальні.
Чому падає паралельний прогін
Паралель (parallel execution) вмикають заради швидкості — і одразу випливають дефекти ізоляції, яких не було видно при послідовному запуску. Причини:
- Конкуренція за спільні дані. Два тести правлять того самого користувача чи той самий запис — гонка (race condition). Один читає те, що інший щойно змінив.
- Спільні глобальні перемикачі. Тест вмикає на весь проєкт якийсь режим (наприклад, «обовʼязкове поле»), і паралельні сусіди, яким цей режим не потрібен, починають падати.
- Обмежені ресурси. Один порт, один тимчасовий файл, одна квота на зовнішньому API — усі воркери бʼються за нього.
- Порядок як прихована залежність. Послідовно тести випадково йшли в «правильному» порядку; паралель цей порядок ламає й оголює приховану звʼязаність.
Ліки: справжня ізоляція даних (унікальні сутності на тест), уникнення глобальних мутацій спільного стану, а для тестів, які інакше не розчепити, — виділена серійна доріжка чи окремий проєкт/акаунт. Паралель — це лакмус: якщо вона валить тести, це майже завжди сигнал, що ізоляція вже дірява, просто послідовний прогін це маскував.
Тротлінг мережі для лову race-багів
Гонки (race conditions) — найпідступніші баги: на швидкій мережі порядок подій «випадково правильний», і дефект спить. Уповільнення мережі (network throttling) навмисно розтягує запити в часі й витягує назовні: подвійні сабміти, роботу з ще незавантаженими даними, стан «спінер завис назавжди».
У Chromium через протокол DevTools (CDP) можна емулювати умови мережі:
// Playwright + CDP: емуляція повільної мережі в Chromium
const client = await context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: 200 * 1024 / 8, // байти/с ≈ 200 кбіт/с
uploadThroughput: 200 * 1024 / 8,
latency: 400 // мс затримки на запит
});
Network.emulateNetworkConditions — це команда CDP (Chrome DevTools Protocol), тому вона працює лише в Chromium; для WebKit і Firefox через Playwright такого важеля немає. Значення downloadThroughput/uploadThroughput задають у байтах за секунду (-1 вимикає тротлінг), а latency — у мілісекундах.
Простіший і кросбраузерний прийом — додати штучну затримку конкретному запиту через перехоплення:
// Затримуємо саме потрібний запит, щоб оголити гонку
await page.route('**/api/cart', async route => {
await new Promise(r => setTimeout(r, 2000));
await route.continue();
});
Це дозволяє, наприклад, натиснути «Оформити» двічі, поки перший запит «висить», і перевірити, чи застосунок блокує повторний сабміт. Так тест із інструмента підтвердження перетворюється на інструмент пошуку реальних race-багів.
Алгоритм діагностики: хто винен — застосунок, тест чи середовище
Коли тест червоний, головне питання — де саме баг. Ось послідовність, яка веде до відповіді без гадань.
- Прочитай помилку буквально.
element intercepts pointer events— щось перекриває клік.Timeout waiting for selector— очікуваний елемент не зʼявився.expected 200, got 500— впав бекенд. Тип помилки вже звужує коло. - Подивись артефакти. Скріншот, відео, трасування (trace), логи мережі й консолі на момент падіння. Часто видно одразу: висить спінер, показано
500, зʼїхав лейаут. - Відтвори локально в умовах CI. Той самий образ / headless / viewport /
TZ. Якщо в CI-умовах локально стабільно зелено, а на CI червоно — підозра зміщується на середовище й ресурси. - Прожени багато разів. Скрипт, що ганяє тест 20–50 разів. Стабільно червоний = детермінований баг (застосунку або тесту). Іноді червоний = флак (гонка або слабке очікування).
- Ізолюй. Прожени тест наодинці й у складі всього набору. Наодинці зелений, у наборі червоний — це протікання стану або конкуренція за паралель, тобто проблема ізоляції.
- Розділи гіпотези за симптомом:
| Симптом | Найімовірніший винуватець |
|---|---|
| Падає лише в паралелі / в наборі | Тест (ізоляція, спільний стан) |
| Падає лише на CI, локально в тих самих умовах — ні | Середовище (ресурси, TZ, viewport) |
| Падає стабільно скрізь після зміни коду | Застосунок (реальний баг або зміна поведінки) |
| Падає раз на N без явної системи | Гонка + слабке очікування (тест) |
| Помилка мережі від стороннього домену | Зовнішня залежність (мокати) |
- Сформулюй висновок чесно. Якщо винен застосунок — це знайдений баг, тест має лишатися червоним і фіксувати регресію, а не «зеленитися» через
forceчи sleep. Якщо винен тест — лагодь синхронізацію та ізоляцію. Якщо середовище — вирівнюй його або фіксуй передумови. І поки причина не відтворена й не виміряна, чесне формулювання — «ще не встановлено», а не поспішний «root cause».
Ця дисципліна — не бюрократія, а спосіб не витрачати дні на «полагоджений» флак, який повертається наступного тижня. Хороший AQA не заспокоюється на зеленому: він знає, чому зелено, і що саме доводить кожне очікування в його тесті.
Що таке flaky-тест і чому він виникає?
Flaky-тест — це тест, який на тому самому коді іноді проходить, а іноді падає, хоча ніхто нічого не змінював. Головна причина в тому, що тест і застосунок живуть у різному часі: браузер працює асинхронно — довантажує розмітку, виконує JavaScript, ходить у мережу, малює анімації, — а тест припускає, що все вже сталося. Розрив між «сторінка ще думає» і «тест уже клікає» і породжує більшість нестабільних падінь. Ліки — синхронізація: діяти рівно тоді, коли застосунок готовий, і не раніше. На практиці це означає чекати на конкретні умови (елемент видимий, відповідь прийшла, URL фінальний), а не на фіксований час. Важливо розуміти: за кожним флаком стоїть конкретна причина, «поганого дня в CI» не буває.
Чому sleep(3) — погана заміна очікуванню?
Жорстка пауза синхронізується з годинником, а не з застосунком — вона не знає, готова система чи ні. Тому вона програє двічі: на повільному CI трьох секунд може не вистачити, і тест флакне; на швидкій машині ти щоразу марно чекаєш повний інтервал, і сотні таких пауз перетворюють десятихвилинний прогін на годинний. Розумне очікування натомість опитує реальну умову й завершується в ту саму мілісекунду, коли вона стала істинною, а якщо ні — падає з чіткою причиною в межах таймауту. Єдиний умовно-виправданий випадок для sleep — коли треба переконатися у відсутності зміни («після дії нічого не має статися»), бо чекати на «нічого» нема як; але й тут краще підтвердити стан через прикладний сигнал.
Тест падає з NoSuchElementException одразу після відкриття сторінки. У чому причина і як виправити?
Причина в тому, що пошук елемента виконується миттєво в момент виклику, а елемент зʼявляється в DOM пізніше — після асинхронного запиту й домальовування розмітки фреймворком. У Selenium це лікується явним очікуванням: WebDriverWait з умовою until.elementLocated дає застосунку час доробити своє. У Playwright проблема знята архітектурно: локатор лінивий, і дія на кшталт await page.locator('#save').click() сама повторює спроби до таймауту, поки елемент не зʼявиться і не стане придатним до дії. Ключова думка, яку варто озвучити: локатор — це не сам елемент, а рецепт, як його знайти; знаходити треба в момент дії, а не наперед.
Чим explicit wait відрізняється від auto-wait, і що таке implicit wait?
Явне очікування (explicit wait) — це коли інженер сам пише умову й таймаут: «чекай, поки елемент стане видимим, до 10 секунд» — класика Selenium через WebDriverWait + expected_conditions. Авто-очікування (auto-wait) — коли інструмент перед кожною дією сам перевіряє набір умов актуальності й перечікує до таймауту; так працює Playwright і частково Cypress. Explicit гнучкіший під нетипові умови, але має високий ризик «забути очікування»; auto-wait знімає рутину, але нетипові умови доводиться дописувати вручну. Є ще третій, історичний механізм — неявне очікування (implicit wait) у Selenium: глобальний таймаут, протягом якого драйвер повторює пошук елемента. Воно вузьке (реагує лише на «елемент не знайдено», не вміє чекати видимість чи клікабельність), а змішування неявного і явного очікувань дає непередбачувані сумарні таймаути — тому рекомендація: обрати щось одне, на практиці явне.
Які перевірки Playwright виконує перед кліком у рамках auto-wait?
Пʼять перевірок актуальності (actionability). Attached — елемент є в DOM. Visible — має непорожній bounding box і не visibility:hidden (при цьому елемент з opacity:0 вважається видимим — типова пастка). Stable — bounding box не змінюється щонайменше два послідовні кадри анімації, тобто елемент не «їде». Receives events — елемент є верхнім у точці дії (hit-test), його не перекриває оверлей. Enabled — немає атрибута disabled. Для дій уведення на кшталт fill додається ще Editable — елемент не readonly. Поки якась умова не виконана, Playwright повторює спроби до таймауту, а потім падає з конкретною причиною — це і робить падіння діагностованим.
Кнопка є в DOM, видима й активна, але клік «нічого не робить». Які можливі причини?
Найімовірніша причина — JavaScript ще не привʼязав обробник події до елемента. Так буває при гідратації (hydration) у React/Vue/Svelte: сервер віддає готовий HTML, а інтерактивність «оживає» лише після завантаження й виконання клієнтського бандлу. Важлива чесність: авто-очікування тут не рятує — Playwright перевіряє, що елемент прикріплений, видимий, стабільний і приймає події, але фізично не може дізнатися, чи навішений onClick у коді застосунку; для браузера кнопка вже «клікабельна». Правильний підхід — чекати на прикладний сигнал готовності: зникнення спінера або атрибут-маркер типу data-hydrated="true", який застосунок сам виставляє. Такий маркер — не стандарт, а домовленість із розробниками, і сильний кандидат саме це й підкреслює: узгодьте з командою, який сигнал означає «інтерактивно готово», і чекайте саме його.
Тест сабмітить форму через AJAX і одразу перевіряє повідомлення про успіх — іноді падає. Як зробити правильно?
Проблема в тому, що перевірка виконується, поки запит ще в дорозі. Частково рятують web-first-твердження на кшталт toBeVisible — вони повторно опитують умову до таймауту. Але надійніше синхронізуватися з подією, на яку реально чекаєш: або через page.waitForResponse на конкретний URL і статус, або через видимий наслідок у UI. Критична деталь із waitForResponse: проміс треба створити до кліку, інакше між кліком і підпискою є вікно, в яке швидка відповідь може прослизнути непоміченою — і це знову гонка. Загальне правило вибору: перевіряй те, що бачить користувач (UI-наслідок), а на мережеву відповідь синхронізуйся тоді, коли зміна в UI непомітна або надто «тонка»; іноді доречно поєднати — дочекатися відповіді, щоб зняти гонку, і окремо стверджувати результат в інтерфейсі.
Тест падає з помилкою «element intercepts pointer events». Що сталося і що робити? Чому force: true — погана ідея?
Це означає, що поверх цільового елемента лежить інший — модалка, напівпрозорий оверлей, тултіп, «липкий» хедер чи cookie-банер. Браузер відправляє клік у точку екрана, і подію ловить верхній за z-порядком елемент, тому Playwright робить hit-test і чесно падає, коли ціль не верхня. Це корисне падіння: тест зловив або реальну проблему UX, або власну помилку. Реакція залежить від причини: модалка ще не закрилась — дочекатися waitFor зі станом hidden; cookie-банер — закрити в передумові, а не «пробивати»; тултіп — прибрати фокус; анімація — дочекатися стабільності. force: true вимикає неосновні перевірки актуальності (зокрема hit-test) й імітує клік попри перекриття — тобто робить тест зеленим, приховавши дефект. Він доречний лише у виняткових випадках, коли ти свідомо тестуєш поведінку під оверлеєм; загальний тест на здоровий глузд — чи зміг би сюди клікнути живий користувач.
Як анімації ламають тести і як їх приборкати?
Анімації псують дві речі. Перше — клік: елемент їде, і координати змінюються між наведенням і натисканням; Playwright тут вимагає стабільності (bounding box незмінний два послідовні кадри) і перечікує, поки елемент рухається. Друге — скріншоти: візуальне порівняння ловить проміжний кадр анімації, і дифф червоніє щоразу по-різному. Найнадійніше — глушити анімації на час тесту: CSS-інʼєкцією виставити нульові animation-duration і transition-duration, або для скріншотів використати вбудовану опцію toHaveScreenshot зі значенням animations: 'disabled' (воно ж дефолтне): скінченні анімації промотуються до кінцевого стану, нескінченні скидаються до початкового. Нюанс для глибини: цей механізм зупиняє CSS-анімації, переходи й Web Animations API, але не чіпає анімації, намальовані вручну через requestAnimationFrame чи на <canvas> — ті доводиться глушити на рівні самого застосунку.
Після логіну йде ланцюжок редіректів із проміжною сторінкою «Зачекайте…». Як тестувати такий флоу?
Головне правило: не привʼязуйся до проміжних URL і не роби припущень про кількість «стрибків» — чекай саме на фінальний стан через page.waitForURL('**/dashboard'), а потім стверджуй видимий елемент дашборда. Тест, який одразу після кліку перевіряє URL, ловить проміжний стан і падає. Варто знати й коди редіректів, бо вони визначають поведінку браузера: 301/302 історично часто змінюють метод на GET, 303 — завжди GET, а 307 і 308 зберігають метод (POST лишається POST). Ще одна тонкість — стани завантаження: domcontentloaded настає, коли розібрано HTML (підресурси й дані ще можуть вантажитися), load — коли завантажено й підресурси; якщо синхронізуватися на domcontentloaded проміжної сторінки, вона за мить зникне разом із твоїм локатором. Тому надійніше чекати кінцевий видимий елемент, а не абстрактне «сторінка завантажилась».
Тести ходять у реальний платіжний шлюз і періодично червоніють без змін у коді. Що робити?
Тест, який реально бʼється об сторонній сервіс, успадковує всю його нестабільність: таймаути, ліміти запитів, зміни у відповідях, падіння в неробочі години — він червоніє не тому, що зламався твій застосунок. Рішення — розділити наміри. Якщо перевіряєш свою логіку, зовнішній сервіс ізолюй через мок або стаб на рівні мережі: у Playwright це page.route з route.fulfill, який віддає детерміновану відповідь; аналог у Cypress — cy.intercept. Бонус мокання — легко перевірити крайні випадки: помилку 500, порожню чи повільну відповідь. Окремо варто мати контрактні або end-to-end тести, які таки ходять у реальний сервіс, але їх виносять в окрему повільну категорію, ганяють рідше й не блокують ними основний пайплайн. Змішувати одне з іншим — прямий шлях до «червоного, якому ніхто не вірить».
Що таке протікання стану між тестами і як забезпечити ізоляцію?
Протікання стану (state leakage) — це коли один тест лишає по собі слід, а інший на нього спотикається: тест A створює користувача test@x.com, тест B намагається створити такого самого й падає на «вже існує», або тести працюють лише в певному порядку. Стан протікає через базу даних, localStorage/cookies/сесію, кеш, зовнішні черги й глобальні змінні тест-фреймворку. Принципи ізоляції: свіжий контекст на тест (у Playwright кожен тест за замовчуванням отримує чистий browser context — окремі cookies і storage); унікальні дані замість хардкоду — генеруй email через timestamp плюс UUID на кожен запуск; кожен тест сам створює свої передумови й прибирає за собою, не спираючись на «спадок» попереднього. Важлива деталь про teardown: прибирання роби в afterEach або фікстурі, а не наприкінці «щасливого» шляху — при падінні тест до прибирання просто не дійде. На спільному стенді стратегію очищення узгоджуй окремо, бо можна зачепити чужі дані.
Тести були зелені послідовно, але посипались після ввімкнення паралельного прогону. Чому?
Паралель не створює нові баги — вона оголює дефекти ізоляції, які послідовний запуск маскував. Типові причини: конкуренція за спільні дані (два тести правлять того самого користувача — гонка, один читає те, що інший щойно змінив); спільні глобальні перемикачі (тест вмикає режим на весь проєкт, і паралельні сусіди падають); обмежені ресурси (один порт, один тимчасовий файл, одна квота на зовнішньому API); порядок виконання як прихована залежність, яку паралель ламає. Ліки — справжня ізоляція: унікальні сутності на тест, уникнення глобальних мутацій спільного стану, а для тестів, які інакше не розчепити, — виділена серійна доріжка чи окремий проєкт/акаунт. Сильна відповідь завершується висновком: паралель — це лакмус; якщо вона валить тести, ізоляція вже була дірява.
Навіщо навмисно уповільнювати мережу в тестах?
Щоб витягнути назовні гонки (race conditions) — найпідступніші баги, які на швидкій мережі сплять, бо порядок подій «випадково правильний». Тротлінг розтягує запити в часі й оголює подвійні сабміти, роботу з ще незавантаженими даними, «спінер завис назавжди». Технічно є два шляхи. Перший — Network.emulateNetworkConditions через CDP-сесію: задаєш пропускну здатність у байтах за секунду і затримку в мілісекундах; це команда Chrome DevTools Protocol, тому працює лише в Chromium. Другий, простіший і кросбраузерний — штучна затримка конкретного запиту через page.route: затримати відповідь /api/cart на дві секунди й перевірити, чи блокує застосунок повторне натискання «Оформити», поки перший запит «висить». Так тест з інструмента підтвердження перетворюється на інструмент пошуку реальних race-багів.
Тест стабільно зелений локально, але флакне на CI. Які найтиповіші причини і як їх ловити?
Локальна машина й CI — різні світи, і причини варто перебирати системно. Перша — ресурси: CI-раннер слабший і завантаженіший, те, що локально стається за 200 мс, там займає 2 с, і жорсткі таймаути не витримують; лікування — розумні очікування, адекватні таймаути, менше воркерів. Друга — headless проти headed і розмір вікна: CI гонить headless із дефолтним, часто меншим viewport, і елемент опиняється за межами видимої області або зʼїжджає в мобільний лейаут; лікування — фіксувати viewport у конфізі й ганяти локально в тому самому режимі. Третя — часовий пояс і локаль: CI нерідко в UTC та англійській локалі, і тест, що звіряє відформатовану дату чи текст, ламається; лікування — фіксувати TZ, локаль, Accept-Language, не покладатися на «сьогоднішню» дату без мокання часу. Інші винуватці: гонки при паралелі, відсутні системні шрифти, різні версії браузера. Практичний прийом, який відрізняє сеньйора: відтворити умови CI локально — той самий Docker-образ, headless, той самий viewport, TZ=UTC, обмежені CPU; флак, який «не ловиться локально», майже завжди ловиться, щойно середовище зробити схожим на CI.
Тест червоний. Як визначити, хто винен — застосунок, тест чи середовище?
Є дисциплінований алгоритм замість гадань. Спершу прочитай помилку буквально — тип помилки вже звужує коло: «intercepts pointer events» — щось перекриває клік, таймаут селектора — елемент не зʼявився, «expected 200, got 500» — впав бекенд. Далі подивись артефакти: скріншот, відео, trace, логи мережі й консолі на момент падіння. Потім відтвори локально в умовах CI (той самий образ, headless, viewport, TZ) — якщо локально стабільно зелено, а на CI червоно, підозра зміщується на середовище. Прожени тест 20–50 разів: стабільно червоний — детермінований баг, іноді червоний — флак. Ізолюй: наодинці зелений, у наборі червоний — протікання стану чи конкуренція за паралель. Симптоми мапляться на винуватців: падає лише в паралелі — тест (ізоляція); лише на CI — середовище; стабільно скрізь після зміни коду — застосунок; раз на N без системи — гонка плюс слабке очікування. І головне для сеньйорської відповіді: якщо винен застосунок, тест має лишатися червоним і фіксувати регресію, а не «зеленитися» через force чи sleep; а поки причина не відтворена й не виміряна, чесне формулювання — «ще не встановлено», а не поспішний root cause.
Три кейси, де тест і застосунок живуть у різному часі: гонка навколо AJAX-сабміту (і чому проміс відповіді створюють до кліку), перекритий клік, який force: true тихо ховає замість лагодити, і навмисне вповільнення мережі, що перетворює тест з інструмента підтвердження на інструмент пошуку race-багів. Скрізь — що дивитися і чому.
Кейс 1. AJAX-сабміт: синхронізуйся з подією, а не з надією
Тест заповнює форму, тисне «Надіслати» й одразу перевіряє «Готово». Запит ще в дорозі — і перевірка ловить стару сторінку. Наївний варіант флакне саме на швидкій відповіді, коли встигає прослизнути повз:
await page.getByLabel('Email').fill('a@b.com');
await page.getByRole('button', { name: 'Submit' }).click();
// Погано: перевіряємо до того, як прийшла відповідь
await expect(page.getByText('Готово')).toBeVisible();
Надійніше синхронізуватися з тією самою подією, на яку ти чекаєш. Проміс відповіді створюють до кліку — інакше між кліком і викликом waitForResponse лишається вікно, крізь яке швидка відповідь проскочить непоміченою:
// Знімаємо гонку: проміс відповіді існує ще до кліку
const respPromise = page.waitForResponse(
(r) => r.url().includes('/api/subscribe') && r.status() === 200
);
await page.getByRole('button', { name: 'Submit' }).click();
await respPromise;
await expect(page.getByRole('alert')).toHaveText('Готово');
Що дивитися і чому:
- Порядок рядків тут — не косметика. Створиш
waitForResponseпісля кліку — і на швидкому бекенді відповідь приходить раніше, ніж ти почав слухати. Тест зелений «здебільшого», а червоніє рівно тоді, коли сервер відповів найшвидше. Проміс має жити раніше за дію, що його спричиняє. toBeVisible/toHaveTextсамі перечікують. Web-first-твердження опитують умову до таймауту, тож голийexpectбезwaitForResponseтеж інколи виживає. Але це маскування гонки живучістю асерту, а не її усунення — на межі таймауту воно все одно впаде.- Перевіряй те, що бачить користувач. За замовчуванням стверджуй UI-наслідок (
alertз текстом), а на мережеву відповідь синхронізуйся, коли зміна в UI надто «тонка», щоб надійно за неї зачепитися. Іноді доречно поєднати: дочекатися відповіді, щоб зняти гонку, і окремо перевірити результат в інтерфейсі.
Кейс 2. Перекритий клік: force: true не лагодить, а ховає баг
Елемент є, видимий, обробник навішений — а клік летить «повз», бо поверх лежить оверлей, модалка, тултіп чи cookie-банер. Playwright робить hit-test («отримує події»): перевіряє, що цільовий елемент — верхній у точці кліку. Якщо ні — повторює спроби й падає з підказкою про перехоплювача:
<div class="overlay">…</div> intercepts pointer events
Спокуса — «пробити» перешкоду силовим кліком. Це найгірша реакція:
// Погано: обходимо перевірки й ховаємо реальний баг
await page.locator('#save').click({ force: true });
Правильно — зняти саме причину перекриття, а не вимикати перевірку:
// Дочекатися, поки перекривач зникне, і клікати чесно
await page.locator('.overlay').waitFor({ state: 'hidden' });
await page.locator('#save').click();
Що дивитися і чому:
force: trueвимикає перевірки актуальності, зокрема hit-test. Тест зеленіє, імітуючи клік попри перекриття, а дефект UX (банер, що не закрився; модалка-привид) лишається в проді. Питання-фільтр: а живий користувач узагалі зміг би сюди клікнути?- Читай перехоплювача з повідомлення буквально.
intercepts pointer eventsназиває елемент, який зловив клік. Cookie-банер чи оверлей закривають у передумові, залишок модалки перечікують черезwaitFor({ state: 'hidden' }), тултіп прибирають зняттям фокуса, анімацію — очікуванням стабільності. - Це корисне падіння, а не завада. Тест зловив або реальну проблему інтерфейсу, або власну відсутність очікування.
forceдоречний лише як свідомий виняток (наприклад, навмисно тестуєш поведінку під оверлеєм), не як спосіб озеленити червоне.
Кейс 3. Тротлінг мережі: витягуємо race-баг подвійного сабміту
На швидкій мережі порядок подій «випадково правильний», і гонка спить. Навмисне вповільнення розтягує запит у часі й оголює дефект: подвійний сабміт, роботу з ще незавантаженими даними, спінер, що завис назавжди. Кросбраузерний прийом — додати затримку саме потрібному запиту через перехоплення:
import { test, expect } from '@playwright/test';
test('повторний «Оформити» під час висячого запиту не створює другий order', async ({ page }) => {
let orderPosts = 0;
await page.route('**/api/checkout', async (route) => {
orderPosts += 1;
await new Promise((r) => setTimeout(r, 2000)); // тримаємо запит «у дорозі»
await route.continue();
});
await page.goto('https://shop.example.com/cart');
const submit = page.getByRole('button', { name: 'Оформити' });
await submit.click();
// друге натискання в обхід auto-wait: звичайний click() чекатиме enabled
// і не відтворить гонку, коли застосунок уже вимикає кнопку
await submit.dispatchEvent('click');
await expect(submit).toBeDisabled(); // застосунок мусив заблокувати кнопку
expect(orderPosts, 'подвійний сабміт створив два замовлення').toBe(1);
});
У Chromium той самий ефект дає емуляція умов мережі через протокол DevTools (CDP):
// Лише Chromium: Network.emulateNetworkConditions — команда CDP
const client = await context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (200 * 1024) / 8, // байти/с ≈ 200 кбіт/с
uploadThroughput: (200 * 1024) / 8,
latency: 400, // мс затримки на запит
});
Що дивитися і чому:
- Затримка через
route— точковий скальпель. Ти гальмуєш саме той запит, у якому підозрюєш гонку, і встигаєш натиснути кнопку двічі, поки перший виклик висить. ЛічильникorderPostsперетворює невидиму гонку на вимірюваний факт: якщо кнопка не блокується, до бекенду піде два POST. emulateNetworkConditions— тільки Chromium. Це команда CDP, тож для WebKit і Firefox через Playwright такого важеля немає;downloadThroughput/uploadThroughputзадають у байтах за секунду (-1вимикає тротлінг),latency— у мілісекундах. Коли треба кросбраузерно — бери затримку черезpage.route.- Це не hard sleep.
setTimeoutтут стоїть усередині обробника запиту — він моделює повільну мережу, а не «спить наосліп» у тілі тесту замість очікування умови. Сам тест синхронізується з реальним станом кнопки (toBeDisabled) і з лічильником запитів, а не з годинником.
Флак і природа синхронізації
- Розумію, чому флак (flakiness) — це не «магія», а розрив у часі: застосунок ще думає, а тест уже діє.
- Можу пояснити, що синхронізація — це діяти рівно тоді, коли застосунок готовий, а не за годинником.
- Знаю, що локатор (locator) — це рецепт пошуку елемента, а не сам елемент, тож знаходити треба в момент дії.
Очікування: auto-wait, explicit wait, hard sleep
- Можу пояснити різницю між авто-очікуванням (Playwright перевіряє актуальність перед дією) і явним очікуванням (
WebDriverWaitу Selenium, умову й таймаут пишу сам). - Знаю п'ять перевірок актуальності Playwright перед кліком: Attached, Visible, Stable, Receives events, Enabled (і Editable для введення).
- Можу пояснити, чому hard sleep (
sleep(3)) погано: він синхронізується з годинником, а не з застосунком — і мало на CI, і забагато локально. - Розумію, чому неявне очікування (implicit wait) у Selenium небезпечно змішувати з явним: непередбачувані сумарні таймаути; обирай щось одне.
- Знаю, що авто-очікування не всесильне: воно не бачить прикладних станів (навішений обробник, завершений AJAX).
Типові сценарії гонок
- Можу пояснити, чому елемент є в DOM, видимий і активний, але клік «нічого не робить» — не навісився обробник під час гідратації (hydration); рятує лише прикладний сигнал готовності.
- Знаю, чому проміс
waitForResponseстворюють ДО кліку, а не після — інакше швидка відповідь прослизне повз. - Розумію правило AJAX-сабміту: перевіряй UI-наслідок, який бачить користувач, а на мережеву відповідь синхронізуйся, коли зміна в UI надто тонка.
- Можу пояснити hit-test («receives events»): клік ловить верхній за z-порядком елемент, тож модалку/оверлей/банер треба дочекатися або закрити в передумові, а не бити
force: true. - Знаю, чому не варто прив'язуватися до проміжних URL при редіректі, а чекати фінальний стан (
waitForURL); розумію різницю кодів 301/302/303 vs 307/308 щодо збереження методу.
Ізоляція, середовище й діагностика
- Можу пояснити, чому тест зелений локально, але флакне на CI: ресурси й швидкість, headless/viewport,
TZ/локаль і дані середовища. - Розумію протікання стану (state leakage) і принципи ізоляції: свіжий контекст на тест, унікальні дані, власні передумови, teardown в
afterEach. - Знаю, чому падає паралельний прогін: конкуренція за спільні дані, глобальні перемикачі, обмежені ресурси, порядок як прихована залежність.
- Можу пояснити, навіщо мокати нестабільний зовнішній API (
page.route/cy.intercept) і виносити реальні контрактні тести в окрему повільну категорію. - Знаю, як тротлінгом мережі (CDP
Network.emulateNetworkConditionsабо затримка вroute) навмисно оголити race-баги на кшталт подвійного сабміту. - Можу провести алгоритм діагностики «застосунок / тест / середовище»: прочитати помилку буквально, глянути артефакти, відтворити умови CI, прогнати багато разів, ізолювати запуск — і чесно сказати «ще не встановлено», поки причина не виміряна.
Тест відкриває сторінку й одразу тисне кнопку «Зберегти», яка зʼявляється лише після AJAX-запиту. Що станеться в Selenium без очікування?
Питання
Що таке флак (flaky test) і в чому його справжня причина?