DOM, селектори та події
Зміст
Коли автотест «не знаходить» кнопку, здебільшого проблема не в кнопці. Проблема в тому, що інженер уявляє сторінку як статичний HTML-файл, а браузер працює з чимось зовсім іншим — з живою деревоподібною структурою, яку постійно перебудовує JavaScript. Цей розділ про те, з чим насправді взаємодіє ваш локатор, чому елементи «зникають» і «протухають», і як писати селектори, що переживуть наступний реліз фронтенду.
Що таке DOM
DOM (Document Object Model, об’єктна модель документа) — це представлення сторінки у вигляді дерева об’єктів, з яким може працювати JavaScript і, опосередковано, ваш тестовий фреймворк. Це не текст і не файл. Це структура в пам’яті браузера: кожен тег, кожен атрибут, кожен шматок тексту стає вузлом (node) — об’єктом із властивостями й методами. Вузли бувають різних типів: елементи (теги), текстові вузли, коментарі, а в корені всього дерева стоїть вузол-документ.
DOM — це стандарт, який супроводжує WHATWG (DOM Living Standard). Він визначає, які є типи вузлів, як вони пов’язані між собою, які методи дозволяють їх шукати й змінювати. Браузер, отримавши HTML, будує з нього DOM, а далі вся динаміка сторінки — це операції над цим деревом.
Ключова ідея для AQA: ваш локатор шукає елемент не в HTML-коді, а в поточному стані DOM. Це різні речі, і плутанина між ними — джерело найбільш підступних «плаваючих» падінь.
DOM vs вихідний HTML
Вихідний HTML (source HTML) — це текст, який сервер віддав у відповідь на запит. Ви бачите його, коли робите «View Page Source» (у більшості браузерів Ctrl+U, на macOS — Cmd+Option+U) або дивитесь на тіло HTTP-відповіді у вкладці Network. DOM — це те, що показує панель Elements у DevTools після того, як браузер розпарсив HTML і виконав увесь JavaScript.
Різниця буває драматична. Розгляньмо мінімальний вихідний HTML сучасного застосунку на React чи Vue:
<!doctype html>
<html>
<head><title>Dashboard</title></head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>
У вихідному коді немає ні таблиці, ні кнопок, ні тексту — лише порожній <div id="root">. Уся сторінка малюється на клієнті: app.js завантажується, виконується, звертається до API, отримує дані й наповнює #root сотнями вузлів. У DOM після цього — повноцінний інтерфейс.
| Характеристика | Вихідний HTML | DOM |
|---|---|---|
| Що це | Текст відповіді сервера | Дерево об’єктів у пам’яті |
| Де подивитись | View Source, Network → Response | DevTools → Elements |
| Коли фіксується | На момент відповіді | Змінюється безперервно |
| Впливає JS | Ні | Так, постійно |
| З чим працює локатор | — | Саме з ним |
Практичний висновок: інструменти, що читають лише вихідний HTML (простий HTTP-клієнт, curl, парсер без виконання JS), «не побачать» вміст SPA-застосунку. Тому для UI-тестів потрібен справжній браузер (Playwright, Selenium, Cypress) — він виконує JavaScript і працює з реальним DOM. Якщо ж вам потрібні лише дані, часто дешевше й стабільніше звернутися напряму до API, який цей DOM наповнює, і не залежати від верстки взагалі.
Динамічність DOM
DOM живий. JavaScript додає, видаляє, переміщує й переписує вузли у відповідь на дії користувача, відповіді від сервера, таймери. Кілька типових операцій, що постійно відбуваються на сторінці:
// додати новий рядок у список
const li = document.createElement('li');
li.textContent = 'Новий елемент';
document.querySelector('#list').appendChild(li);
// прибрати спінер після завантаження даних
document.querySelector('.spinner').remove();
// змінити текст і клас кнопки
const btn = document.querySelector('#save');
btn.textContent = 'Збережено';
btn.classList.add('is-success');
Кожна така операція змінює дерево. Модальне вікно, якого «не було», з’являється у DOM у момент кліку. Список, що підвантажується під час скролу, дописує вузли. Тост зі сповіщенням живе дві секунди й зникає.
Саме динамічність — головна причина, чому UI-тести бувають нестабільними (flaky, «плавають»). Тест і сторінка виконуються паралельно й незалежно. Тест може спробувати клікнути кнопку в ту мілісекунду, коли фреймворк ще не встиг її домалювати, — і отримати «елемент не знайдено». Через мить кнопка вже там. Отже, падіння не детерміноване: сьогодні зелене, завтра червоне, без жодних змін у коді.
Правильна відповідь на динамічність — це не sleep(3), а очікування (wait) конкретної умови: «дочекайся, поки елемент стане видимим/клікабельним/зникне». До цього ще повернемось у розділах про stale element і таймінг.
Дерево DOM: parent, child, sibling
Вузли DOM пов’язані родинними стосунками, і термінологія тут буквально сімейна. Для будь-якого вузла є:
- батьківський (parent) — вузол, що безпосередньо містить поточний;
- дочірні (children) — вузли, які поточний безпосередньо містить;
- сусідні (siblings) — вузли з тим самим батьком;
- нащадки (descendants) — усі вузли всередині, на будь-якій глибині;
- предки (ancestors) — усі вузли вгору по дереву аж до кореня.
<ul id="menu">
<li class="item">Головна</li>
<li class="item active">Профіль</li>
<li class="item">Вихід</li>
</ul>
Тут <ul> — батько трьох <li>. Три <li> — сусіди між собою. Текст «Профіль» — дочірній текстовий вузол свого <li>. Стосовно всього документа <ul> — нащадок <body>, а <body> — предок усіх трьох пунктів.
Чому це важливо для тестів. По-перше, селектори й XPath прямо оперують цими стосунками (комбінатори CSS, осі XPath). По-друге, коли на сторінці кілька однакових елементів, ви часто знаходите стабільний «якір» (унікальний контейнер) і йдете від нього до потрібного нащадка — це надійніше, ніж чіплятися за глобально неунікальний клас. По-третє, важлива відмінність: текстові вузли й пробіли між тегами теж є вузлами DOM. Тому element.childNodes і element.children дають різне — перше включає текст і коментарі, друге лише елементи-теги. У локаторах це інколи дає несподівані збіги.
Типи CSS-селекторів
CSS-селектор — це шаблон, за яким браузер (і тестовий фреймворк) відбирає елементи з DOM. Базові типи:
| Тип | Синтаксис | Що вибирає |
|---|---|---|
| Універсальний | * | будь-який елемент |
| За тегом (type) | button | усі <button> |
| За класом | .item | елементи з class=“item” |
| За id | #save | елемент з id=“save” |
| За атрибутом | [type="email"] | елементи з таким атрибутом |
| Група | h1, h2, h3 | усі перелічені |
Селектори за атрибутами мають гнучкі варіанти зіставлення, дуже корисні в тестах, коли значення частково динамічне:
| Селектор | Значення атрибута |
|---|---|
[data-id="42"] | точно дорівнює 42 |
[class*="btn"] | містить підрядок btn (будь-де) |
[class^="icon-"] | починається з icon- |
[class$="-active"] | закінчується на -active |
[href~="new"] | містить new як окреме слово у списку, розділеному пробілами |
Приклад із реального життя: фреймворки часто генерують класи виду Button_root__a1b2c, де хвіст — випадковий хеш, що змінюється при кожній збірці. Чіплятися за повний клас безглуздо, а от [class^="Button_root"] переживе перезбірку. Хоча краще взагалі попросити розробників додати стабільний атрибут — про це нижче.
Про специфічність (specificity) для тестів достатньо пам’ятати одне: id-селектор сильніший за клас, клас сильніший за тег. Але для локаторів це вторинне — нам потрібна не «перемога в каскаді», а унікальність і стабільність збігу.
Комбінатори
Комбінатори (combinators) описують стосунки між елементами. Їх чотири, і плутанина між ними — часта причина, коли селектор знаходить не те або нічого.
| Комбінатор | Синтаксис | Значення |
|---|---|---|
| Нащадок (descendant) | A B (пробіл) | B десь усередині A, на будь-якій глибині |
| Дочірній (child) | A > B | B — безпосередня дитина A |
| Сусідній наступний (adjacent) | A + B | B — рівно наступний сусід одразу після A |
| Загальний сусідній (general) | A ~ B | B — будь-який сусід після A |
<form id="login">
<label>Email</label>
<input name="email">
<span class="error">Обов'язкове поле</span>
</form>
#login input /* нащадок: input усередині форми */
#login > input /* дочірній: input прямо у формі */
input + span.error /* span.error одразу після input */
label ~ span /* будь-який span-сусід після label */
Найпоширеніша помилка новачка — сплутати пробіл і >. div span знайде <span> на будь-якій глибині всередині div, зокрема глибоко вкладені; div > span — лише прямих дітей. У глибоко вкладеній верстці це різниця між одним потрібним елементом і десятком зайвих. Комбінатор + особливо корисний у тестах для пошуку повідомлення про помилку, що стоїть одразу після конкретного поля вводу.
Псевдокласи
Псевдоклас (pseudo-class) вибирає елементи за станом або позицією, а не за розміткою. Записується через двокрапку.
| Псевдоклас | Що вибирає |
|---|---|
:hover, :focus | елемент під курсором / у фокусі |
:checked, :disabled | стан чекбокса/поля |
:first-child, :last-child | перша/остання дитина |
:nth-child(2) | друга дитина |
:not(.active) | усі, крім тих, що мають клас active |
:empty | без вмісту |
input:disabled /* усі вимкнені поля */
li:nth-child(odd) /* непарні рядки списку */
button:not([disabled]) /* активні кнопки */
tr:first-child td /* клітинки першого рядка */
Для тестів псевдокласи станів цінні тим, що дозволяють перевіряти саме стан без окремого читання атрибутів: input:checked знайде елемент лише коли він відмічений. Але з позиційними (:nth-child) варто бути обережним — вони крихкі: додали один рядок вище, і :nth-child(3) вказує вже на інше. Позицію варто використовувати як останній засіб, коли стабільнішого якоря немає.
Окремо про :has() — реляційний псевдоклас, який дозволяє фільтрувати елемент за його нащадком (наприклад, tr:has(input:checked) — рядок, у якому є відмічений чекбокс). Це рідкісний випадок, коли CSS уміє «дивитися вниз» на нащадка і відбирати предка за ним. Донедавна підтримки не було, тепер :has() доступний в усіх основних браузерах (Chrome/Edge — з версії 105 і Safari з 15.4, обидва 2022 року; Firefox — з версії 121 наприкінці 2023) і в селекторному рушії Playwright. Якщо ж тести ганяються на старіших браузерних складках, підтримку варто перевірити на цільовій конфігурації.
XPath проти CSS: коли потрібен XPath
XPath — це мова запитів для навігації по XML/HTML-дереву. Вона потужніша за CSS, але й багатослівніша, повільніша для читання людиною й, як правило, крихкіша.
CSS: #login > input[name="email"]
XPath: //form[@id="login"]/input[@name="email"]
| Можливість | CSS | XPath |
|---|---|---|
| За класом/id/атрибутом | так | так |
| Спуск до нащадка/дитини | так | так |
| Підйом до предка | ні | так (ancestor::, ..) |
| Пошук за текстом вузла | ні (напряму) | так (text(), contains()) |
| Читабельність | вища | нижча |
Головна причина тягнутися по XPath — дві речі, яких CSS не вміє: підйом угору по дереву (від елемента до його предка) і зіставлення за текстовим вмістом.
//button[contains(text(), "Видалити")]
//td[text()="Іван"]/ancestor::tr//button[@class="edit"]
Другий приклад читається так: знайти клітинку з текстом «Іван», піднятися до її рядка таблиці, і в цьому рядку знайти кнопку редагування. CSS так не вміє, бо не має ні пошуку за текстом, ні осі предків.
Проте сучасні тестові фреймворки закривають обидві ці потреби власними засобами: пошук за текстом і за роллю, фільтрація локаторів (locator.filter({ hasText })), відносне позиціювання. Тому практичне правило: за замовчуванням CSS або спеціалізовані локатори; XPath — точково, коли інакше не виходить, зокрема для складної навігації в таблицях.
Дрібниця, яка ловить багатьох: у XPath зіставлення text()="Іван" чутливе до пробілів і регістру, а якщо в елемента кілька текстових вузлів, contains(text(), ...) перевіряє лише перший з них (у XPath 1.0 функція бере рядкове значення першого вузла набору), а не весь вкладений текст. Для «тексту з урахуванням вкладеності» використовують . (рядкове значення всього елемента) замість text(): //button[contains(., "Видалити")].
Локатори за роллю та текстом
Найстабільніший спосіб знайти елемент — той самий, яким його знаходить користувач і читалка екрана: за роллю (role) і видимим текстом. На цьому побудовані сучасні рекомендовані API — getByRole, getByText, getByLabel у Playwright і споріднені getByRole, getByText, getByLabelText у Testing Library.
// Playwright
await page.getByRole('button', { name: 'Зберегти' }).click();
await page.getByLabel('Email').fill('user@example.com');
await page.getByText('Профіль оновлено').waitFor();
Роль відповідає семантиці елемента в дереві доступності (accessibility tree) — тому самому, що бачить асистивна технологія. Кнопка має роль button, посилання — link, поле вводу — textbox. Доступне ім’я (accessible name) формується з видимого тексту, aria-label, пов’язаного <label> тощо.
Чому такі локатори стійкіші:
- Не залежать від верстки. Розробник поміняв
<div onclick>на справжній<button>, переверстав контейнери, переназвав CSS-класи — роль і напис лишились. Селектор.btn-primary.mt-3 > spanпри такому рефакторингу зламається, аgetByRole('button', { name: 'Зберегти' })— ні. - Ближче до наміру. Тест перевіряє поведінку з погляду користувача: «є кнопка Зберегти», а не «є div з класом btn». Це те, що насправді має працювати.
- Побічно перевіряють доступність. Якщо
getByRoleне знаходить кнопку, бо це насправді клікабельний<div>без ролі, — це водночас сигнал про проблему доступності. Тест ловить реальний дефект, а не лише свою крихкість.
Слабке місце — текст, залежний від локалі: якщо застосунок багатомовний, name: 'Зберегти' зламається на англійській збірці. Тут або прив’язуються до ключа перекладу через окремий атрибут, або тестують у фіксованій локалі.
data-testid
Коли ні роль, ні текст не дають надійного якоря (наприклад, іконка без підпису, або текст постійно змінюється), використовують спеціальний атрибут-«ярлик» для тестів — за домовленістю це найчастіше data-testid.
<button data-testid="user-menu-toggle" class="Header_iconBtn__x9f2">
<svg>...</svg>
</button>
await page.getByTestId('user-menu-toggle').click();
Переваги очевидні: атрибут існує виключно заради тестів, тому його не чіпають при зміні стилів чи тексту; він унікальний і читабельний; контракт між тестом і кодом явний. Недоліки теж є: це домовленість, яку треба підтримувати в команді; атрибут не перевіряє доступність (на відміну від ролі); і його легко ненавмисно видалити, бо він «нічого не робить» з погляду розробника, який не пише тести.
Розумна ієрархія вибору локатора для більшості випадків: роль + доступне ім’я → текст/лейбл → data-testid → CSS за стабільним атрибутом → XPath. CSS за класами й позиційні селектори — ближче до кінця списку, а прив’язка до автогенерованих класів чи глибоких ланцюжків div > div > div — те, чого варто уникати як основного підходу.
Події: click, input, submit
Взаємодія користувача зі сторінкою — це потік подій (events). Браузер генерує подію, а код через обробники (event handlers) на неї реагує.
| Подія | Коли виникає | Типове застосування |
|---|---|---|
click | натискання й відпускання кнопки миші на елементі | кнопки, посилання |
input | будь-яка зміна значення поля | «живий» пошук, валідація на льоту |
change | зміна значення (для тексту — після втрати фокуса) | select, чекбокси |
submit | відправлення форми | обробка форми до відходу на сервер |
focus / blur | отримання / втрата фокуса | підсвітка активного поля |
keydown / keyup | натискання клавіш | гарячі клавіші, маски вводу |
document.querySelector('#login').addEventListener('submit', (e) => {
e.preventDefault(); // не перезавантажувати сторінку
validateAndSend();
});
document.querySelector('#search').addEventListener('input', (e) => {
filterList(e.target.value); // фільтр при кожному символі
});
Для AQA критична різниця між input і change, а також те, що фреймворки часто слухають саме input. Якщо тест виставляє значення поля напряму через властивість (element.value = '...') без генерації події, застосунок «не помітить» зміни — стан у React/Vue не оновиться. Тому надійні дії фреймворків (fill, type) не просто пишуть значення, а емулюють справжні події вводу так, як це робить браузер. Це одна з причин не смикати DOM напряму через evaluate, коли є штатний метод дії.
Ще один нюанс — e.preventDefault(). Обробник submit, що викликає preventDefault, скасовує стандартну поведінку (перехід за формою). Якщо у вашому тесті клік по «Надіслати» не веде до навігації — це може бути не баг, а свідомо перехоплена подія, і перевіряти треба не URL, а появу тоста чи запит у Network.
Bubbling і capturing
Подія проходить DOM у три фази, і розуміння цього прямо впливає на те, як писати обробники й чому іноді «клік спрацьовує двічі».
- Захоплення (capturing) — подія йде згори вниз: від кореня документа до цільового елемента.
- Ціль (target) — досягає елемента, на якому виникла.
- Спливання (bubbling) — піднімається назад угору, від цілі до кореня.
За замовчуванням обробники спрацьовують на фазі спливання. Це означає, що клік по кнопці всередині картки «сплине» і на картку, і на її контейнер, і вище. Щоб слухати на фазі захоплення, третій аргумент addEventListener роблять true (або передають { capture: true }).
outer.addEventListener('click', () => console.log('outer'), true); // capturing
inner.addEventListener('click', () => console.log('inner')); // bubbling
// клік по inner надрукує: outer, inner
Зупинити спливання можна через e.stopPropagation(). Це часте джерело «загадок»: клік по вкладеному елементу не доходить до зовнішнього обробника, бо хтось усередині зупинив поширення. Не всі події спливають однаково — наприклад, focus і blur не спливають, а їхні аналоги focusin/focusout спливають; submit спливає. Це впливає на те, де можна повісити один обробник на групу елементів.
Делегування подій
Делегування (event delegation) — техніка, що прямо випливає зі спливання: замість того, щоб вішати обробник на кожен із сотні елементів, вішають один на спільного предка й дивляться, звідки прийшла подія.
document.querySelector('#todo-list').addEventListener('click', (e) => {
const btn = e.target.closest('.delete-btn');
if (btn) btn.closest('li').remove();
});
Тут важлива деталь: e.target — це найглибший елемент, по якому реально клікнули. Якщо кнопка містить іконку (<svg>), при кліку по іконці e.target буде саме <svg>, а не кнопка, тож наївне e.target.matches('.delete-btn') промахнеться. Тому беруть e.target.closest('.delete-btn') — воно підніметься від місця кліку до потрібного предка.
Один обробник на #todo-list обслуговує всі кнопки видалення, зокрема ті, що з’являться в майбутньому. Це ефективно й пояснює важливий для тестів факт: обробник може висіти не на тому елементі, по якому ви клікаєте. Тому спроби «перевірити, чи є onclick на кнопці» безглузді — його там може не бути фізично. Перевіряти треба результат кліку (щось змінилось у DOM, пішов запит), а не наявність обробника.
Делегування також пояснює, чому щойно доданий динамічний елемент одразу «працює» без перевішування обробників — і чому клік по ньому в тесті має спрацьовувати, щойно елемент з’явився й став видимим.
Shadow DOM і чому в ньому важко шукати
Shadow DOM — це механізм інкапсуляції: елемент отримує власне приховане під-дерево (shadow root), ізольоване від основного документа. Стилі й розмітка всередині не «протікають» назовні й навпаки. Так побудовані нативні складні елементи (<video>, <input type="date">) і багато дизайн-систем.
const host = document.querySelector('my-widget');
const shadow = host.shadowRoot; // доступ до тіньового дерева (для open)
shadow.querySelector('.internal-button');
Ключова проблема для тестів: звичайний document.querySelector не «пробиває» межу тіньового дерева. Селектор, написаний для основного документа, не бачить елементи всередині shadow root. Треба спершу дістатись хоста, потім його shadowRoot, і шукати вже там — інколи через кілька вкладених тіней поспіль.
Є два режими: open (до shadowRoot можна дістатись через JS) і closed (посилання на тіньове дерево не віддається зовні взагалі — shadowRoot повертає null). Із closed shadow DOM автоматизація майже безсила, бо туди немає програмного доступу за задумом.
Хороша новина: Playwright уміє автоматично «пробивати» відкриті тіньові дерева для своїх CSS- і текстових локаторів (це прямо зафіксовано в його документації) — тому getByRole/getByText часто просто працюють крізь open shadow DOM. А ось XPath межу тіньового дерева не перетинає (за документацією Playwright, «XPath does not pierce shadow roots»), тому локатори на XPath крізь Shadow DOM, як правило, не працюють.
Нюанс. У Selenium доступ до open shadow DOM дає
getShadowRoot()(з версії 4.0; для Chrome 96+ потрібен Selenium 4.1+ через зміну серіалізації). Closed shadow DOM програмно недоступний за задумом — це стосується всіх інструментів.
Web components
Web components — це набір браузерних технологій для створення власних елементів: користувацькі теги (Custom Elements, наприклад <user-card>), Shadow DOM для інкапсуляції та шаблони (<template>). Дизайн-системи (наприклад, Salesforce Lightning) і багато корпоративних UI-китів активно на них побудовані.
Для тестів це означає дві речі. По-перше, тег елемента може бути нестандартним — <my-button> замість <button>, — тож пошук за тегом даватиме несподіванки, а роль доводиться виставляти вручну через атрибут role/ARIA (або її немає зовсім). По-друге, начинка компонента майже напевно захована в Shadow DOM з усіма наслідками з попереднього пункту. Практично: орієнтуйтесь на роль і доступне ім’я самого компонента, а домовленість про data-testid на web components економить багато болю.
iframe і чому елементи в ньому «не знаходяться»
iframe вбудовує в сторінку окремий, самостійний документ зі своїм власним DOM. Це не «ще один шар» поточної сторінки — це інший документ, зі своїм window і своїм деревом.
Саме тому найпоширеніша розгубленість: у DevTools елемент чудово видно, а локатор його «не знаходить». Причина в тому, що ваш пошук іде по DOM головного документа, а елемент живе в DOM айфрейма. Треба спершу перемкнути контекст на потрібний фрейм, і лише тоді шукати всередині.
// Playwright
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Номер картки').fill('4111111111111111');
// Selenium
driver.switchTo().frame("payment-iframe");
driver.findElement(By.id("card-number")).sendKeys("4111...");
driver.switchTo().defaultContent(); // повернутись назад
У Selenium після роботи з фреймом обов’язково повертаються в основний контекст (defaultContent), інакше подальші пошуки шукатимуть у фреймі. Класичні кейси з iframe — платіжні форми, віджети підтримки, вбудовані відео й рекламні блоки.
Окремий момент — політика одного походження (same-origin policy). Вона забороняє JavaScript батьківської сторінки читати вміст iframe з іншого домену: iframe.contentDocument поверне null, а спроба обійти фрейм через page.evaluate зазнає невдачі. Але це стосується коду, що виконується всередині сторінки. Драйвери автоматизації працюють на рівні браузера, поза цим обмеженням, тому і Playwright (frameLocator), і Selenium (switchTo().frame()) уміють перемкнути контекст у кросдоменний фрейм і шукати елементи всередині. Різниця радше в ергономіці: frameLocator вбудований у ланцюжок локаторів з авточеканням, тоді як у Selenium контекст перемикають вручну й не забувають повертатися назад.
Нюанс. Обмеження на роботу з кросдоменними iframe залежать від браузера, драйвера та їхніх версій — перевір на цільовому стеку, перш ніж закладатися на це в тестах.
Stale element
Stale element (застарілий елемент), він же StaleElementReferenceException у Selenium, — це коли тест тримає посилання на вузол DOM, а цього вузла вже фізично немає в дереві або його замінили новим.
Механіка проста. Ви знайшли елемент і зберегли посилання. Тим часом фреймворк перерендерив шматок сторінки: старий вузол видалено, на його місці — новий, зовні ідентичний, але це інший об’єкт. Ваше збережене посилання тепер вказує «в порожнечу».
WebElement row = driver.findElement(By.cssSelector(".row"));
refreshTable(); // React перемалював таблицю
row.click(); // StaleElementReferenceException
Типові сценарії: пересортування таблиці, оновлення списку після відповіді сервера, перемикання вкладок, будь-який ререндер. Особливо підступно, коли ви зберегли список елементів у циклі, а всередині циклу дія викликає перемальовування — усі наступні посилання протухають.
Способи боротьби:
- Не зберігати посилання надовго. Знаходити елемент безпосередньо перед дією, а не заздалегідь.
- Повторний пошук. Спіймали stale — знайдіть заново й повторіть.
- Ліниві локатори. Playwright-локатор — це не посилання на вузол, а «рецепт пошуку». Він резолвиться в реальний елемент у момент дії й автоматично перезнаходить елемент, тому класична проблема staleness там майже не виникає. Це фундаментальна відмінність від старої моделі Selenium
WebElement.
Чому «not found» — це часто питання таймінгу, а не селектора
Повернімось до тези з початку розділу. Коли тест каже «елемент не знайдено», перша й неправильна реакція — правити селектор. Значно частіше селектор коректний, а проблема в часі.
Тест і сторінка — це два процеси, що біжать паралельно. Тест виконує крок за мілісекунди; сторінка ж малює елементи асинхронно — після відповіді API, після анімації, після наступного циклу рендеру фреймворку. Якщо тест шукає елемент раніше, ніж той з’явився в DOM, — «not found», хоча за пів секунди елемент буде на місці. Звідси й нестабільність: результат залежить від того, хто цього разу встиг першим.
Як розрізнити причини на практиці:
| Симптом | Ймовірна причина |
|---|---|
| Падає завжди, стабільно | справді неправильний селектор |
| Падає час від часу, «плаває» | таймінг / гонка з рендером |
| Знаходить у DevTools вручну, а тест — ні | елемент з’являється пізніше, або він в iframe/shadow |
| Знаходить, але клік не спрацьовує | елемент є, та ще не інтерактивний (перекритий, disabled) |
Правильне лікування таймінгу — очікування конкретної умови, а не сон:
// ПОГАНО: гадаємо тривалість, флак + повільно
await sleep(3000);
await page.click('#save');
// ДОБРЕ: чекаємо саме те, що потрібно
await page.getByRole('button', { name: 'Зберегти' }).click();
// Playwright сам чекає, поки елемент з'явиться, стане видимим і клікабельним
Playwright тут вбудовує auto-waiting: перед дією він чекає, поки елемент прикріплений до DOM, видимий, стабільний (не рухається дві послідовні анімаційні кадри) і готовий приймати події (не перекритий іншим елементом), у межах таймауту. Це знімає більшість гонок без ручних очікувань. У Selenium ту саму роль грають явні очікування (WebDriverWait з умовами на кшталт visibilityOfElementLocated), тоді як Thread.sleep — антипатерн, бо або марнує час, або все одно програє гонку на повільному прогоні.
Практичний висновок для інженера: перш ніж чіпати селектор, з’ясуйте, чи елемент узагалі є в DOM на момент пошуку і чи не живе він в іншому контексті (iframe, shadow DOM). Найчастіше «not found» — це не «я неправильно описав елемент», а «я подивився не туди або надто рано».
Що таке DOM і чим він відрізняється від HTML-коду сторінки?
DOM (Document Object Model) — це представлення сторінки у вигляді дерева об'єктів у пам'яті браузера, а HTML — лише текст, який сервер віддав у відповідь. Браузер парсить HTML, будує з нього дерево вузлів (елементи, текстові вузли, коментарі з документом у корені), і далі JavaScript працює саме з цим деревом: додає, видаляє й переписує вузли. Тому DOM живий і змінюється безперервно, а вихідний HTML зафіксований на момент відповіді сервера. Для автотестів це ключова річ: локатор шукає елемент не в HTML-коді, а в поточному стані DOM, і плутанина між ними — джерело найпідступніших «плаваючих» падінь.
Чому «View Page Source» показує майже порожню сторінку, а в браузері видно повний інтерфейс?
Тому що View Source показує вихідний HTML з сервера, а інтерфейс намальовано JavaScript-ом уже на клієнті. У SPA-застосунку (React, Vue) сервер часто віддає лише порожній <div id="root"> і скрипт; скрипт виконується, ходить в API, отримує дані й наповнює дерево сотнями вузлів — усе це видно тільки в DevTools → Elements, який відображає актуальний DOM. Практичний наслідок: інструменти без виконання JS (curl, простий HTTP-клієнт, парсер) «не побачать» вміст SPA — для UI-тестів потрібен справжній браузер (Playwright, Selenium, Cypress). А якщо потрібні лише дані, дешевше й стабільніше сходити напряму в API, який цей DOM наповнює.
Що означають parent, child, sibling у DOM? І чим children відрізняється від childNodes?
Це «родинні» стосунки вузлів: parent безпосередньо містить поточний вузол, children — ті, кого він безпосередньо містить, siblings — вузли з тим самим батьком; descendants і ancestors — те саме, але на будь-яку глибину вниз чи вгору. На цих стосунках побудовані комбінатори CSS і осі XPath, тому термінологію питають навіть у джунів. Тонкість: текстові вузли й пробіли між тегами — теж вузли DOM, тому element.childNodes включає текст і коментарі, а element.children — лише елементи-теги. У практиці тестів це виринає як несподівані збіги локаторів. Корисний патерн: коли однакових елементів багато, знайти стабільний «якір»-контейнер і йти від нього до нащадка — надійніше, ніж чіплятися за глобально неунікальний клас.
Які базові типи CSS-селекторів ти знаєш?
Універсальний *, за тегом (button), за класом (.item), за id (#save), за атрибутом ([type="email"]) і група через кому (h1, h2, h3). Окремо варто знати варіанти зіставлення атрибутів: [data-id="42"] — точна рівність, [class*="btn"] — містить підрядок, [class^="icon-"] — починається з, [class$="-active"] — закінчується на, [href~="new"] — містить слово у списку через пробіл. Для тестів атрибутні селектори особливо цінні, коли значення частково динамічне. Про специфічність достатньо пам'ятати, що id сильніший за клас, а клас за тег, — але для локаторів важлива не «перемога в каскаді», а унікальність і стабільність збігу.
Чим div span відрізняється від div > span?
Пробіл — це комбінатор нащадка: div span знайде span на будь-якій глибині всередині div. Знак > — комбінатор дитини: div > span знайде лише прямих дітей. У глибоко вкладеній верстці це різниця між одним потрібним елементом і десятком зайвих, і плутанина між ними — найпоширеніша помилка новачка. Ще два комбінатори: A + B — рівно наступний сусід одразу після A, і A ~ B — будь-який сусід після A. У тестах + особливо корисний, щоб знайти повідомлення про помилку, яке стоїть одразу після конкретного поля вводу: input + span.error.
Що таке псевдокласи і чому :nth-child вважається поганим вибором для локатора?
Псевдоклас вибирає елементи за станом або позицією, а не за розміткою: :hover, :focus, :checked, :disabled, :first-child, :nth-child(2), :not(.active). Псевдокласи станів у тестах корисні — input:checked знайде чекбокс лише коли він відмічений, тобто перевіряє стан без окремого читання атрибутів. А от позиційні крихкі за визначенням: додали один рядок вище — і :nth-child(3) вказує вже на інший елемент, тест падає без жодної зміни в тестованій фічі. Позицію використовують як останній засіб, коли стабільнішого якоря немає. Бонус для сеньйорського рівня — :has(): реляційний псевдоклас, що відбирає предка за нащадком (tr:has(input:checked) — рядок із відміченим чекбоксом); він уже підтримується всіма основними браузерами й селекторним рушієм Playwright.
На сторінці класи виду Button_root__a1b2c, і хеш змінюється при кожній збірці. Як писати локатор?
Чіплятися за повний клас безглуздо — хеш згенерований і «протухне» на наступному релізі. Робочий прийом — атрибутний селектор за стабільною частиною: [class^="Button_root"] (починається з) або [class*="Button_root"] (містить) переживе перезбірку. Але це все одно милиця: краще попросити розробників додати стабільний атрибут (data-testid) або перейти на локатор за роллю і доступним ім'ям, які від класів не залежать узагалі. На співбесіді тут очікують не лише синтаксис, а й розуміння, що прив'язка до автогенерованих класів — антипатерн як основний підхід.
Коли справді потрібен XPath, а коли достатньо CSS?
XPath потрібен для двох речей, яких CSS не вміє: підйом угору по дереву (ancestor::, ..) і зіставлення за текстовим вмістом (text(), contains()). Класичний кейс — таблиці: //td[text()="Іван"]/ancestor::tr//button[@class="edit"] — знайти клітинку за текстом, піднятися до рядка і в ньому знайти кнопку редагування; CSS так не вміє. В усьому іншому CSS читабельніший, а сучасні фреймворки закривають обидві потреби своїми засобами: пошук за текстом і роллю, locator.filter({ hasText }), відносне позиціювання. Тому практичне правило: за замовчуванням CSS або спеціалізовані локатори, XPath — точково, коли інакше не виходить. Мінус XPath ще й у тому, що він, як правило, багатослівніший і крихкіший.
У чому підступ contains(text(), "...") у XPath?
У тому, що коли в елемента кілька текстових вузлів, contains(text(), ...) перевіряє лише перший з них, а не весь вкладений текст — у XPath 1.0 функція бере рядкове значення першого вузла набору. Плюс зіставлення text()="Іван" чутливе до пробілів і регістру. Тому кнопка <button> з іконкою і текстом у вкладеному <span> може «не знаходитись», хоча текст на екрані є. Для тексту з урахуванням вкладеності використовують крапку — рядкове значення всього елемента: //button[contains(., "Видалити")]. Це типове сеньйорське уточнення, яке відрізняє людину, що реально дебажила XPath-локатори, від того, хто вчив синтаксис за шпаргалкою.
Чому getByRole('button', { name: 'Зберегти' }) стабільніший за CSS-селектор по класах?
Бо він шукає елемент так само, як користувач і читалка екрана: за роллю в дереві доступності (accessibility tree) і доступним ім'ям, а не за деталями верстки. Розробник переверстав контейнери, переназвав CSS-класи, замінив <div onclick> на справжній <button> — роль і напис лишились, локатор живий; .btn-primary.mt-3 > span при тому ж рефакторингу зламається. Такий тест ближчий до наміру: перевіряє «є кнопка Зберегти», а не «є div з класом btn». Бонус — побічна перевірка доступності: якщо getByRole не знаходить кнопку, бо це клікабельний <div> без ролі, тест зловив реальний дефект. Слабке місце — локалізація: name: 'Зберегти' зламається на англійській збірці, тому багатомовні застосунки тестують у фіксованій локалі або прив'язуються до ключа перекладу через окремий атрибут.
Навіщо потрібен data-testid і яка взагалі ієрархія вибору локаторів?
data-testid — атрибут-«ярлик», що існує виключно заради тестів: його не чіпають при зміні стилів чи тексту, він унікальний і читабельний, контракт між тестом і кодом явний. Використовують його, коли ні роль, ні текст не дають надійного якоря — іконка без підпису, текст, що постійно змінюється. Мінуси: домовленість треба підтримувати в команді; атрибут не перевіряє доступність; і його легко ненавмисно видалити, бо для розробника він «нічого не робить». Розумна ієрархія: роль + доступне ім'я → текст/лейбл → data-testid → CSS за стабільним атрибутом → XPath. Класи й позиційні селектори — у кінці списку, а автогенеровані класи та ланцюжки div > div > div — те, чого варто уникати як основного підходу.
Чим подія input відрізняється від change і чому це важливо для автотестів?
input виникає при будь-якій зміні значення поля (кожен символ), change для текстових полів — лише після втрати фокуса, а для select і чекбоксів — при зміні значення. Фреймворки на кшталт React/Vue часто слухають саме input, тож якщо тест виставить значення напряму через властивість (element.value = '...') без генерації події, застосунок «не помітить» зміни — стан не оновиться. Тому штатні дії фреймворків (fill, type) не просто пишуть значення, а емулюють справжні події вводу так, як це робить браузер. Звідси практичне правило: не смикати DOM напряму через evaluate, коли є штатний метод дії. І суміжний нюанс: якщо клік по «Надіслати» не веде до навігації, це може бути не баг, а e.preventDefault() в обробнику submit — тоді перевіряти треба не URL, а появу тоста чи запит у Network.
Розкажи про фази проходження події: що таке bubbling і capturing?
Подія проходить DOM у три фази: захоплення (capturing) — згори вниз від кореня документа до цілі, ціль (target) — сам елемент, на якому подія виникла, і спливання (bubbling) — назад угору від цілі до кореня. За замовчуванням обробники спрацьовують на фазі спливання, тому клік по кнопці всередині картки «сплине» і на картку, і на контейнер, і вище — звідси ефект «клік спрацьовує двічі». Щоб слухати на фазі захоплення, передають true або { capture: true } третім аргументом addEventListener. Спливання можна зупинити через e.stopPropagation() — часте джерело «загадок», коли клік по вкладеному елементу не доходить до зовнішнього обробника. І не всі події спливають: focus/blur не спливають (їхні аналоги focusin/focusout — спливають), а submit спливає; це визначає, де можна повісити один обробник на групу елементів.
Що таке делегування подій і як воно впливає на тестування?
Делегування — техніка, що випливає зі спливання: замість обробника на кожному з сотні елементів вішають один на спільного предка й дивляться, звідки прийшла подія. Важлива деталь: e.target — найглибший елемент, по якому реально клікнули; якщо кнопка містить <svg>, при кліку по іконці e.target буде саме <svg>, тож наївне e.target.matches('.delete-btn') промахнеться — беруть e.target.closest('.delete-btn'). Для тестів звідси два висновки. Перший: обробник може висіти не на тому елементі, по якому ви клікаєте, тому «перевірити, чи є onclick на кнопці» безглуздо — перевіряти треба результат кліку: зміну в DOM або запит у Network. Другий: делегування пояснює, чому щойно доданий динамічний елемент одразу «працює» без перевішування обробників — клік у тесті має спрацьовувати, щойно елемент з'явився й став видимим.
Що таке Shadow DOM і чому звичайні селектори в ньому не працюють?
Shadow DOM — механізм інкапсуляції: елемент отримує власне приховане під-дерево (shadow root), ізольоване від основного документа, — так побудовані нативні складні елементи (<video>, <input type="date">) і багато дизайн-систем на web components. Ключова проблема: document.querySelector не «пробиває» межу тіньового дерева — селектор для основного документа не бачить елементи всередині shadow root, треба спершу дістатись хоста, потім його shadowRoot, і шукати вже там, інколи через кілька вкладених тіней. Режимів два: open (до shadowRoot можна дістатись через JS; у Selenium — getShadowRoot() з версії 4.0) і closed (shadowRoot повертає null, програмного доступу немає за задумом — автоматизація майже безсила). Хороша новина для Playwright: його CSS- і текстові локатори автоматично пробивають відкриті тіньові дерева, тож getByRole/getByText часто просто працюють. А ось XPath межу shadow root не перетинає — за документацією Playwright, XPath не пробиває тіньові дерева, тому XPath-локатори крізь Shadow DOM, як правило, не працюють.
Елемент видно в DevTools, але локатор його не знаходить. Які причини перевіриш?
Перше — інший контекст: елемент може жити в iframe (це окремий документ зі своїм власним DOM і window, пошук по головному документу його не бачить) або в Shadow DOM. Для iframe треба перемкнути контекст: у Playwright — page.frameLocator('#payment-iframe') і далі звичайні локатори з авточеканням; у Selenium — switchTo().frame(...), а після роботи обов'язково switchTo().defaultContent(), інакше всі подальші пошуки шукатимуть у фреймі. Класика жанру з iframe — платіжні форми, віджети підтримки, вбудовані відео. Друге — таймінг: у DevTools ви дивитесь на DOM «зараз», а тест шукав елемент раніше, ніж той з'явився. Окремий нюанс — same-origin policy: JS сторінки не може читати вміст кросдоменного iframe (contentDocument поверне null, обхід через page.evaluate не спрацює), але драйвери автоматизації працюють на рівні браузера поза цим обмеженням, тому frameLocator і switchTo().frame() зазвичай уміють перемкнутися й у кросдоменний фрейм — але деталі залежать від браузера, драйвера та їхніх версій, тож на цільовому стеку це варто перевірити.
Що таке StaleElementReferenceException і як з ним боротися?
Це ситуація, коли тест тримає посилання на вузол DOM, а вузла вже фізично немає в дереві або його замінили новим. Механіка: знайшли елемент, зберегли посилання; тим часом фреймворк перерендерив шматок сторінки — старий вузол видалено, на його місці новий, зовні ідентичний, але це інший об'єкт, і збережене посилання вказує «в порожнечу». Типові сценарії: пересортування таблиці, оновлення списку після відповіді сервера, будь-який ререндер; особливо підступно, коли зберегли список елементів у циклі, а дія всередині циклу викликає перемальовування — протухають усі наступні посилання. Боротьба: не зберігати посилання надовго (шукати безпосередньо перед дією), повторний пошук після падіння, або ліниві локатори. Playwright-локатор — це не посилання на вузол, а «рецепт пошуку», який резолвиться в реальний елемент у момент дії, тому класичний staleness там майже не виникає — це фундаментальна відмінність від старої моделі Selenium WebElement.
Тест періодично падає з «element not found», хоча селектор коректний. Як діагностуєш і лікуєш?
Спершу визначаю характер падіння, бо симптом підказує причину: падає завжди й стабільно — справді неправильний селектор; падає час від часу — таймінг, гонка з рендером; вручну в DevTools знаходиться, а тестом ні — елемент з'являється пізніше або живе в iframe/shadow DOM; знаходиться, але клік не проходить — елемент ще не інтерактивний (перекритий, disabled). Корінь проблеми в тому, що тест і сторінка — два паралельні незалежні процеси: тест виконує крок за мілісекунди, а сторінка малює елементи асинхронно — після відповіді API, анімації, наступного циклу рендеру. Лікування — очікування конкретної умови, а не sleep(3000): сон або марнує час, або все одно програє гонку на повільному прогоні. У Playwright це вбудований auto-waiting: перед дією він чекає, поки елемент прикріплений до DOM, видимий, стабільний і не перекритий, у межах таймауту; у Selenium ту саму роль грає WebDriverWait з умовами на кшталт visibilityOfElementLocated, а Thread.sleep — антипатерн. Практичне правило: перш ніж правити селектор, з'ясуй, чи елемент узагалі є в DOM на момент пошуку і чи не живе він в іншому контексті — найчастіше «not found» означає «подивився не туди або надто рано».
Три кейси, де падіння локатора вирішує не селектор, а розуміння, з чим той локатор працює: чому HTTP-клієнт «не бачить» кнопку, яку видно в браузері; чому «not found» на SPA — це майже завжди таймінг або чужий контекст, а не помилка в CSS; і чому елемент, який чудово підсвічується в DevTools, все одно не знаходиться, бо живе в iframe чи Shadow DOM. Скрізь — що дивитися і чому.
Кейс 1. Один URL — дві різні сторінки: вихідний HTML проти DOM
Тест на API-клієнті «не знаходить» таблицю, яку ви щойно бачили на екрані. Перш ніж правити селектор, порівняйте два джерела: тіло HTTP-відповіді й реальний DOM. На SPA це буквально різні документи.
Дивимось, що віддав сервер, без виконання JavaScript:
curl -s https://app.example.com/dashboard | grep -c "<tr"
# 0 — жодного рядка таблиці в тілі відповіді
Тепер те саме, але вже в реальному DOM після рендеру — через справжній браузер:
import { test, expect } from '@playwright/test';
test('таблиця є в DOM, але не у вихідному HTML', async ({ page, request }) => {
// 1) вихідний HTML: те, що бачить curl / HTTP-клієнт
const res = await request.get('https://app.example.com/dashboard');
const rawHtml = await res.text();
expect(rawHtml).not.toContain('<tr'); // порожній #root, дані ще не завантажені
// 2) DOM після виконання JS: те, з чим працює локатор
await page.goto('https://app.example.com/dashboard');
await expect(page.getByRole('row')).not.toHaveCount(0);
});
Що дивитися і чому:
curl/HTTP-клієнт читає лише вихідний HTML. У SPA це порожній<div id="root">плюс<script>; ні таблиці, ні кнопок там немає — їх домалює JS вже в браузері. Тому парсер без виконання JavaScript «не побачить» вміст, і це не баг локатора, а не те джерело.- View Source vs Elements — швидка діагностика. Ctrl+U (macOS — Cmd+Option+U) показує те, що віддав сервер; панель Elements — поточний DOM. Якщо елемент є в Elements, але його немає у View Source, — сторінка динамічна, і для UI-тесту потрібен справжній браузер (Playwright/Selenium/Cypress), а не HTTP-клієнт.
- Якщо потрібні лише дані — не тестуйте через DOM узагалі. Часто дешевше й стабільніше сходити напряму до того API, який наповнює
#root, і не залежати від верстки. UI-браузер лишіть для того, що справді перевіряє інтерфейс.
Кейс 2. «Not found» на SPA: спершу таймінг і локатор наміру, а не правка CSS
Класична пастка: тест «плаває» — сьогодні зелений, завтра червоний без змін у коді. Інстинкт правити селектор здебільшого хибний. Тест і сторінка біжать паралельно, і локатор часто шукає елемент раніше, ніж фреймворк його домалював.
Спочатку розрізніть симптом за таблицею — вона економить години:
| Симптом | Ймовірна причина |
|---|---|
| Падає завжди, стабільно | справді неправильний селектор |
| Падає час від часу, «плаває» | таймінг / гонка з рендером |
| У DevTools вручну знаходиться, а тест — ні | елемент з'являється пізніше, або він в iframe/Shadow DOM |
| Знаходиться, але клік не спрацьовує | елемент є, та ще не інтерактивний (перекритий, disabled) |
Тепер лікування — очікування конкретної умови й локатор, що не залежить від верстки:
// ПОГАНО: sleep гадає тривалість — або флак, або марно повільно;
// плюс селектор чіпляється за автогенерований хеш класу
await sleep(3000);
await page.click('.Button_root__a1b2c > span'); // хвіст __a1b2c зміниться при наступній збірці
// ДОБРЕ: локатор за роллю + іменем, Playwright сам чекає готовності
await page.getByRole('button', { name: 'Зберегти' }).click();
Що дивитися і чому:
- «Плаває» ≠ «неправильний селектор». Стабільне падіння вказує на селектор; недетерміноване — на гонку з рендером. Не міняйте локатор, поки не переконались, що елемент узагалі був у DOM на момент пошуку.
sleep— антипатерн. Він або марнує час, або все одно програє гонку на повільному прогоні. Правильно чекати саме потрібну умову: Playwright перед дією вбудовано чекає, поки елемент прикріплений до DOM, видимий, стабільний і не перекритий, у межах таймауту; у Selenium ту саму роль граєWebDriverWaitз умовоюvisibilityOfElementLocated.getByRole/getByText/getByLabelпереживають рефакторинг верстки. Розробник переверстав контейнери й переназвав класи — роль і напис лишились, а.btn-primary.mt-3 > spanзламався. Ієрархія вибору: роль + доступне ім'я → текст/лейбл →data-testid→ CSS за стабільним атрибутом → XPath.- Автогенеровані класи й
:nth-child— крихкі. За хвіст-хешButton_root__a1b2cчіплятися безглуздо; якщо мусите за клас, беріть[class^="Button_root"]. Позиційний:nth-child(3)з'їде, щойно вставлять рядок вище, — тримайте його як останній засіб.
Кейс 3. Елемент видно в DevTools, а локатор його «не бачить»: iframe і Shadow DOM
Найпоширеніша розгубленість: у панелі Elements елемент є, підсвічується, а тест каже «not found». Причина не в селекторі — ваш пошук іде по DOM головного документа, а елемент живе в іншому дереві: у документі iframe або в тіньовому під-дереві веб-компонента.
Для iframe контекст перемикають явно — це окремий документ зі своїм window і DOM:
// Playwright: frameLocator вбудований у ланцюжок з авточеканням
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Номер картки').fill('4111111111111111');
// Selenium: перемкнутись у фрейм і ОБОВ'ЯЗКОВО повернутись назад
driver.switchTo().frame("payment-iframe");
driver.findElement(By.id("card-number")).sendKeys("4111...");
driver.switchTo().defaultContent(); // інакше подальші пошуки шукатимуть у фреймі
Для Shadow DOM ключова відмінність — чим шукати:
// getByRole/getByText і CSS-локатори Playwright самі пробивають ВІДКРИТИЙ shadow root
await page.getByRole('button', { name: 'Відкрити меню' }).click();
// а XPath межу тіньового дерева НЕ перетинає — цей локатор промахнеться
await page.locator('xpath=//button[contains(., "Відкрити меню")]').click();
Що дивитися і чому:
- «Видно в DevTools, а тест не бачить» = не той контекст. Перш ніж правити селектор, з'ясуйте, чи не в iframe елемент і чи не в Shadow DOM. У Playwright ознака iframe — потрібен
frameLocator; у Selenium —switchTo().frame(...), і не забутиdefaultContent(), бо інакше наступні пошуки залишаться всередині фрейма. - Кросдоменний iframe для драйвера не перешкода. Same-origin policy блокує читання
iframe.contentDocumentкодом сторінки (повернеnull), але драйвери працюють на рівні браузера, поза цим обмеженням, — іframeLocator, іswitchTo().frame()перемикаються в кросдоменний фрейм. - XPath крізь Shadow DOM не працює. За документацією Playwright, XPath не пробиває тіньові корені; CSS- і текстові локатори пробивають лише
openshadow root. Якщо тіньове деревоclosed,shadowRootвіддаєnullі програмного доступу немає за задумом — автоматизація майже безсила. - Не зберігайте посилання на вузол надовго. Після ререндеру (пересортування таблиці, оновлення списку) старий вузол замінюється новим об'єктом — Selenium кине
StaleElementReferenceException. Playwright-локатор — це «рецепт пошуку», а не посилання: він перезнаходить елемент у момент дії, тому staleness там майже не виникає.
DOM vs HTML
- Розумію, що локатор шукає елемент у поточному стані DOM, а не у вихідному HTML, який віддав сервер.
- Можу пояснити різницю між «View Source» (текст відповіді) і панеллю Elements (дерево об'єктів після виконання JS).
- Знаю, чому простий HTTP-клієнт чи
curl«не бачать» вміст SPA, і чому для UI потрібен справжній браузер, а для даних часто дешевше піти в API. - Розумію, чому DOM живий: JS додає, видаляє й переписує вузли у відповідь на дії, відповіді сервера й таймери.
- Можу пояснити родинні стосунки вузлів (parent, children, siblings, descendants, ancestors) і чим
childNodesвідрізняється відchildren.
Селектори: CSS, комбінатори, XPath
- Знаю базові типи CSS-селекторів і атрибутні варіанти зіставлення (
^=,$=,*=,~=) для чіпляння за частково динамічні значення на кшталтButton_root__hash. - Розумію різницю між комбінаторами: пробіл (нащадок) vs
>(пряма дитина),+(наступний сусід) vs~(будь-який сусід після). - Можу пояснити, чому позиційні псевдокласи (
:nth-child) крихкі, а псевдокласи станів (:checked,:disabled) корисні для перевірки стану без читання атрибутів. - Знаю, що
:has()дозволяє відбирати предка за нащадком і що його підтримка з'явилась у браузерах відносно нещодавно. - Можу пояснити, коли справді потрібен XPath: підйом угору по дереву (
ancestor::,..) і пошук за текстом (text(),contains()), чого CSS не вміє. - Знаю пастку XPath:
contains(text(), ...)бере лише перший текстовий вузол, тому для вкладеного тексту використовуютьcontains(., ...).
Стабільні локатори
- Розумію, чому локатори за роллю й видимим текстом (
getByRole,getByText,getByLabel) найстійкіші: не залежать від верстки, ближчі до наміру й побічно перевіряють доступність. - Знаю слабке місце локаторів за текстом — залежність від локалі — і як його обходять.
- Можу пояснити роль
data-testidта ієрархію вибору: роль + ім'я → текст/лейбл →data-testid→ CSS за стабільним атрибутом → XPath.
Події та їхні фази
- Розумію різницю між
inputіchange, і чому виставленняelement.valueнапряму без події не оновлює стан React/Vue, томуfill/typeемулюють справжні події. - Можу пояснити, чому
e.preventDefault()уsubmitскасовує навігацію, і що тоді перевіряти не URL, а тост чи запит у Network. - Знаю три фази події (capturing → target → bubbling), що за замовчуванням обробники на спливанні, і як
stopPropagationламає зовнішні обробники. - Розумію делегування подій і чому обробник може висіти не на елементі кліку, тому перевіряти треба результат, а не наявність
onclick; знаю пастку зe.targetна вкладеній<svg>таclosest().
Контексти й таймінг
- Можу пояснити, чому звичайний
querySelectorне пробиває межу Shadow DOM, різницю режимівopen/closed, і що Playwright пробиває open shadow для CSS/тексту, але XPath — ні. - Знаю, чому елементи в
iframe«не знаходяться» без перемикання контексту (frameLocator,switchTo().frame()), і що в Selenium треба повертатись уdefaultContent. - Розумію stale element: збережене посилання протухає після ререндеру, і чому ліниві локатори Playwright майже знімають цю проблему.
- Розумію, чому «not found» частіше питання таймінгу, ніж селектора, і що лікування — очікування конкретної умови (auto-waiting /
WebDriverWait), а неsleep.
З чим насправді працює твій локатор, коли шукає елемент на сторінці?
Питання
Чим DOM відрізняється від вихідного HTML і з чим із них працює локатор?