DOM, селектори та події
Зміст
Кнопку видно на екрані, у DevTools вона підсвічується — а тест каже «element not found». Далі година йде на правку селектора, і найчастіше даремно: селектор коректний. Розходження в іншому: інженер уявляє сторінку як HTML-файл, а браузер працює з іншим обʼєктом — живим деревом у памʼяті, яке скрипти перебудовують безперервно.
Ця глава — повний виклад теми на сайті: інші глави посилаються сюди по механіку дерева, селекторів і подій. Мета вузька: за хвилину відповісти, з чим саме працює ваш (locator), чому обробник кліку висить не там, де ви думали, і чому елемент, який видно очима, для пошуку не існує.
Що таке DOM
DOM (Document Object Model, обʼєктна модель документа) — програмний інтерфейс до документа: він подає сторінку як дерево вузлів і обʼєктів, щоб код міг змінювати її структуру, стилі та вміст. Ключове слово — «подає»: це не текст і не файл, а структура в памʼяті браузера. Розібравши HTML, браузер будує дерево DOM і вже його використовує, щоб показати сторінку.
Документ подано логічним деревом: кожна гілка завершується вузлом (node), а вузол містить обʼєкти. Вузлом є все, що є всередині документа — елемент лише один із різновидів, поруч живуть текстові вузли й коментарі, а коренем стоїть document. І окремо для тих, хто вивчив DOM «як частину JavaScript»: DOM не є мовою програмування — це вебплатформенний API, спроєктований незалежним від мови. Поведінку дерева й подій нормує DOM Living Standard від WHATWG.
Вузли повʼязані родинними стосунками: батьківський, дочірні, сусідні, нащадки на будь-якій глибині та предки — на них тримаються комбінатори CSS і осі XPath. Наслідок, який регулярно дає несподівані збіги: пробіли розмітки теж стають вузлами, бо браузери вставляють текстові вузли, щоб подати пробіли вихідного коду. Тому node.childNodes[0] може вказувати на пробільний текст, а не на елемент, а element.childNodes (усі вузли, включно з текстом і коментарями) та element.children (лише елементи) дають різне.
Вихідний HTML і DOM — два різні документи
Вихідний HTML (source HTML) — це текст, який сервер віддав у відповідь на запит; саму відповідь розбирає глава «HTTP: методи, структура, заголовки». DOM — це те, що з цього тексту вийшло після парсингу й виконання всього JavaScript. Як обʼєктне представлення сторінки DOM змінюється скриптами вже після завантаження, тож два документи розходяться тим сильніше, чим більше на сторінці клієнтської логіки.
У SPA (single-page application) розходження максимальне за означенням: такий застосунок завантажує лише один вебдокумент, а далі змінює вміст його тіла через JavaScript-API на кшталт fetch(). Тобто у відповіді сервера інтерфейсу може не бути взагалі — лише каркас, а таблиці, кнопки й тексти зʼявляються в дереві після виконання скрипта й запитів до API.
Звідси головна теза глави: локатор шукає елемент не в HTML-коді, а в поточному стані DOM.
Наша практика (не канон). Джерела дають механізм, але не діагностичний прийом — далі те, як це роблять команди, з якими ми працювали. Порівнюйте два вікна: «View Page Source» (те саме видно в тілі відповіді на вкладці Network) проти панелі Elements у DevTools. Перше — текст, який віддав сервер; друге — поточне дерево після виконання скриптів. Елемент є в Elements і немає у View Source — сторінка динамічна, і жоден інструмент без виконання JavaScript (HTTP-клієнт, curl, HTML-парсер) її вмісту не побачить: для UI-тестів потрібен справжній браузер. Типовий каркас SPA у вихідному коді — це порожній <div id="root"> плюс <script>. Дзеркальний висновок економить години: якщо потрібні не кнопки, а дані, дешевше піти напряму до API, який це дерево наповнює.
Дерево живе: динамічність і stale element
JavaScript додає, видаляє, переміщує й переписує вузли у відповідь на дії користувача, відповіді сервера й таймери: модального вікна не було — воно зʼявилося на кліку, спінер зник після відповіді API, тост прожив дві секунди. Дерево не «збирається один раз».
Правильна відповідь на цю рухомість — не пауза на три секунди, а очікування конкретної умови: щоб елемент став видимим, клікабельним або зник. Механіку очікувань розбирає глава «Практичні сценарії AQA: флак і синхронізація», а момент, коли дерево SPA можна вважати готовим, — глава «Асинхронне завантаження: SPA/MPA, CSR/SSR».
Друге, що випливає з рухомості дерева, — модель самого локатора. Локатор Playwright — не посилання на вузол, а «рецепт пошуку», який резолвиться в реальний елемент у момент дії. Це фундаментальна відмінність від моделі Selenium WebElement, і саме через неї класична проблема застарілого посилання там майже не виникає.
// знімок вузла: після ререндеру посилання вказує в порожнечу
const row = await page.$('.row');
await sortTable(); // фреймворк перемалював таблицю
await row.click(); // цього вузла в дереві вже немає
// локатор: опис пошуку, який виконається в момент дії
const rowLocator = page.locator('.row');
await sortTable();
await rowLocator.click(); // пошук відбудеться зараз, у свіжому дереві
Наша практика (не канон). Під наступні два твердження канонічного джерела в реєстрі немає — це наш висновок із того, як улаштоване дерево. Перше: динамічність DOM — головна причина нестабільності UI-тестів, бо тест і сторінка виконуються паралельно; спроба клікнути в ту мілісекунду, коли фреймворк ще не домалював елемент, дає «не знайдено», а через мить елемент уже на місці. Друге: stale element — це збережене посилання на вузол, якого вже немає в дереві або який замінили новим після ререндеру; лікується воно не навмання, а тим, щоб не тримати посилання надовго й шукати елемент безпосередньо перед дією.
Селектор і локатор: одна механіка, різна мета
Селектор — шаблон, за яким добирають елементи; ті самі селектори працюють і в JavaScript, щоб діставати вузли з DOM. Локатор в автотесті будується поверх тієї самої механіки: він описує, як знайти елемент у поточному стані дерева. Тому «CSS проти локатора фреймворку» — не два світи, а надбудова над одним.
Модуль CSS selectors описує понад 60 селекторів і пʼять комбінаторів, а також їхню специфічність. Для локатора специфічність вторинна: тут важить не перемога в каскаді, а унікальність і стабільність збігу — локатор має пережити наступний реліз фронтенду. Добирати елементи можна за типом (тегом), класом, ідентифікатором, атрибутом, універсально, а також за поточним станом і позицією в дереві.
| Комбінатор | Значення |
|---|---|
A B (пробіл) | B десь усередині A, на будь-якій глибині |
A > B | B — безпосередня дитина A |
A + B | B — наступний сусід одразу після A |
A ~ B | B — будь-який подальший сусід A |
Пʼятий, комбінатор стовпця ||, у специфікації описаний, але браузери його не підтримують; кома ж комбінатором не є — це список селекторів. Часта помилка новачка саме тут: сплутати пробіл і >. div span знайде span на будь-якій глибині, div > span — лише прямих дітей; у глибокій верстці це різниця між одним потрібним елементом і десятком зайвих.
Селектори за атрибутами вміють часткове зіставлення: ^= — «починається з», $= — «закінчується на», *= — «містить щонайменше одне входження підрядка». Саме воно рятує, коли фреймворк генерує класи з випадковим хешем на кшталт Button_root__a1b2c: чіплятися за повний клас безглуздо, а [class^="Button_root"] переживе перезбірку. Поруч пастка: ~= до цієї трійки не належить — він шукає рівно таке слово в списку через пробіл, тож [class~="logo"] знайде class="header logo", але не class="logotype".
Псевдокласи добирають елементи за станом, якого немає в дереві документа: наведення, фокус, вимкненість, відвіданість. У тестах це зручно — input:checked знаходить елемент лише коли він відмічений. Позиційні (:nth-child) крихкі: вставили рядок вище — і селектор указує вже на інше. Окремо варто знати реляційний :has(): він добирає предка або попереднього сусіда відносно опорного елемента (tr:has(input:checked) — рядок із відміченим чекбоксом). За статусом Baseline він «Widely available» в усіх браузерах із грудня 2023 року, тож на старіших збірках браузерів підтримку перевіряють окремо.
Коли справді потрібен XPath
Дві речі XPath уміє, а набір комбінаторів CSS — ні. Перша: рух угору по дереву. Вісь parent дає батька контекстного вузла, ancestor — усіх його предків, preceding-sibling веде до попередніх сусідів, а скорочення .. — це рівно parent::node(). Частину випадків тепер закриває :has(), але лише на збірках браузерів, новіших за грудень 2023. Друга — зіставлення за текстом: вузловий тест text() істинний для будь-якого текстового вузла, а рядкове значення елемента є конкатенацією рядкових значень усіх його текстових нащадків у порядку документа. Платня за обидві можливості — багатослівність і вища крихкість виразу.
Класична пастка XPath 1.0 виростає з трьох нешкідливих поодинці правил: text() повертає множину вузлів, а не рядок; аргумент функції, що чекає рядок, конвертується так, ніби викликали функцію string; а конвертація множини вузлів бере значення вузла, першого в порядку документа. Разом це означає, що для <button>Оплата <b>картою</b></button> вираз contains(text(), 'Оплата картою') не спрацює — перевіряється лише перший текстовий вузол. Ліки — крапка замість text(): contains(., 'Оплата картою') бачить напис цілком. Друга пастка того самого класу — індексація: //x[1] означає не «перший x у документі», а «усі x, що є першими дітьми своїх батьків»; справді перший збіг беруть як (//x)[1].
Що робить локатор стабільним
Найстабільніший спосіб знайти елемент — той, яким його знаходить сам користувач і читач екрана: за роллю та видимим текстом. Роль відповідає семантиці елемента в дереві доступності, а доступне імʼя формується з видимого тексту, aria-label чи повʼязаного label. Testing Library формулює принцип прямо: «The more your tests resemble the way your software is used, the more confidence they can give you». Доступність як предмет — глава «Семантичний HTML і доступність»; конкретні API локаторів — розділ про інструменти.
Цінність тут у незалежності від верстки: розробник переверстав й перейменував класи — роль і напис лишились, локатор живий. Побічно такі локатори ловлять і дефекти доступності: якщо кнопка насправді клікабельний <div> без ролі, пошук за роллю її не знайде. Коли ні роль, ні текст не дають надійного якоря (іконка без підпису, напис, що змінюється), беруть data-testid — атрибут даних, який існує заради тестів і робить контракт між тестом і кодом явним.
Наша практика (не канон). Порядок переваги джерела не задають — це наше ранжування: роль плюс доступне імʼя → текст або лейбл → data-testid → CSS за стабільним атрибутом → XPath. Автогенеровані класи й ланцюжки на кшталт .btn-primary.mt-3 > span як основний підхід тримаємо поза списком. Дві ціни варто сказати вголос. Локатори за текстом крихкі до локалі: привʼязка до українського напису зламається на англійській збірці, тому тестують у фіксованій локалі або йдуть через окремий атрибут. А data-testid вимагає командної домовленості й легко зникає при рефакторингу — для розробника, який не пише тестів, цей атрибут «нічого не робить».
Події: як клік знаходить обробника
Взаємодія користувача зі сторінкою — це потік подій: браузер генерує подію, а код реагує на неї через обробники. Подія, надіслана елементу в дереві, може дістатися й обробників на його предках — саме звідси ростуть делегування подій і половина «загадкових» кліків.
Подія проходить дерево трьома фазами: захоплення (capturing) — від кореня до цілі, фаза цілі, спливання (bubbling) — назад угору; поточну фазу видно у властивості eventPhase. Спершу в порядку дерева спрацьовують слухачі з capture: true, потім, якщо подія спливає, — у зворотному порядку решта. За замовчуванням обробники стоять на спливанні; щоб слухати на захопленні, третім аргументом addEventListener передають true.
Три методи пояснюють більшість «чому не спрацювало». stopPropagation() не дає події дістатися інших обʼєктів дерева, але стандартної дії браузера не скасовує — звідси загадки, коли клік по вкладеному елементу не доходить до зовнішнього обробника; stopImmediatePropagation() додатково відсікає решту слухачів на тому самому обʼєкті; preventDefault() скасовує стандартну дію лише для подій із cancelable: true і поширення не спиняє, а успіх видно через defaultPrevented. Практичний наслідок: якщо клік по «Надіслати» не веде до навігації, це може бути не баг, а свідомий preventDefault() в обробнику submit — перевіряти тоді треба не URL, а тост чи запит у Network.
| Подія | Коли виникає |
|---|---|
click | натискання на елементі |
input | будь-яка зміна значення поля |
change | зміна значення (чим відрізняється від input — далі в підрозділі) |
submit | відправлення форми |
focus / blur | отримання / втрата фокуса |
keydown / keyup | натискання / відпускання клавіші |
Специфікацій тут дві, і це не бухгалтерія. focus, blur, focusin, focusout, keydown і keyup нормує W3C UI Events; click, input, change і submit живуть у переліку подій HTML Standard, і в UI Events їх немає взагалі. Різниця не в назві документа, а в інтерфейсі: change іде з інтерфейсом Event і цілями «Form controls», submit — з інтерфейсом SubmitEvent і ціллю «form elements», click — з PointerEvent. Інтерфейс визначає, які поля в події є, тож шукати submit серед подій інтерфейсу користувача — марна витрата часу. Звідти ж і точна межа пари, яку плутають найчастіше: input спрацьовує, «when the user changes the contenteditable element's content, or the form control's value», а change — коли користувач значення зафіксував («commits a value change»). І пояснення, чому Enter на кнопці дає клік: click стандарт описує як подію, що спрацьовує синтетично й тоді, коли елемент активують непозиційним пристроєм — «e.g. a keyboard».
Спливають не всі події однаково, і це визначає, куди можна повісити один обробник на групу полів. blur не спливає — UI Events позначає його «Bubbles: No» і описує як подібний до focusout, але без спливання; focusin і focusout спливають обидва, причому focus нормативно спрацьовує перед focusin, а blur — перед focusout.
Делегування (event delegation) прямо випливає зі спливання: один обробник на спільному предку обслуговує всі дочірні елементи, зокрема ті, що зʼявляться згодом. Важлива деталь про ціль: e.target — найглибший елемент, по якому реально клікнули. Якщо кнопка містить <svg>, ціллю буде саме <svg> — тому в обробнику беруть e.target.closest('.delete-btn').
Наша практика (не канон). Далі — наслідки, під які канонічного джерела в реєстрі немає. Фреймворки зазвичай слухають саме input, тому пряме присвоєння element.value = '...' без генерації події застосунок не помітить: стан у React чи Vue не оновиться. Через це штатні дії тестових фреймворків (fill, type) не пишуть значення напряму, а емулюють справжні події вводу — і саме тому не варто смикати DOM через evaluate. Ще одна деталь до пари input/change, якої стандарт не формулює: для текстового поля «зафіксував значення» на практиці означає втрату фокуса — тоді й приходить change, тоді як input приходить на кожен символ. І два висновки про перевірки: обробник може фізично не висіти на елементі, по якому ви клікаєте, тож питання «чи є onclick на кнопці» безглузде — перевіряють результат кліку; а делегування пояснює, чому щойно доданий динамічний елемент одразу «працює» без перевішування обробників, тож клік по ньому має спрацьовувати, щойно він зʼявився й став видимим.
Межі пошуку: Shadow DOM та iframe
Shadow DOM дає елементу власне приховане під-дерево, ізольоване від основного документа. Термінологія фіксована: shadow host — елемент-носій, — внутрішнє дерево, — його корінь, — межа між shadow DOM і звичайним DOM. Shadow tree ніколи не існує саме по собі — воно прикріплене до іншого дерева через свій host, а створює його виклик attachShadow(). Ізоляція двобічна: CSS сторінки не діє на вузли компонента, а його стилі не протікають назовні.
Головне для тестів: document.querySelectorAll() не бачить вузлів усередині shadow tree — вони фактично приховані від JavaScript сторінки. Селектор, написаний для основного документа, shadow boundary не пробиває: треба дістатись хоста, потім його shadowRoot, і шукати вже там. Режим open віддає корінь через element.shadowRoot, closed повертає null — але це сигнал наміру, а не барʼєр безпеки, і MDN каже прямо: «you should not consider this a strong security mechanism, because there are ways it can be evaded, for example by browser extensions running in the page». Тому висновок «із closed shadow DOM усі інструменти автоматизації безсилі» джерелом не підтверджений: драйвери працюють на рівні браузера — рівно там, звідки режим і обходиться. Практична асиметрія теж задокументована: Playwright автоматично пробиває відкриті shadow tree своїми CSS- і текстовими локаторами, а два винятки названо прямо — XPath межу shadow tree не перетинає, і закриті shadow root не підтримуються.
Межа працює і для подій: коли подія перетинає shadow boundary, спрацьовує ретаргетинг — ціль підміняється на host-елемент, тож зовні видно компонент, а не його внутрішні вузли. Той самий принцип у composedPath(): він повертає шлях події, приховуючи вузли закритих shadow tree.
iframe робить схоже, але радикальніше: він вбудовує окремий самостійний документ зі своїм window і своїм деревом. Це не «ще один шар» сторінки, тому пошук по DOM головного документа елементів усередині фрейма не бачить — контекст спершу перемикають на потрібний фрейм (як саме — канон розділу про інструменти), а в Selenium після роботи з фреймом обовʼязково повертаються в основний контекст.
До межі дерева додається межа безпеки. (same-origin policy) обмежує, як документ або скрипт одного взаємодіє з ресурсом іншого; походження — це трійка «схема / хост / порт» (докладно — глава «HTTPS, TLS і безпека» і глава про CORS). Вбудувати чужу сторінку через iframe можна, а прочитати її вміст — ні: посилання на вікно чужого походження дає дуже обмежений доступ, штатним каналом лишається window.postMessage. Драйвери автоматизації працюють поза цим обмеженням, на рівні браузера, тому в фрейм перемкнутися вміють; деталі залежать від браузера, драйвера та версій. Звідси й типова розгубленість AQA: «у DevTools елемент видно, а локатор не знаходить» — часто не про селектор, а про контекст.
Наша практика (не канон). Дві деталі без канонічного джерела. У власних елементах (web components) тег нестандартний — <my-button> замість <button>, — а роль доводиться виставляти вручну через ARIA, тому орієнтуються на роль і доступне імʼя самого компонента, а домовленість про data-testid економить багато болю. І адреса, за якою шукати iframe: платіжні форми, віджети підтримки, вбудовані відео й рекламні блоки — саме там ламаються «звичайні» локатори.
Типові помилки
- Виглядає як «елемента немає на сторінці», а насправді його немає у вихідному HTML, але він є в DOM: інструмент без виконання JavaScript бачить лише текст відповіді сервера.
- Виглядає як «поганий селектор», а насправді гонка з рендером: стабільне падіння вказує на селектор, недетерміноване — на таймінг.
- Виглядає як «елемент зник посеред тесту», а насправді його замінили новим при ререндері, і збережене посилання вказує в порожнечу — сам локатор коректний.
- Виглядає як «кнопка без
onclick, отже мертва», а насправді обробник висить на предку через делегування — перевіряти треба результат кліку, а не наявність атрибута. - Виглядає як «текст на кнопці є, а XPath не знаходить», а насправді
contains(text(), ...)бере лише перший текстовий вузол; напис цілком бачитьcontains(., ...). - Виглядає як «локатор зламався», а насправді елемент живе в іншому дереві — у shadow tree компонента або в документі iframe.
Підсумок
- DOM — не текст і не файл, а дерево вузлів і обʼєктів у памʼяті браузера; локатор працює з його поточним станом, а не з HTML, який віддав сервер.
- У SPA вихідний HTML може не містити інтерфейсу взагалі — для UI потрібен справжній браузер, а за самими даними дешевше піти в API.
- Дерево змінюється безперервно, тож посилання на вузол швидко протухає: елемент шукають перед дією, а на асинхронність відповідають очікуванням умови, не паузою.
- Селектор і локатор — одна механіка; для локатора важить не специфічність, а унікальність і стійкість збігу до наступного релізу фронтенду.
- Подія доступна обробникам на предках, а Shadow DOM та iframe ставлять межу, крізь яку звичайний пошук не йде: «видно в DevTools, а тест не знаходить» — питання контексту.
Можливі питання
- «Чим DOM відрізняється від HTML-коду сторінки?» Перевіряють базу. Сильна відповідь називає різницю природи (текст відповіді проти дерева обʼєктів у памʼяті) і додає наслідок: локатор шукає в DOM.
- «Чому
curlне бачить вміст сторінки, яку ви щойно дивилися в браузері?» Дивляться, чи розумієте, що інтерфейс SPA домальовує JavaScript на клієнті, а не приходить у тілі відповіді. - «Чим
div spanвідрізняється відdiv > span?» Класика на комбінатори; сильний кандидат додає, чому в глибокій верстці це різниця між одним елементом і десятком зайвих. - «Що таке stale element і як з ним боротися?» Слухають механізм (вузол замінили при ререндері), а не назву винятку, і лікування: не тримати посилання, шукати перед дією.
- «Розкажіть про фази проходження події.» Очікують три фази, дефолт на спливанні й розуміння, що
stopPropagation()іpreventDefault()роблять різні речі. - «Елемент видно в DevTools, а локатор його не знаходить. Ваші дії?» Міні-кейс, у якому цінують порядок: спершу контекст (iframe, Shadow DOM), потім момент часу, і лише тоді сам селектор.
Джерела
- MDN — Introduction to the DOM — DOM як програмний інтерфейс до документа, дерево вузлів і обʼєктів,
documentяк корінь і статус DOM як вебплатформенного API. - WHATWG DOM Standard — нормативна модель дерева й родинні відношення вузлів.
- MDN — Node: childNodes property — пробільні текстові вузли, різниця
childNodesіchildren, живість колекції.
Вихідний HTML і DOM — два різні документи
- MDN — Introduction to the DOM — DOM як обʼєктне представлення сторінки, яке скрипти змінюють після завантаження.
- MDN Glossary — SPA (Single-page application) — SPA завантажує один документ і змінює вміст його тіла через JavaScript-API.
Дерево живе: динамічність і stale element
- MDN — Introduction to the DOM — дерево змінюється скриптами вже після завантаження сторінки.
- Playwright — Auto-waiting (actionability) — очікування конкретного стану елемента замість фіксованої паузи.
- Selenium — Waiting Strategies — очікування умови як штатна стратегія синхронізації.
- Playwright — Locators — локатор як спосіб знайти елемент у будь-який момент, а не збережене посилання на вузол.
Селектор і локатор: одна механіка, різна мета
- MDN — CSS selectors — селектор як шаблон добору, ті самі селектори в JavaScript, базові типи, пʼять комбінаторів, специфічність і псевдокласи.
- MDN — Attribute selectors — часткове зіставлення
^=,$=,*=, окрема семантика~=і прапорціi/s. - MDN — :has() CSS pseudo-class — добір предка за нащадком і статус Baseline із грудня 2023 року.
- Playwright — Locators — локатор як опис пошуку елемента, для якого важать унікальність і стійкість збігу.
- W3C — XML Path Language (XPath) Version 1.0 — осі
parent,ancestor,preceding-siblingі скорочення.., вузловий тестtext(), рядкове значення елемента, конвертація множини вузлів у рядок і застереження про//para[1].
- Playwright — Locators — пошук за роллю й доступним імʼям як рекомендований підхід і незалежність такого локатора від верстки.
- Testing Library — Guiding Principles — принцип «що ближче тест до способу використання, то більше довіри».
- MDN — Use data attributes — атрибути даних як власні дані на елементі.
Події: як клік знаходить обробника
- WHATWG DOM Standard — потік подій і доступність обробників на предках, три фази й
eventPhase, порядок виклику слухачів,stopPropagation(),stopImmediatePropagation(),preventDefault()іdefaultPrevented, делегування таe.targetяк найглибша ціль. - W3C — UI Events — події фокуса й клавіатури: «Bubbles: No» для
blur, спливанняfocusin/focusoutі нормативний порядок між ними. - WHATWG HTML Standard — Index: List of events —
click,input,changeіsubmitіз їхніми інтерфейсами (PointerEvent,Event,SubmitEvent), межаinput↔change(«commits a value change») і синтетичнийclickвід непозиційного пристрою. - MDN — Introduction to events — скасування стандартної дії форми обробником
submit.
Межі пошуку: Shadow DOM та iframe
- MDN — Using shadow DOM — терміни shadow host/tree/root/boundary,
attachShadow(), двобічна ізоляція стилів, невидимість вузлів shadow tree дляquerySelectorAll(), режимиopen/closedі застереження проclosed. - WHATWG DOM Standard — прикріпленість shadow tree до host, режими кореня, ретаргетинг події на межі та
composedPath(). - MDN — Same-origin policy — походження як трійка «схема / хост / порт», обмежений доступ до вікна чужого походження і
window.postMessage. - Playwright — Frames — фрейм як окремий документ, у який контекст пошуку треба перемкнути.
- Selenium — Working with IFrames and frames — повернення в основний контекст після роботи з фреймом.
- Playwright — Locators — симптом «видно вручну, а тест не знаходить» як питання контексту пошуку; CSS- і текстові локатори пробивають відкриті shadow tree, а винятки — XPath і закриті shadow root.
Відео до теми
Чим DOM відрізняється від HTML-коду сторінки?
Це два різні обʼєкти, і плутанина між ними коштує годин дебагу. HTML — текст, який приїхав у тілі відповіді сервера: рядки, що лежать незмінними. DOM — те, у що браузер перетворив цей текст: розібрав розмітку, побудував у власній памʼяті дерево вузлів і обʼєктів, а далі дав скриптам це дерево переписувати. Тобто DOM — не файл і не текст, а програмний інтерфейс до документа, через який код читає й змінює його структуру, вміст і стилі. Для тестувальника звідси випливає одна, але головна річ: шукає в поточному стані дерева, а не в тому, що віддав сервер. Тому фраза «в коді сторінки такого елемента немає» сама по собі не доводить нічого.
З яких вузлів складається дерево і чому element.childNodes та element.children повертають різне?
Вузол (node) — базова одиниця дерева, і елемент лише один із його різновидів: поруч рівноправно живуть текстові вузли та коментарі, а на вершині стоїть document. children віддає тільки елементи, childNodes — усі вузли підряд, разом із текстом і коментарями. Різницю робить відчутною форматування: переноси рядків і відступи в розмітці браузер теж перетворює на текстові вузли, тож у красиво відбитому HTML першим «дитям» цілком може виявитися порожній текст, а не потрібний елемент. Звідси класичне здивування, коли childNodes[0] вказує не туди, а мінімізована збірка того самого коду поводиться інакше. У щоденних локаторах це не спливає, зате миттєво вилазить, щойно хтось лізе в дерево через evaluate і рахує дітей руками. Родинні звʼязки вузлів — предки, нащадки, сусіди — це не абстракція заради теорії: саме на них стоять комбінатори CSS і осі XPath.
Ви відкрили сторінку в браузері, а curl на ту саму адресу повертає майже порожній HTML. Чому?
Бо curl забирає текст відповіді й на цьому зупиняється — виконувати JavaScript він не вміє. У застосунку типу SPA (single-page application) сервер віддає один документ-каркас, а інтерфейс домальовує скрипт: робить запити до API і наповнює тіло документа вже на клієнті. У вихідному коді такої сторінки часто немає нічого, крім порожнього контейнера на кшталт <div id="root"> та підключеного бандла. Наслідок двобічний і дуже практичний. Треба клікати по інтерфейсу — потрібен справжній браузер, бо HTTP-клієнт чи HTML-парсер вміст просто не побачать. Треба самі дані — дешевше не мучити UI, а звернутися до того самого API, який це дерево наповнює. Перевіряється така гіпотеза за два кліки: порівняти «View Page Source» із панеллю Elements у DevTools. Розходження між ними і є доказом того, що сторінка домальовує себе на клієнті.
Чим div span відрізняється від div > span, і які ще комбінатори варто памʼятати?
Пробіл — це комбінатор нащадка: div span збере всі span усередині div на будь-якій глибині. Знак > звужує до прямих дітей — лише той рівень, що безпосередньо під div. У пласкому демо-прикладі різниці не видно, а в реальній верстці з десятком обгорток це різниця між одним потрібним елементом і купою зайвих збігів, через які локатор стає неоднозначним. Решта пари стосується сусідів: A + B бере елемент, який іде одразу після A, а A ~ B — будь-який наступний елемент того самого рівня. Дві деталі для повноти відповіді: кома комбінатором не є — це просто список селекторів, і описаний у специфікації комбінатор стовпця || браузери не підтримують.
Фреймворк генерує класи з випадковим хешем. Як побудувати CSS-локатор, що переживе наступний білд?
Чіплятися за повне імʼя класу тут безглуздо: Button_root__a1b2c зміниться від першої ж перезбірки. Рятує часткове зіставлення в селекторах за атрибутом — ^= («починається з»), $= («закінчується на») і *= («містить підрядок»). Стабільною лишається людиночитана частина імені, тож [class^="Button_root"] переживає зміну хеша. Тут же чатує пастка з сусіднім оператором: ~= до цієї трійки не належить — він вимагає, щоб у списку класів через пробіл стояло рівно таке слово. Тому [class~="logo"] спрацює на class="header logo" і промовчить на class="logotype". І загальне зауваження: якщо в розмітки взагалі немає нічого стабільнішого за згенерований клас, це вже привід домовлятися про data-testid, а не винаходити хитріші селектори.
Чому позиційні селектори вважають крихкими і що змінює :has()?
:nth-child(3) описує не елемент, а його місце в черзі — а черга змінюється від будь-якої правки верстки чи від нового рядка в даних. Тест після цього не падає чесно, а мовчки починає перевіряти сусідній обʼєкт, і це найгірший сценарій із можливих. Псевдокласи стану поводяться інакше й у тестах якраз корисні: input:checked знайде поле лише тоді, коли воно справді відмічене, тобто селектор перевіряє стан замість позиції. Окремо стоїть реляційний :has() — він добирає елемент за тим, що всередині нього: tr:has(input:checked) дає рядок таблиці з відміченим чекбоксом. До його появи такий добір «знизу вгору» вимагав XPath. За статусом Baseline :has() широко доступний у браузерах із грудня 2023 року, тож на старих збірках браузерів підтримку перевіряють окремо.
Що таке stale element і як із ним боротися?
Це збережене посилання на вузол, якого в дереві вже немає: фреймворк перемалював компонент, і на місці старого вузла тепер новий обʼєкт, а змінна в тесті вказує в порожнечу. Симптом упізнаваний — елемент знайшовся, а дія по ньому впала, хоча очима все на місці. Причина не в селекторі: він коректний, застаріло саме посилання. Лікується це не сліпим , а звичкою не тримати посилання довше, ніж потрібно, і шукати елемент безпосередньо перед дією. Найчастіше ловиться в трьох місцях: сортування й фільтрація таблиць, оновлення списку після відповіді API, повторний рендер після зміни стану.
Чим локатор Playwright відрізняється від збереженого посилання на елемент?
Локатор — це опис пошуку, а не сам елемент. Він нічого не тримає в памʼяті й резолвиться в конкретний вузол у момент дії, тобто в найсвіжішому стані дерева. Збережене посилання (класичний WebElement у Selenium або результат page.$()) — навпаки, знімок: він показує на вузол, який існував у момент пошуку. Різниця стає видимою рівно тоді, коли між пошуком і дією сторінка встигла перемалюватися: знімок протухає, локатор просто шукає заново.
const row = await page.$('.row'); // знімок вузла
await sortTable(); // таблицю перемалювали
await row.click(); // посилання вказує на неіснуючий вузол
const rowLocator = page.locator('.row'); // рецепт пошуку
await sortTable();
await rowLocator.click(); // пошук виконається зараз
Саме через цю модель проблема застарілого посилання у Playwright майже не виникає — не тому, що дерево стабільніше, а тому, що посилання ніхто не зберігає.
Коли XPath справді потрібен, а коли це просто громіздкіший CSS?
Виправданих приводів рівно два. Перший — рух угору по дереву: осі parent і ancestor ведуть до предків, preceding-sibling — до попередніх сусідів, а скорочення .. означає рівно перехід до батька. Частину таких випадків тепер закриває :has(), але лише там, де його підтримка гарантована. Другий привід — добір за текстом, якого в чистому CSS немає взагалі. Усе інше — теги, класи, атрибути, вкладеність — CSS робить коротше й читабельніше, тож XPath там лише додає багатослівності й крихкості. Окремо варто памʼятати обмеження інструментів: у Playwright XPath не перетинає межу , тоді як CSS- і текстові локатори відкриті shadow tree пробивають.
Чому contains(text(), 'Оплата картою') не знаходить кнопку з таким написом?
Бо text() повертає не рядок, а множину текстових вузлів, і при конвертації в рядок береться лише перший із них у порядку документа. Для розмітки <button>Оплата <b>картою</b></button> текстових вузлів усередині кнопки два, і перевірка бачить тільки «Оплата ». Лікується заміною text() на крапку: contains(., 'Оплата картою') працює з рядковим значенням самого елемента, а воно складається з усіх текстових нащадків по черзі. Пастка того ж — індексація: //button[1] читається не як «перша кнопка в документі», а як «кожна кнопка, що є першою кнопкою серед дітей свого батька», тож таких збігів може бути десяток. Справді перший збіг дає обгортка в дужки — (//button)[1]. Обидві помилки живучі, бо вираз виглядає правильним і навіть іноді спрацьовує — на розмітці без вкладених тегів.
Який локатор ви вважаєте найстабільнішим і чому?
Той, яким елемент знаходить сам користувач: за роллю і видимим написом. Роль описує, чим елемент є в дереві доступності, а доступне імʼя збирається з видимого тексту, aria-label або повʼязаного label. Стабільність тут не магія, а наслідок незалежності від верстки: контейнери переставили, класи перейменували, а кнопка лишилася кнопкою з тим самим написом — локатор живий. Testing Library формулює це як правило довіри: тест вартий рівно стільки, наскільки він повторює спосіб, у який продуктом користуються насправді. Побічний бонус — такі локатори підсвічують дефекти доступності: якщо «кнопка» насправді клікабельний div, якому роль не виставили, локатор за роллю пройде повз, і це знахідка для баг-репорта, а не перешкода. Ціна теж є: прив'язка до напису ламається на іншій локалі, тому такі тести ганяють у фіксованій мові інтерфейсу.
Коли доречний data-testid і яка в нього ціна?
Тоді, коли надійного людського якоря немає: іконка без підпису, напис, що змінюється від даних, або однакові за роллю й текстом елементи в списку. Атрибут існує саме заради тестів, тому робить контракт між тестом і кодом явним — на відміну від класу, який завтра перейменують заради стилів. У власних елементах (web components) він рятує ще частіше: тег там нестандартний, і роль може бути не виставлена. Ціна теж чесна й називається вголос: атрибут вимагає командної домовленості й дисципліни, бо для розробника, який тестів не пише, він виглядає марним і зникає при першому ж рефакторингу. Тому в порядку переваги він стоїть після ролі й тексту, але перед ланцюжками CSS і XPath.
Розкажіть про фази проходження події.
Фаз три. Захоплення (capturing) — подія йде від кореня документа вниз до цільового елемента. Фаза цілі — вона дісталася елемента, на якому сталася. Спливання (bubbling) — подія повертається тим самим шляхом угору, до предків. Поточну фазу можна прочитати у властивості eventPhase. Порядок виклику слухачів звідси й випливає: спершу спрацьовують ті, що зареєстровані з capture: true, у порядку дерева, а потім — решта, у зворотному порядку. За замовчуванням обробники вішають саме на спливання, а слухач на захопленні реєструється явно — третій аргумент addEventListener треба виставити в true. Практичний висновок для QA: обробник кліку може висіти зовсім не на тому елементі, по якому користувач клікнув.
Чим stopPropagation(), stopImmediatePropagation() і preventDefault() відрізняються?
Перші два зупиняють подорож події, третій — стандартну поведінку браузера, і плутати їх дорого. stopPropagation() не дає події дістатися інших вузлів дерева: обробник на предку просто не спрацює, хоча клік був. stopImmediatePropagation() робить те саме й додатково відсікає решту слухачів на цьому ж елементі — тобто другий обробник, зареєстрований поруч, теж мовчатиме. preventDefault() не спиняє поширення взагалі: він скасовує дію самого браузера, і то лише для подій, які позначені як скасовні (cancelable: true); чи спрацювало — видно у властивості defaultPrevented. Найчастіший практичний випадок: сабміт форми не веде до перезавантаження сторінки не тому, що щось зламалося, а тому що обробник свідомо скасував стандартну відправку. Перевіряти в такому тесті треба не зміну URL, а результат — тост, оновлений список або запит у Network.
Що таке делегування подій і чому e.target буває не тим елементом, якого чекають?
Делегування — це один обробник на спільному предку замість сотні обробників на дітях. Працює воно рівно завдяки спливанню: подія від будь-якої дитини все одно доходить до контейнера, і той розбирається, звідки вона прийшла. Головний бонус — динамічні елементи: рядок, доданий у таблицю через хвилину після завантаження, працює одразу, бо нічого перевішувати не треба. Пастка в тому, що e.target — це найглибший елемент, по якому реально відбувся клік. Якщо кнопка містить іконку, ціллю буде саме svg усередині, а не кнопка, і перевірка «чи це наша кнопка» провалиться. Тому в обробниках і пишуть e.target.closest('.delete-btn') — піднімаються від справжньої цілі до потрібного предка. Для тестувальника з цього випливає інше: питання «чи висить на кнопці onclick» безглузде, перевіряти треба результат кліку.
Чому обробник на групі полів вішають на focusout, а не на blur?
Бо blur не спливає — специфікація UI Events позначає його як подію, що не піднімається деревом. Тобто обробник на контейнері форми її просто не побачить, скільки полів у ньому не було б. Для тієї самої мети існує пара focusin і focusout: вони спливають обидва, тому один слухач на предку ловить втрату фокуса будь-яким полем усередині. Порядок теж нормований: focus спрацьовує перед focusin, а blur — перед focusout. Практичний наслідок для тестів: якщо валідація поля навішана через делегування, вона запуститься на focusout, і в сценарії треба справді забрати фокус із поля, а не просто ввести текст і перейти до .
Чому присвоєння element.value через evaluate не оновлює стан застосунку?
Тому що фреймворки реагують не на значення в DOM, а на подію. React чи Vue слухають input, а пряме присвоєння властивості жодної події не породжує: у полі текст видно, а внутрішній стан застосунку про нього не знає — кнопка лишається заблокованою, валідація не запускається, форма відправляє порожнечу. Саме тому fill і type у тестових фреймворках ідуть довшим шляхом — вони відтворюють справжній ввід разом із подіями, які він породжує. Звідси й практичне правило: не смикати DOM через evaluate там, де є звичайна дія. Поруч живе пара, яку легко переплутати: input приходить на кожен введений символ, а change для текстового поля — лише після втрати фокуса. Тому сценарій «ввів значення і одразу перевіряю підсумок» може падати не через баг, а через те, що поле ще не втратило фокус.
Що таке Shadow DOM і чому звичайний селектор не пробиває його межу?
Це власне приховане під-дерево елемента, ізольоване від основного документа: елемент-носій називають shadow host, внутрішнє дерево — shadow tree, його корінь — , а межу між ними — . Створюється воно викликом attachShadow(), а ізоляція діє в обидва боки: стилі сторінки не дістають вузлів компонента, а його власні стилі не протікають назовні. Для тестів головне інше: пошук по головному документу вузлів за цією межею не знаходить, тому шлях лежить через хост — узяти в нього shadow root і шукати вже всередині. Відкритий режим цей корінь віддає у властивості shadowRoot, закритий на її місці лишає null, але це заявлений намір, а не бар'єр безпеки: MDN прямо застерігає не вважати closed сильним захистом, бо обійти його можна. Тому теза «із закритим Shadow DOM автоматизація безсила» — перебільшення: драйвери працюють на рівні браузера. Конкретика по інструменту важливіша за загальні слова: Playwright відкриті shadow tree пробиває — і CSS-, і текстовими локаторами, — але два винятки задокументовані окремо: XPath цю межу не перетинає, а закриті корені не підтримуються. Події підпорядковані тій самій межі: на виході з shadow tree ціль підміняється на сам host, тож зовнішній код бачить компонент цілим, а не його внутрішні вузли — це ретаргетинг, і той самий принцип ховає вміст закритих дерев у composedPath().
Чому елемент усередині iframe не знаходиться пошуком по головному документу?
Тому що iframe — не шар сторінки, а самостійний документ, у якого власне вікно (window) і власне дерево. Пошук по DOM батьківської сторінки в це дерево не заглядає взагалі: контекст спершу перемикають на потрібний фрейм, а в Selenium після роботи з ним обовʼязково повертаються назад, інакше наступні кроки шукатимуть не там. Поверх межі дерева стоїть ще одна — безпекова. (same-origin policy) визначає, наскільки документ або скрипт може дістатися ресурсу з чужим походженням, а саме походження складають три речі: схема, хост і порт. Вбудувати чужу сторінку можна, а прочитати її вміст зі скрипта — ні; штатний канал спілкування — window.postMessage. Драйвери автоматизації живуть на рівні браузера й тому в фрейм зайти вміють, хоча деталі залежать від браузера й версій. Практична підказка, куди дивитися першою чергою: оплата картою, чат підтримки, вбудоване відео й рекламні блоки — це майже завжди фрейми.
Локатор не знаходить елемент, який ви бачите в DevTools. З чого почнете розбір?
Порядок перевірки важливіший за самі перевірки, бо селектор — найспокусливіша й найчастіше хибна гіпотеза. Спершу контекст: чи не лежить елемент в iframe або в shadow tree компонента — тоді проблема не в тому, як шукаємо, а де шукаємо. Далі момент часу: чи не гонка це з рендером — ознака проста, стабільне падіння вказує на локатор, плаваюче на таймінг. Потім стан елемента: він може бути в дереві, але перекритий оверлеєм, поза видимою областю або в неактивній вкладці інтерфейсу. І лише в кінці — сам селектор, який до цього моменту вже майже виправданий. Окремо варто перевірити унікальність: якщо збігів кілька, деякі інструменти впадуть не з «не знайдено», а зі строгої перевірки на єдиність.
Три кейси навколо одного болю: тест не знаходить те, що людина бачить очима. Перший — про порядок діагностики, другий — про переписування , який ламається щорелізу, третій — про клік, який відбувся, а нічого не змінив.
Кейс 1. «У DevTools елемент є, а тест каже not found»
Найдорожча помилка тут — почати з селектора. Він найпомітніший, тому підозра падає на нього першою, хоча перевіряти його варто в останню чергу. Дешевший порядок — від контексту пошуку до самого виразу: кожен наступний крок робиться, лише якщо попередній не дав відповіді.
| Симптом | Підозрюваний | Дешева перевірка |
|---|---|---|
| елемент є в панелі Elements, у «View Page Source» його немає | сторінка динамічна, інструмент без JS її не бачить | відкрити тіло відповіді на вкладці Network: там сам каркас і бандл |
у дереві над елементом видно вузол iframe | пошук іде не в тому документі | перемкнути контекст на фрейм і повторити пошук уже в ньому |
у дереві видно позначку #shadow-root | елемент за shadow boundary | перевірити режим (open чи closed) і замінити XPath на CSS або текстовий локатор |
| падає то через раз, то через десять прогонів | гонка з рендером | очікування конкретної умови замість пошуку одразу після goto |
| падає стабільно й однаково | справді локатор або стан елемента | звірити збіг у консолі браузера на живій сторінці |
| знайшовся, але дія не проходить | елемент перекритий, поза екраном або вимкнений | подивитися, що лежить зверху за координатами кліку |
| помилка не «не знайдено», а про кілька збігів | локатор неунікальний | звузити контекстом-предком, не позицією |
Три висновки, які економлять найбільше часу.
- Відсутність у вихідному коді — не діагноз, а очікувана поведінка. Порожній плюс підключений скрипт у тілі відповіді означає, що інтерфейс збирається на клієнті. Це закриває питання «чому мій HTTP-клієнт нічого не бачить» і одночасно підказує, що дані є окремим запитом до API.
iframeі#shadow-rootу дереві — це не деталь верстки, а межа пошуку. Локатор до них коректний і все одно не спрацює: він виконується в іншому дереві. Обидві позначки видно в панелі Elements неозброєним оком, тож перевірка коштує секунди.- Характер падіння вказує на клас причини. Детерміноване падіння — це про сам вираз або про стан елемента, недетерміноване — майже завжди про час. Плутанина тут веде до нескінченного «покращення» робочого селектора.
Кейс 2. Локатор, який ламається щорелізу
Дано: рядок таблиці, у якому треба клікнути «Видалити». Фронтенд збирає класи з хешем, а всередині кнопки лежить іконка. Ланцюжок з автогенерованих класів ламається на першій же перезбірці.
| Було | Проблема | Стало |
|---|---|---|
.MuiTableRow-root:nth-child(3) .btn-a1b2c | і позиція, і хеш класу змінюються без попередження | пошук рядка за вмістом, кнопки — за роллю й написом |
//div[@class='row'][1]//button | [1] тут не «перший рядок у документі» | (//div[@class='row'])[1]//button, а краще взагалі не XPath |
//button[contains(text(), 'Видалити файл')] | усередині кнопки вкладений тег, тож перевіряється лише перший текстовий вузол | //button[contains(., 'Видалити файл')] |
[data-testid="row-delete"] без домовленості з командою | атрибут зникне при першому рефакторингу | той самий атрибут, але зафіксований у code review як контракт |
Робочий варіант читається майже як опис дії користувача: знайти рядок із потрібним файлом, у ньому натиснути кнопку з написом «Видалити».
import { test, expect } from '@playwright/test';
test('рядок зникає після видалення', async ({ page }) => {
await page.goto('/files');
// контекст задає рядок, а не його позиція в таблиці
const row = page.getByRole('row').filter({ hasText: 'report-2026.pdf' });
await row.getByRole('button', { name: 'Видалити' }).click();
await expect(row).toBeHidden();
});
Що тут працює і чому:
- Звуження контекстом замість індексу.
nth-child(3)описує чергу, а не обʼєкт: додали рядок вище — і тест мовчки почав видаляти чужий файл. Це гірше за падіння, бо виглядає як зелений прогін. - Роль плюс видимий напис не залежать від верстки. Розробник може поміняти обгортки, класи й порядок вузлів — доки кнопка лишається кнопкою з тим самим написом, локатор живий. Побічно він же ловить дефект доступності: якщо це не
button, а клікабельнийdivбез ролі, пошук за роллю провалиться, і це знахідка для баг-репорта. - Локатор нічого не тримає в памʼяті.
row— опис пошуку, тому після перемальовування таблиці він шукає заново, а не вказує на видалений вузол. Ось той самий сценарій зі знімком, який гарантовано зламається:
const rowHandle = await page.$('.row'); // знімок вузла
await page.getByRole('button', { name: 'Оновити' }).click();
await rowHandle.click(); // вузол уже замінили при ререндері
data-testidберуть, коли людських якорів немає. Іконка без підпису, напис із динамічних даних, десяток однакових рядків. Тоді атрибут — це явна домовленість між тестом і кодом, і про неї треба домовитися вголос, інакше він зникне як «нічого не робить».
Кейс 3. Клік проходить, а нічого не відбувається
Тест натискає «Зберегти», крок зелений, на результат падає. Клік у такому сценарії справді відбувся — питання в тому, що з ним сталося далі. Чотири типові розвʼязки, і жоден із них не лікується правкою селектора.
| Що бачимо | Механізм | Що робити в тесті |
|---|---|---|
| клік по кнопці з іконкою «не помічають» | ціллю став вкладений svg, а обробник перевіряє клас кнопки | це дефект обробника: у ньому має бути closest(), з боку тесту тут нічого не виправиш |
| форма не перезавантажує сторінку | обробник submit скасував стандартну дію | асертити не URL, а тост, оновлений список або запит у Network |
| натиснули дитину, а зовнішній обробник мовчить | подія не піднялася деревом: десь по дорозі викликали stopPropagation() | перевіряти той результат, який реально настає, а не очікуваний ланцюжок |
| поле заповнене, а кнопка лишилася заблокованою | значення записали напряму, без події input | замінити присвоєння на штатний fill |
Ось та сама помилка з полем у коді — вона виглядає робочою й мовчки не працює:
// НЕ спрацює: значення в DOM зʼявиться, а стан застосунку про нього не дізнається
await page.evaluate(() => {
const input = document.querySelector('#email') as HTMLInputElement;
input.value = 'qa@example.com';
});
// штатна дія емулює справжні події вводу — фреймворк їх чує
await page.getByLabel('Email').fill('qa@example.com');
Друга пастка того ж роду — валідація, навішана на втрату фокуса:
await page.getByLabel('Email').fill('не-пошта');
// change для текстового поля приходить після blur, тому фокус треба справді забрати
await page.getByLabel('Пароль').click();
await expect(page.getByText('Некоректна адреса')).toBeVisible();
Що дивитися і чому:
- Питання «чи висить на кнопці обробник» безглузде. Через делегування він може стояти на контейнері, на предку через два рівні або взагалі на
document. Перевіряють не наявність обробника, а наслідок кліку. - Делегування пояснює й зворотне. Щойно доданий у список елемент працює одразу, без жодного перевішування обробників, — тобто клікати можна відразу, як він опинився в дереві й став видимим. Якщо не спрацьовує, підозра йде на видимість і перекриття, а не на «обробник ще не встиг навішатися».
preventDefault()— часто не баг, а задум. Форма, яка не перезавантажує сторінку, у SPA є нормою; помилковим тут буває саме очікування тесту.- Ввід перевіряють через ту саму дію, що й користувач. Пряме присвоєння
valueекономить мілісекунди й ламає весь сенс перевірки: тест починає тестувати DOM, а не застосунок.
Дерево замість тексту
- Можу пояснити різницю HTML vs DOM однією фразою: перше — текст відповіді сервера, друге — дерево обʼєктів у памʼяті браузера, з яким і працює .
- Знаю різницю
childNodesvschildrenі причину, чому в дереві зʼявляються порожні текстові вузли з відступів розмітки. - Умію за два кліки відрізнити статичну сторінку від динамічної («View Page Source» проти панелі Elements) і пояснити, чому в SPA за самими даними дешевше піти в API, ніж клікати UI.
Рухоме дерево і stale element
- Знаю різницю локатор vs збережене посилання на вузол: перше резолвиться в момент дії, друге є знімком, який протухає після ререндеру, — звідси stale element і його лікування.
- Можу пояснити, чому відповідь на рухомість дерева — очікування конкретної умови, а не фіксована пауза, і розрізняю симптоми: стабільне падіння вказує на локатор, плаваюче — на гонку з рендером.
Селектори
- Знаю різницю
div spanvsdiv > span, можу назвати решту комбінаторів (+,~) і памʼятаю, що кома комбінатором не є. - Знаю різницю
^=/$=/*=vs~=і вмію побудувати локатор до класу з випадковим хешем через часткове зіставлення. - Можу пояснити, чому
:nth-childкрихкий, аinput:checkedіtr:has(input:checked)— ні, і памʼятаю статус:has()(Baseline із грудня 2023).
XPath
- Можу назвати дві речі, яких CSS не вміє: рух угору по дереву (
parent,ancestor,preceding-sibling,..) і добір за текстом. - Знаю дві пастки XPath 1.0:
contains(text(), ...)бачить лише перший текстовий вузол протиcontains(., ...), а//x[1]означає «першийxсеред дітей кожного батька», не перший збіг у документі — справді перший дає(//x)[1].
Стабільні локатори
- Можу назвати порядок переваги: роль і доступне імʼя → видимий текст чи лейбл →
data-testid→ CSS за стабільним атрибутом → аж потім XPath. - Розумію, звідки береться доступне імʼя (видимий текст,
aria-label, повʼязанийlabel) і чому пошук за роллю побічно ловить дефекти доступності. - Знаю ціну двох підходів: локатор за текстом ламається на іншій локалі, а
data-testidтримається лише на командній домовленості.
Події
- Можу назвати три фази (захоплення, ціль, спливання), дефолт на спливанні й різницю
stopPropagation()vsstopImmediatePropagation()vspreventDefault(). - Можу пояснити делегування, чому
e.targetбуває вкладеною іконкою, а не кнопкою, і чому питання «чи єonclickна кнопці» безглузде. - Знаю різницю
blurvsfocusout(перша не спливає) таinputvschange, а звідси — чому пряме присвоєнняvalueне оновлює стан React або Vue.
Межі пошуку
- Знаю термінологію Shadow DOM (host, tree, root, boundary), різницю режимів
openvsclosedі застереження MDN, щоclosed— заявлений намір, а не механізм безпеки. - Памʼятаю обмеження інструмента: CSS- і текстові локатори Playwright пробивають відкриті , XPath межу не перетинає, закриті корені не підтримуються.
- Знаю різницю iframe vs звичайний (окремий документ зі своїм
window), потребу перемикати контекст і те, що обмежує . - Перевіряю симптом «видно в DevTools, а тест не знаходить» у правильному порядку: контекст → момент часу → стан елемента → сам селектор.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Чим вихідний HTML відрізняється від DOM за природою?




