vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    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, виконує app.jsНаповнюєGET / (запит сторінки)HTML з порожнімЗапит до API за данимиДаніСерверБраузерСерверБраузерПарсить HTML, виконує app.jsНаповнюєGET / (запит сторінки)HTML з порожнімЗапит до API за данимиДані
    ХарактеристикаВихідний HTMLDOM
    Що цеТекст відповіді сервераДерево об’єктів у пам’яті
    Де подивитисьView Source, Network → ResponseDevTools → 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 > BB — безпосередня дитина A
    Сусідній наступний (adjacent)A + BB — рівно наступний сусід одразу після A
    Загальний сусідній (general)A ~ BB — будь-який сусід після 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"]
    
    МожливістьCSSXPath
    За класом/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 — те, чого варто уникати як основного підходу.

    Роль + доступне ім'я

    Текст / лейбл

    data-testid

    CSS за стабільним атрибутом

    XPath

    Роль + доступне ім'я

    Текст / лейбл

    data-testid

    CSS за стабільним атрибутом

    XPath

    Події: 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 у три фази, і розуміння цього прямо впливає на те, як писати обробники й чому іноді «клік спрацьовує двічі».

    1. Захоплення (capturing) — подія йде згори вниз: від кореня документа до цільового елемента.
    2. Ціль (target) — досягає елемента, на якому виникла.
    3. Спливання (bubbling) — піднімається назад угору, від цілі до кореня.

    1. захоплення ↓

    3. спливання ↑

    document

    контейнер

    картка

    кнопка — ціль

    картка

    контейнер

    document

    1. захоплення ↓

    3. спливання ↑

    document

    контейнер

    картка

    кнопка — ціль

    картка

    контейнер

    document

    За замовчуванням обробники спрацьовують на фазі спливання. Це означає, що клік по кнопці всередині картки «сплине» і на картку, і на її контейнер, і вище. Щоб слухати на фазі захоплення, третій аргумент 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)

    Ні, стабільно

    Ні, плаває

    Так, видно в DevTools

    iframe / shadow DOM

    Ні, але клік не йде

    not found

    Елемент є в DOM
    на момент пошуку?

    Неправильний селектор

    Таймінг: чекати умову

    Інший контекст?

    Перемкнути контекст

    Ще не інтерактивний:
    перекритий / disabled

    Ні, стабільно

    Ні, плаває

    Так, видно в DevTools

    iframe / shadow DOM

    Ні, але клік не йде

    not found

    Елемент є в DOM
    на момент пошуку?

    Неправильний селектор

    Таймінг: чекати умову

    Інший контекст?

    Перемкнути контекст

    Ще не інтерактивний:
    перекритий / 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» — це не «я неправильно описав елемент», а «я подивився не туди або надто рано».