Асинхронне завантаження: SPA/MPA, CSR/SSR
Зміст
Найдорожча година в житті AQA витрачається так: тест падає з «element not found», ти відкриваєш — і елемент там є. Або інакше: тест клікає по кнопці, кнопку видно, клік проходить, а нічого не відбувається. Спокуса одна — правити (locator) і додавати паузи. Причина майже завжди інша: сторінка збирається шматками, і тест зазирнув у неї між двома шматками.
Ця глава — канон теми на сайті: решта глав посилаються сюди, а тут зібрано повний виклад того, звідки береться розрив між «браузер показав» і «застосунок готовий». І ще одна межа: тут причина, а не рецепт. Практичні прийоми боротьби з (flakiness) живуть у главі про практичні сценарії AQA, а внутрішня механіка браузера — у главі про архітектуру браузера й рендеринг.
Запит без перезавантаження: AJAX і fetch
AJAX (asynchronous JavaScript and XML) — обмін даними із сервером у фоні, без повного перезавантаження сторінки: оновлюється лише потрібна частина DOM (Document Object Model), і візуально сторінка не «моргає». Технічно це або старий XMLHttpRequest (XHR), або сучасний Fetch API — браузерний інтерфейс для надсилання HTTP-запитів із JavaScript, побудований на (Promise), а не , і інтегрований із service workers та CORS.
Головний наслідок для тесту — один рядок: «сторінка відкрилась» ще не означає «дані на місці»; у момент, коли автотест бачить сторінку, потрібного контенту в DOM може не бути зовсім.
Три деталі fetch ловлять у пастку і розробників, і тестувальників. Перша й головна: відповідь 404 чи 500 дає успішний проміс — якщо сервер відповів помилкою, fetch() «fulfills with a Response», тож статус перевіряють вручну, а response.ok істинний лише для діапазону 2xx (про самі коди — у главі HTTP статус-коди). Друга: перелік причин, коли проміс усе-таки відхиляється, у джерелі навмисно відкритий — «some errors, such as a network error or a bad scheme». Закривати його не можна: памʼятати треба межу «HTTP-відповідь є → успіх; відповіді не було → reject», а не завчений список. Третя: тіла запиту й відповіді — потоки (ReadableStream), тож одне й те саме тіло не можна прочитати двічі.
У XHR замість промісу — пʼять станів readyState: 0 UNSENT, 1 OPENED, 2 HEADERS_RECEIVED, 3 LOADING, 4 DONE. Важать не назви, а три деталі: HEADERS_RECEIVED настає лише після того, як пройдено всі редиректи; у LOADING тіло ще тече, і responseText тримає часткову відповідь (для текстового responseType); а DONE означає «операція завершилась» — успішно або з помилкою, тож readyState === 4 сам по собі про успіх не свідчить. Скасування є в обох: xhr.abort() і AbortController із signal.
Обидва механізми йдуть через мережевий стек браузера, тому і панель Network, і перехоплення в тесті ловлять їх однаково — незалежно від того, чим написаний фронтенд. Звідси перша заміна сліпій паузі: чекати на конкретну відповідь за URL і статусом або підмінити її (див. главу Перехоплення й мокання мережі).
Наша практика (не канон). Термін AJAX популяризував Джессі Джеймс Ґарретт у лютому 2005 року, а технологія зʼявилася раніше — на базі XMLHttpRequest, який спершу реалізували в Microsoft для Outlook Web Access. Сюди ж деталь, про яку часто забувають: вбудованого у fetch немає — його роблять тим самим AbortController. Канонічного джерела ні під історію, ні під це твердження в реєстрі немає.
SPA і MPA: що переміщується, а що ні
SPA (single-page application) — це застосунок, який завантажує один вебдокумент, а далі оновлює вміст його body через JavaScript, коли треба показати інший контент. Переходи «між сторінками» документа не перезавантажують: змінюється лише URL і вміст.
Технічна основа переходу — History API: history.pushState() і history.replaceState() міняють адресу в рядку браузера без запиту на сервер. Тут є деталь, на якій ламаються тести навігації: подія popstate реагує лише на переміщення по історії (кнопки «назад»/«вперед», history.back()), а самі виклики pushState/replaceState її не генерують.
Практична різниця для тесту не в архітектурі, а в наявності моменту «сторінка завантажилась». Подія load спрацьовує, коли завантажено сам документ і всі його підресурси — у багатосторінковому застосунку (MPA, multi-page application) це відбувається на кожному переході, і дочекатися навігації просто. У SPA документ завантажується раз, тож при переході старі сигнали не спрацьовують, і чекати доводиться на появу контенту.
Друга різниця бʼє по ізоляції: документ живе весь сценарій, отже, стан у памʼяті нікуди не дівається між кроками — звідси жорсткіші вимоги: чиста сесія, скинуте сховище (Web Storage), свіжий між тестами. Третя — ціна: обсяг JavaScript росте разом із застосунком і псує чутливість сторінки до взаємодій.
Наша практика (не канон). Далі — те, як це формулюють команди, з якими ми працювали; специфікації під ці рядки немає. MPA означує «кожен екран — окремий HTML-документ», і стан там скидається при переході, тоді як у SPA він накопичується. Клієнтський роутер перехоплює клік по посиланню, скасовує стандартну навігацію і сам міняє адресу та вміст; історичний варіант того самого — hash-роутинг (зміна частини URL після #). Звідси головна пастка: URL змінюється раніше, ніж приїжджають дані — адреса вже /profile, а на місці контенту ще скелетон (skeleton), тому перевіряти треба дві речі одночасно: URL і появу змістовного елемента. Сюди ж рівні (lazy loading) — loading="lazy" для зображень та iframe, динамічний import() для коду (code splitting), рендер компонентів через IntersectionObserver — і віртуалізація списків: нескінченна прокрутка, яка віртуалізує рядки, тримає в DOM лише видиме вікно, тож перевірка «усі 200 елементів присутні» неможлива в принципі. Рівні варто розрізняти: при loading="lazy" сам тег у DOM є, відкладено лише завантаження ресурсу, а от ліниво відрендерений компонент чи рядок віртуалізованого списку фізично відсутній у DOM, поки до нього не догорнули, — тож «element not found» тут не про поганий локатор, елемент треба спершу привести у видиму область.
CSR, SSR і гідратація: розмітка є, а обробники ще ні
Стратегія рендерингу відповідає на одне питання: хто будує HTML — сервер чи браузер.
CSR (client-side rendering) — рендеринг застосунку в браузері: розмітку будує JavaScript, змінюючи DOM. При чистому CSR у перші миті після відкриття сторінки в DOM немає нічого корисного, і «сирий» HTTP-запит без виконання JavaScript побачить порожнечу.
SSR (server-side rendering) — рендеринг на сервері: клієнт отримує HTML, а не JavaScript, який його побудує. Сервер генерує повний HTML у відповідь на навігацію, тож клієнту не потрібні додаткові звернення за даними, і перший показ зазвичай швидший — до клієнта їде менше JavaScript. Окремий варіант — статичний рендеринг: HTML будується під час збірки.
Гідратація (hydration) — запуск клієнтських скриптів, які додають стан і інтерактивність до вже відрендереного сервером HTML (вона припускає, що DOM при цьому не змінюється). Саме тут живе найпідступніший для тестів стан, і джерело каже про нього прямо: сторінка виглядає інтерактивною, але не реагує на введення, доки не виконаються клієнтські скрипти й не навісяться обробники подій. Ціна теж названа: SSR із регідратацією покращує перший показ, але може суттєво погіршити відгук на взаємодії.
Звідси відповідь на питання «чи є універсальний сигнал, що сторінка готова». Немає. DOMContentLoaded настає, коли розібрано HTML, — у CSR у цей момент даних ще немає. load чекає на підресурси, але AJAX-запити за даними йдуть після нього, а при клієнтській навігації він не повторюється. document.readyState === "complete" несе ті самі обмеження, що й load. Готовність — поняття конкретного застосунку, а не властивість браузера.
Наша практика (не канон). Стандартного DOM-атрибута «гідратацію завершено» не існує: надійний сигнал залежить від фреймворка і його версії, тож маркер готовності доводиться зʼясовувати або домовляти для свого стека. І саме тому падіння на гідратації плаваюче: на швидкій машині гідратація встигає до кліку, на завантаженому CI — ні. З того самого досвіду й перелік причин, чому оманливе «мережа затихла»: (polling), вебсокет і аналітика не дають їй затихнути ніколи — джерело таких причин не називає, воно лише позначає це очікування як небажане.
Конвеєр рендерингу: коли елемент справді готовий
«Є на сторінці» — не одна подія, а кілька різних. Конвеєр рендерингу (rendering pipeline) перетворює HTML, CSS і JavaScript на пікселі фіксованою послідовністю етапів: DOM і CSSOM → дерево рендерингу → layout (геометрія) → paint (пікселі) → composite (складання шарів). Повний перелік етапів конвеєра починається ще раніше — з JavaScript, який ініціює візуальну зміну, і обчислення стилів. Проганяє всі етапи не кожна зміна: зміна «тільки-paint»-властивості пропускає layout, а зміна, якій не потрібні ні layout, ні paint, іде одразу в композитинг.
Найкорисніша для AQA деталь — асиметрія видимості. Елемент із display: none у дереві рендерингу відсутній: він є в DOM, але геометрії не має. Елемент із visibility: hidden у дереві присутній — займає місце, просто не видно. Тому наявність вузла в DOM — найраніша й найслабша гарантія: перевірка на кшталт «елемент прикріплений до дерева» спрацює набагато раніше, ніж елемент реально стане клікабельним.
Ще одна причина «елемент є, а тест мигає» — головний потік. Будь-яка зміна дерева рендерингу запускає layout повторно, а важкий синхронний перерахунок займає потік: елементи вже намальовані, але на кліки не реагують. Анімований елемент — окремий випадок тієї самої природи: інструмент вимагає стабільності перед дією. І оскільки результат layout залежить від розміру екрана, той самий локатор на «десктопній» і «мобільній» може вести до різних елементів.
DOM як результат, а не джерело
DOM — програмний інтерфейс до документа: сторінка подана деревом вузлів і обʼєктів, щоб код міг змінювати її структуру, стилі та вміст. Браузер будує це дерево, розібравши HTML, і далі показує сторінку вже з нього; поведінку дерева й подій у ньому нормує DOM Living Standard.
Ключове слово — «далі». Як обʼєктне представлення сторінки DOM змінюється скриптами вже після завантаження: JavaScript додає, видаляє й переписує вузли у відповідь на дії користувача, відповіді сервера й таймери. Навіть колекції дітей вузла живі — їхній вміст змінюється щоразу, коли дітей додають чи прибирають.
Звідси головна плутанина теми. Вихідний HTML — це текст відповіді сервера; DOM — те, що лишилося після парсингу й виконання всього JavaScript. Інструменти, які читають лише перше (простий HTTP-клієнт, curl, парсер без виконання JS), вмісту SPA не побачать: для UI-перевірок потрібен справжній браузер, а якщо потрібні лише дані — часто дешевше піти напряму в API.
Для локаторів це означає дві речі. Перша: локатор шукає елемент не у вихідному HTML, а в поточному стані дерева, тож він має бути рецептом пошуку, який розвʼязується в момент дії, а не збереженим наперед посиланням на вузол — саме через збережені посилання виникає класична проблема «елемент застарів» після ререндеру. Друга: перш ніж правити селектор на «not found», зʼясуй, чи елемент узагалі є в DOM на момент пошуку і чи не живе він в іншому контексті — в iframe або Shadow DOM. Правильна відповідь на динамічність — не sleep(3), а очікування конкретної умови.
У Selenium ця помилка має власне імʼя — StaleElementReferenceException, і знати його треба, щоб упізнати її в лозі. Дока формулює причину одним реченням: «Elements do not get relocated automatically» — драйвер видає елементу ідентифікатор посилання й очікує знайти його в конкретному місці DOM. Тому виняток означає не «елемента немає на сторінці», а «моє посилання на нього більше не дійсне», і лікування залежить від причини. Змінився DOM — елемент треба перезнайти. Змінився контекст (перемкнулися у фрейм або в інше вікно) — посилання «will still be valid, but will be temporarily inaccessible», і тут «it won't help to relocate the element»: треба повернутися в потрібний контекст. Після навігації контекст знищено, тож потрібні обидві дії. І окремо про звичний рефлекс: обгортку з кешем і повтором по винятку дока подає не як рішення, а як — вона «can cause problems if the locator you're using references a different element (and not the one you want) after the page has changed»; перезнаходити щоразу надійніше, але «not the most efficient approach, especially when running on a remote grid».
Подієвий цикл: чому перевірка ловить проміжний стан
(event loop) — механізм, яким браузер узгоджує події, дії користувача, скрипти, рендеринг і мережу. Канон теми — глава Виконання JavaScript та event loop; тут потрібні лише ті чотири наслідки, з яких народжується проміжний стан.
Перший: кожна задача обробляється повністю, перш ніж почнеться будь-яка інша, а колбек асинхронної дії не виконується миттєво — він стає задачею в черзі. Тому асинхронний колбек ніколи не перерве синхронний код, що вже виконується.
Другий: черга мікрозадач — окрема сутність, а не одна з черг задач, і після кожної задачі вона спорожнюється повністю. Реакції промісів — мікрозадачі; setTimeout, події DOM і події XHR — задачі. Завершення мережевого запиту браузер оформлює як задачу, яка резолвить проміс, але .then на ньому виконається вже мікрозадачею — на відміну від старого XHR.onload, який лишається звичайною задачею.
Третій: рендеринг відбувається не після кожної задачі, а в «нагоду для рендерингу». середовище не витісняє, тож вона затримує і обробку подій, і кадр рендерингу — сторінка на екрані є, а на кліки не реагує.
Четвертий: таймери нічого не гарантують — специфікація каже це прямо: «This API does not guarantee that timers will run exactly on schedule». Тому setTimeout(fn, 0) означає «якнайшвидше після того, як звільниться потік», а задана — мінімум, а не обіцянка. Практичний висновок: «елемент є в DOM, але тест падає» часто означає, що вміст оновлює мікрозадача чи мережевий колбек, який ще не відпрацював.
Гонка станів у завантаженні даних
Гонка (race condition) — це залежність результату від того, у якому порядку завершилися асинхронні операції, за відсутності гарантій щодо цього порядку. Оскільки кожна задача виконується повністю, гонка виникає не всередині однієї задачі, а між задачами: два запити за даними стартували, а хто відповість першим — питання мережі, а не коду.
Виправлення на боці застосунку відоме: відстежувати актуальність запиту — скасовувати попередній через AbortController або ігнорувати відповіді, чий запит уже застарів.
Виправлення на боці тесту важливіше, бо тест, який не синхронізується зі станом, сам створює гонку — дока Selenium прямо називає таку гонку між браузером і кодом тесту однією з головних причин . її не усуває, а маскує: пауза не перевіряє стан, вона лише сподівається, що за цей час усе встигне, — а джерело показує, що програє вона двічі (замало — тест падає, забагато — прогін стає непідйомним) і що механізми синхронізації кращі. Правильна альтернатива — очікування за умовою: чекати рівно доти, доки настане потрібний стан, а таймаут тримати як стелю терпіння, а не як фіксований простій.
Наша практика (не канон). Класичний приклад гонки — живий пошук: користувач набирає ab, потім abc, летять два запити, і якщо відповідь на ab прийде пізніше, вона перезапише правильні результати для abc. Суть дефекту в одному рядку: перемагає «останній, що відповів», хоча має перемагати «останній, що надіслали». За нашим досвідом гонка — ще й найчастіша першопричина в UI-тестах; канонічне джерело обережніше — воно каже «одна з головних причин», а сам приклад із живим пошуком опори в реєстрі не має.
Селектори й локатори при динамічній розмітці
Канон селекторів — глава про DOM, селектори та події; тут лише той зріз, який ламається саме через асинхронність. Селектор — шаблон, за яким добирають елементи; локатор в автотесті будується поверх тієї самої механіки й описує, як знайти елемент у поточному стані дерева.
Динамічна розмітка бʼє по локаторах двома способами. Перший — згенеровані імена: й CSS-модулі дають класи на кшталт Button_root__a1b2c, які змінюються з кожним ; рятує часткове зіставлення за атрибутом — ^= («починається з»), $= («закінчується на»), *= («містить підрядок»). Другий — позиційність: :nth-child добирає за місцем у дереві, а коли рядок довантажився вище, селектор указує вже на інший елемент.
Тому найстабільніший спосіб знайти елемент — той, яким його знаходить користувач і читач екрана: за роллю та видимим текстом. Роль відповідає семантиці елемента, а доступне імʼя формується з видимого тексту, aria-label чи повʼязаного <label> — жодне з цього не залежить від верстки . Компроміс — data-testid: явний контракт між тестом і кодом, який не чіпають при зміні стилів.
// крихко: клас переживе рівно один білд
await page.locator('.Button_root__a1b2c').click();
// стабільно: роль і доступне імʼя не залежать від верстки
await page.getByRole('button', { name: 'Купити' }).click();
Наша практика (не канон). Робоча ієрархія вибору — роль плюс доступне імʼя → текст або лейбл → data-testid → CSS за стабільним атрибутом → XPath. Саме таке ранжування наше: джерела дають перелік рекомендованих локаторів без порядку переваги. Сюди ж — окрема пастка асинхронності: скелетон (сірий плейсхолдер у формі майбутнього контенту) теж є елементом DOM, і локатор «за формою блоку» чіпляється саме за нього. Тому перевірку прив'язують до того, чого в плейсхолдері бути не може: конкретного тексту, ціни, імені. А «індикатор зник» тримають у парі з «контент зʼявився» — між цими двома подіями буває проміжок, і перевірка лише першої з них ненадійна.
Очікування й синхронізація як відповідь на все перелічене
Усе попереднє зводиться до одного: тест і застосунок живуть у різному часі, а синхронізація — це вміння діяти рівно тоді, коли застосунок готовий. Механічно вона стоїть на двох речах.
Перша — перевірки придатності до дії (actionability), які інструмент робить сам. Перед кліком Playwright чекає, поки локатор укаже рівно на один елемент, а сам елемент буде видимим, стабільним, увімкненим і отримуватиме події; для введення додається перевірка «редагований». Означення варті запамʼятовування: видимий — має непорожній і не має visibility: hidden (нульовий розмір і display: none невидимі, а opacity: 0 вважається видимим); стабільний — зберігає той самий bounding box щонайменше два послідовні кадри анімації; отримує події — є ціллю вказівникової події в точці дії, тобто перевірка ловить оверлей, модалку чи тост, що перехоплює клік. Не дочекався в межах таймауту — TimeoutError. Опція force вимикає необовʼязкові перевірки — зокрема перестає контролювати, чи справді елемент отримує клік; здебільшого це спосіб зробити тест зеленим, приховавши дефект.
Друга — явні очікування прикладних сигналів, про які інструмент знати не може: чи навішений обробник після гідратації, чи завершився AJAX, чи оновилися дані. Web-first перевірки самі повторюють спробу, доки умова не виконається, — саме тому ручні паузи не потрібні. А коли зміна в інтерфейсі надто тонка, синхронізуються на мережевій відповіді, і тут є деталь, яку часто пропускають: проміс очікування створюють до дії, що тригерить запит, інакше швидка відповідь прослизне у вікно між дією і викликом очікування — і це знову гонка.
// проміс створено ДО кліку, інакше швидка відповідь прослизне
const response = page.waitForResponse(
(r) => r.url().includes('/api/profile') && r.status() === 200,
);
await page.getByRole('link', { name: 'Профіль' }).click();
await response;
// і однаково перевіряємо кінцевий, змістовний стан
await expect(page.getByRole('heading', { name: 'Профіль' })).toBeVisible();
Жорстке очікування програє двічі: замало — тест флакне на повільному CI, забагато — прогін марно розтягується; головна вада в тому, що воно синхронізується з годинником, а не із застосунком. Наша практика (не канон). Єдиний випадок, де до паузи тягнуться закономірно, — перевірка відсутності зміни: чекати нема на що. Але джерело тут не арбітр: дока Selenium такого винятку не формулює й не забороняє його — про виправданість чи заборону короткої паузи в ній не сказано нічого. Тому й ми не подаємо це правилом: чесніший хід — знайти спостережуваний сигнал, після якого відсутність зміни вже щось означає (прихід тієї самої застарілої відповіді, завершення запиту, зміна лічильника), і перевіряти стан після нього. Не варто змішувати й моделі очікувань: неявне очікування Selenium реагує тільки на «елемент не знайдено» і в суміші з явним дає непередбачувані сумарні таймаути. Окремо глушать анімації — інакше вони ламають і клік (елемент їде), і візуальне порівняння.
Наша практика (не канон). Найнадійніший сигнал готовності — той, який застосунок виставляє сам: домовлений із командою атрибут на кшталт data-hydrated чи aria-busy="false". Це річ конкретного застосунку, а не стандарт, тож канонічного джерела під цю практику немає — але вона знімає рівно ту невизначеність, якої не закриває .
Тротлінг мережі як спосіб побачити проміжні стани навмисно
Проміжні стани, з яких складається ця глава, на швидкому Wi-Fi просто не встигають проявитися. (throttling) робить їх видимими: меню Throttling у панелі мережі емулює повільні зʼєднання за готовими пресетами.
Дві деталі, які економлять час. Перша: набір пресетів змінювався між версіями Chrome — у поточних це, зокрема, Fast 4G, Slow 4G і 3G, а раніше були Fast 3G / Slow 3G. Тому для відтворюваних умов надійніше задавати власний профіль із фіксованими значеннями завантаження, віддачі й затримки, ніж покладатися на назву пресета. Друга: поруч живе режим Offline і Disable cache — дешева перевірка деградації, чи повідомляє застосунок про втрату мережі.
Головне ж — як читати результат. Якщо тест падає на повільному профілі, він не «флакне на швидкому» — він просто ще не впав: очікування в ньому недостатньо прив'язані до подій. Сам тротлінг стабільності не додає — це інструмент відтворення й діагностики.
Наша практика (не канон). Тротлінг оголює стани завантаження: чи є спінер і скелетон, чи не «моргає» порожній екран, чи лишається кнопка активною, поки запит іще летить (класика подвійного надсилання). Це чесний спосіб знайти гонку до того, як її знайде CI, — тож перед тим як списати падіння на «повільний ранер», відтвори його локально на повільному профілі. Канонічного джерела під ці прийоми в реєстрі немає, це наш робочий досвід.
Типові помилки
- «Сторінка відкрилась — можна перевіряти». Насправді
loadгарантує документ і підресурси, а дані приїжджають окремим запитом після нього; у SPA при клієнтському переході він узагалі не повторюється. - «
fetchне кинув помилки — значить, усе гаразд». Насправді404і500дають успішний проміс: без перевіркиresponse.okпомилка сервера проходить мовчки, і в інтерфейсі просто нічого не оновлюється. - «Кнопку видно — можна клікати». Насправді на SSR-сторінці розмітка приходить раніше за обробники: до кінця гідратації клік провалюється в порожнечу, і саме тому падіння плаваюче.
- «element not found — треба правити локатор». Насправді елемента може фізично не бути в DOM: він лінивий, віртуалізований або дані ще не приїхали. Правити треба стратегію очікування, а не селектор.
- «Мережа затихла — сторінка готова». Насправді полінг, вебсокет чи аналітика не дають їй затихнути ніколи, а затихнути вона може й тоді, коли дані ще обробляються в JS; документація Playwright прямо позначає це очікування як DISCOURAGED.
- «Тест зелений локально — значить, стабільний». Насправді на повільному профілі він падає, і це не властивість CI, а властивість тесту: очікування прив'язані до годинника, а не до подій.
Підсумок
- «Показано» і «готово» — різні події. Між ними лежать окремий запит за даними, побудова DOM, layout, paint і навішування обробників; тест має чекати на потрібну стадію, а не на «сторінку».
- DOM — результат, а не джерело. Локатор шукає в поточному стані дерева, і воно змінюється скриптами весь час; те, що видно у відповіді сервера, і те, що бачить браузер, збігається лише в найпростіших випадках.
- Універсального сигналу готовності немає.
DOMContentLoaded,load,readyState === "complete"і «мережа затихла» кожен по-своєму недостатні; готовність — поняття конкретного застосунку. - Гонка виникає між задачами, а не всередині них. Порядок завершення асинхронних операцій не гарантований, тож фіксована пауза її не усуває, а маскує.
- Надійне очікування прив'язане до умови. Автоочікування закриває «елемент зʼявився і став клікабельним», решту — гідратацію, приїзд даних, оновлення стану — доводиться виражати явно.
Можливі питання
- «Чим SPA відрізняється від MPA і що це змінює для тестування?» Чекають не список властивостей, а наслідок: у SPA документ один, тому чіткого моменту «сторінка завантажилась» при переході немає, а стан не скидається сам — звідси вимоги до ізоляції.
- «Що таке гідратація і чому клік по видимій кнопці може нічого не зробити?» Перевіряють, чи розділяєш розмітку й інтерактивність. Сильна відповідь називає вікно між появою HTML і навішуванням обробників і згадує, що стандартного сигналу «гідратацію завершено» немає.
- «Як поводиться
fetchпри відповіді 404?» Питання-детектор: слабка відповідь каже «кине помилку». Правильна — проміс резолвиться, статус перевіряють вручну, а реджект буває, коли відповіді не було зовсім. - «Чому
sleepу тесті — антипатерн і чим його замінити?» Дивляться, чи бачиш причину, а не ритуал: пауза синхронізується з годинником, а не із застосунком. Плюсом — очікування конкретної відповіді, створене до дії, і перевірка кінцевого змістовного стану. - «Тест падає лише на CI. З чого почнеш?» Часта форма на мідл-рівні. Сильна відповідь пропонує відтворити умови (повільний профіль мережі), а не збільшувати таймаут, і називає підозрюваних: гонка запитів, гідратація, лінивий контент.
Джерела
Запит без перезавантаження: AJAX і fetch
- MDN — Using the Fetch API — Fetch як заміна XHR, успішний проміс на
404/500,response.okдля2xx, відкритий перелік причин реджекту, одноразовість потоку тіла, скасування черезAbortController. - MDN Glossary — SPA (Single-page application) — оновлення частини документа замість повного перезавантаження.
- MDN — XMLHttpRequest — старий механізм фонового запиту і скасування через
abort(). - MDN — XMLHttpRequest: readyState property — пʼять станів,
HEADERS_RECEIVEDпісля всіх редиректів, часткове тіло вLOADING, двозначністьDONE. - Playwright — Network — очікування конкретної відповіді за URL і статусом замість сліпого очікування.
- Chrome DevTools — Network features reference — фонові запити в тому самому журналі мережі.
- Playwright — Auto-waiting (actionability) — перевірки придатності перед дією замість припущення, що потрібний вміст уже на місці.
SPA і MPA: що переміщується, а що ні
- MDN Glossary — SPA (Single-page application) — один документ, оновлення вмісту через JavaScript, переходи без перезавантаження.
- MDN — History API —
pushState/replaceStateбез запиту на сервер і те, що вони не генеруютьpopstate. - MDN — Window: load event — момент спрацювання
load: документ разом із підресурсами. - Playwright — class Page — навігаційні сигнали й очікування контенту після переходу.
- Playwright — Authentication — свіжий контекст браузера як спосіб не тягнути стан між тестами.
- MDN — Web Storage API — сховище, яке переживає кроки тесту, якщо його не скинути.
- web.dev — Rendering on the web — ціна клієнтського рендерингу: обсяг JavaScript і чутливість сторінки.
- Playwright — Auto-waiting (actionability) — очікування придатності елемента до дії замість сигналу «сторінка завантажилась».
CSR, SSR і гідратація: розмітка є, а обробники ще ні
- web.dev — Rendering on the web — означення CSR, SSR, статичного рендерингу й гідратації; «виглядає інтерактивною, але не реагує на введення»; порожній DOM при чистому CSR і ціна регідратації.
- MDN Glossary — SPA (Single-page application) — чому вміст такої сторінки перевіряють лише в браузері, що виконує JavaScript.
- MDN — Document: DOMContentLoaded event — що саме гарантує подія: розібраний HTML, не дані.
- MDN — Window: load event — межа гарантії
load. - Playwright — class Page — стани завантаження сторінки як сигнали й
networkidleз позначкою DISCOURAGED.
Конвеєр рендерингу: коли елемент справді готовий
- MDN — Critical rendering path — послідовність етапів від DOM і CSSOM до paint, повторний layout після зміни дерева, залежність геометрії від розміру екрана.
- web.dev — Rendering performance (pixel pipeline) — пʼять етапів конвеєра і те, що не кожна зміна проганяє всі.
- Chrome for Developers — Inside look at modern web browser (part 3) —
display: noneпоза деревом рендерингу протиvisibility: hiddenу ньому. - Chrome DevTools — Simulate mobile devices with device mode — залежність результату layout від розміру області перегляду.
- Playwright — Auto-waiting (actionability) — наявність у DOM як найслабша гарантія і вимога стабільності перед дією.
DOM як результат, а не джерело
- MDN — Introduction to the DOM — дерево вузлів як інтерфейс до документа і його зміна скриптами після завантаження.
- WHATWG DOM Standard — нормування дерева й подій у ньому.
- MDN — Node: childNodes property — живі колекції, які змінюються разом із деревом.
- MDN Glossary — SPA (Single-page application) — чому вміст SPA не видно поза браузером, що виконує JavaScript.
- curl — manual page — HTTP-клієнт без виконання JavaScript як інструмент, що бачить лише відповідь сервера.
- Playwright — Locators — локатор як рецепт пошуку, що розвʼязується в момент дії.
- Playwright — Frames — інший контекст пошуку як причина «не знайдено».
- Playwright — Auto-waiting (actionability) — очікування умови замість фіксованої паузи.
- Selenium — Waiting Strategies — те саме правило в класичному інструменті.
- Selenium — Common errors (troubleshooting) —
StaleElementReferenceException: ідентифікатор посилання й «Elements do not get relocated automatically», три причини з протилежними рецептами, межі перезнаходження й кешу з повтором.
Подієвий цикл: чому перевірка ловить проміжний стан
- WHATWG HTML Standard — Event loops — узгодження подій, скриптів, рендерингу й мережі; черга мікрозадач як окрема сутність; нагода для рендерингу.
- MDN — JavaScript execution model — run-to-completion, колбек як задача в черзі, пріоритет мікрозадач, довга задача й затримка кадру.
- MDN — Using microtasks in JavaScript with queueMicrotask() — реакції промісів як мікрозадачі.
- WHATWG HTML Standard — Timers — відсутність гарантії виконання таймера за розкладом.
- Playwright — Auto-waiting (actionability) — чому очікування прив'язують до стану, а не до часу.
Гонка станів у завантаженні даних
- MDN — JavaScript execution model — відсутність гарантій щодо порядку задач і повне виконання кожної з них.
- MDN — Using the Fetch API — скасування застарілого запиту через
AbortController. - Selenium — Waiting Strategies — гонка між браузером і кодом тесту як одна з головних причин флаку, подвійна ціна фіксованої паузи й очікування умови як заміна.
- Playwright — Auto-waiting (actionability) — таймаут як межа очікування умови.
Селектори й локатори при динамічній розмітці
- MDN — CSS selectors — добір за типом, атрибутом, станом і позицією в дереві.
- MDN — Attribute selectors — часткове зіставлення
^=,$=,*=. - Playwright — Locators — пошук за роллю й доступним імʼям і те, чого уникають як основного підходу.
- Testing Library — Guiding Principles — шукати елемент так, як його знаходить користувач.
- MDN — Use data attributes — власні атрибути як місце для тестового контракту.
Очікування й синхронізація як відповідь на все перелічене
- Playwright — Auto-waiting (actionability) — набір перевірок перед дією, означення «видимий», «стабільний», «отримує події»,
TimeoutError, наслідокforce, повторювані web-first перевірки. - Playwright — class Page — створення промісу очікування відповіді до дії, що тригерить запит.
- Playwright — Locators — розвʼязання локатора в момент дії.
- Playwright — Best Practices — рівень, на якому формулюють перевірку.
- Testing Library — Guiding Principles — перевіряти те, що бачить користувач.
- Playwright — Visual comparisons — глушіння анімацій опцією для скріншотів.
- Selenium — Waiting Strategies — явні й неявні очікування, ціна фіксованої паузи, застереження проти змішування.
- MDN — Document: DOMContentLoaded event — межа сигналу «HTML розібрано».
Тротлінг мережі як спосіб побачити проміжні стани навмисно
- Chrome DevTools — Network features reference — меню Throttling і пресети, власні профілі, режим Offline поруч із Disable cache.
- Selenium — Waiting Strategies — падіння на повільному профілі як ознака очікувань, не прив'язаних до подій.
- Playwright — Auto-waiting (actionability) — стабільність від прив'язки до стану, а не від швидкості мережі.
Тест падає з «element not found», а на скріншоті елемент є. Що відбувається?
знімають після падіння, тобто пізніше, ніж шукав елемент, — за ці частки секунди застосунок домалював те, чого раніше не існувало. Сторінка збирається не одним актом, а серією: приходить документ, виконується JavaScript, летить окремий запит за даними, і лише тоді потрібний вузол зʼявляється в дереві. Тест зазирнув у щілину між двома етапами й чесно нічого не знайшов. Тому перше питання не «який селектор правильний», а «чи взагалі існував елемент у момент пошуку». Якщо не існував — виправляти треба стратегію очікування, а не локатор: додати умову, яка тримає тест доти, доки не настане потрібний стан. Селектор винен лише тоді, коли вузол у DOM (Document Object Model) був, а знайти його не вдалося.
Що таке AJAX і навіщо він потрібен?
AJAX (asynchronous JavaScript and XML) — підхід, за якого сторінка тягне дані з сервера у фоні й оновлює лише потрібний шматок DOM, замість повного перезавантаження. Візуально це виглядає як миттєва реакція: список перефільтрувався, кошик перерахувався, а решта екрана не сіпнулась. Реалізують це двома інтерфейсами: історичним XMLHttpRequest (XHR) і сучаснішим Fetch API. Для тестувальника наслідок формулюється одним рядком: подія «документ відкрився» і подія «дані на екрані» — різні, і між ними лежить вимірюваний проміжок часу. Саме в цей проміжок і провалюються перевірки, написані одразу після навігації.
Чим fetch кращий за XMLHttpRequest і що в ньому лишилося незручним?
fetch побудований на (Promise), а не на і подіях, тому природно лягає на async/await і не породжує драбину вкладених обробників. Він інтегрований із CORS і service workers, тож це не просто синтаксичний цукор над XHR. Незручностей теж вистачає. Перша: помилковий статус від сервера не вважається помилкою запиту, і обробляти його треба руками. Друга: тіло відповіді — потік (ReadableStream), прочитати його двічі не вийде, тому виклик response.json() після response.text() завершиться помилкою. Третя: вбудованого немає — обмеження часу роблять через AbortController, той самий, яким скасовують застарілий запит.
Як fetch поводиться, якщо сервер відповів 404?
Проміс завершується успішно. Це класичне питання-детектор: слабка відповідь звучить як «кине виняток», сильна — «зарезолвиться обʼєктом Response зі статусом 404». Логіка проста: HTTP-відповідь від сервера прийшла, отже мережева операція вдалася, а помилковий статус усередині — прикладна деталь, яку код зобовʼязаний перевірити сам. Робочий інструмент — властивість response.ok, істинна лише для діапазону 2xx. Наслідок для дефектів прямий: якщо розробник забув перевірку статусу, відповідь про помилку потрапляє в гілку «успіх», застосунок мовчки нічого не оновлює, а користувач бачить порожній екран без жодного попередження. У тесті це виглядає як «елемент не зʼявився», хоча першопричина — необроблена помилка сервера.
Коли ж проміс fetch таки відхиляється?
Тоді, коли HTTP-відповіді не було зовсім: обірвалася мережа, схема URL некоректна, запит скасували через AbortController. Документація навмисно не дає закритого переліку таких причин — формулювання говорить про «деякі помилки, як-от мережева помилка або погана схема», лишаючи список відкритим. Тому вчити перелік напамʼять — програшна стратегія; у голові варто тримати саму межу: відповідь від сервера є, хай навіть 500, — проміс успішний; відповіді немає — реджект. На співбесіді перевіряють розуміння цієї межі, а не здатність перерахувати випадки.
Чим SPA відрізняється від MPA — і що з цього випливає для автотестів?
SPA (single-page application) завантажує один вебдокумент і далі перемальовує його вміст засобами JavaScript, коли користувач «переходить» між екранами. MPA (multi-page application) на кожен екран віддає окремий HTML-документ, тобто кожен перехід — повноцінна навігація з новим завантаженням. Різниця для тесту не архітектурна, а часова: у MPA існує чіткий момент «сторінка завантажилась», на який можна спертися, а в SPA цей момент трапляється рівно раз — на старті. Далі доводиться чекати не на навігацію, а на появу конкретного контенту. Другий наслідок — ізоляція: документ живе весь сценарій, тому стан у памʼяті, сховище (Web Storage) і сесія переїжджають із кроку в крок і з тесту в тест, якщо їх не скинути свідомо. Тому свіжий між тестами для SPA — не перестраховка, а необхідність. Третій, менш очевидний: обсяг JavaScript росте разом із застосунком, і сторінка стає повільнішою в реакціях на дії.
Що робить history.pushState() і чому це пастка для перевірки URL?
pushState() і replaceState() змінюють адресу в рядку браузера без походу на сервер — саме на цьому тримається клієнтський роутинг. Пастка в порядку подій: роутер спершу переписує URL, а вже потім іде за даними нового екрана. Перевірка «адреса стала /profile» проходить у момент, коли на місці контенту ще спінер або скелетон (skeleton). Тест, який дивиться тільки на адресу, зеленітиме навіть тоді, коли екран так і не завантажився. Робоче правило — перевіряти пару: адресу і появу змістовного елемента цього екрана. Є ще дрібниця, яку люблять питати окремо: подія popstate реагує на рух по історії (кнопки «назад» і «вперед», history.back()), а самі виклики pushState і replaceState її не породжують.
Що таке CSR і SSR і як відрізнити їх на практиці?
CSR (client-side rendering) означає, що сервер віддає майже порожню оболонку, а розмітку будує JavaScript уже в браузері. SSR (server-side rendering) — навпаки: сервер генерує готовий HTML під конкретну навігацію, тому перший показ зазвичай швидший, а клієнту їде менше коду. Є й проміжний варіант — статичний рендеринг, коли HTML збирають заздалегідь, на етапі . Найдешевший спосіб відрізнити: витягнути сторінку клієнтом, який не виконує JavaScript, і подивитись на вихідний HTML. Побачили в ньому потрібні тексти — рендерить сервер; побачили порожній і пачку тегів <script> — рендерить браузер. Для тестування це визначає дві речі: чи можна перевіряти контент без справжнього браузера і наскільки рано в житті сторінки взагалі має сенс шукати елементи.
Що таке гідратація і чому натискання видимої кнопки іноді ні до чого не призводить?
Гідратація (hydration) — етап, на якому клієнтський JavaScript «оживляє» вже надіслану сервером розмітку: відновлює стан компонентів і навішує обробники подій, не перемальовуючи при цьому DOM. Звідси й проблема: розмітка кнопки приїжджає раніше, ніж обробник на неї. Якийсь час сторінка виглядає цілком готовою — кнопка намальована, курсор змінюється, — але натискання не запускає нічого, бо слухача ще немає. Тест у цю мить робить формально успішний клік і падає вже на перевірці результату, який ніколи не настане. Плаваючий характер падіння пояснюється тим самим: на потужній машині гідратація встигає завершитись до кліку, на завантаженому CI- — ні. Стандартного атрибута «гідратацію завершено» браузер не має, тож надійний маркер доводиться домовляти з командою.
Чи існує універсальний сигнал «сторінка готова»?
Ні, і це головна теза теми. DOMContentLoaded каже лише про те, що HTML розібрано в дерево, — при клієнтському рендерингу екран у цей момент ще порожній. load додає підресурси, але запити за даними стартують уже після нього, а при переході всередині SPA він взагалі не повторюється. document.readyState === "complete" успадковує ті самі обмеження, бо описує ту саму подію. Очікування «мережа затихла» виглядає розумним рівно доти, доки в застосунку не зʼявляється (polling), вебсокет чи аналітика — тоді вона не затихає ніколи; Playwright і сам позначає цей режим як небажаний. Висновок для співбесіди: готовність — властивість конкретного застосунку, а не браузера, тому її формулюють прикладною умовою («видно заголовок екрана», «у таблиці є рядки»), а не подією життєвого циклу.
Чому наявність елемента в DOM — найслабша з можливих гарантій?
Бо між «вузол прикріплено до дерева» і «з ним можна працювати» стоїть увесь конвеєр рендерингу: дерево рендерингу, обчислення геометрії (layout), малювання (paint) і складання шарів (composite). На кожному етапі елемент може випасти. Вузол із display: none у DOM присутній, а в дереві рендерингу його немає взагалі: геометрії він не має і кліку не отримає. Різниця з visibility: hidden тут принципова — такий елемент у дереві лишається й місце займає, просто його не видно. Поверх усього цього — обробники: елемент буває видимим, стабільним і клікабельним, а слухача на ньому ще немає. Тому перевірка «елемент прикріплений до дерева» спрацьовує найраніше й майже нічого не обіцяє, а придатність до дії доводиться перевіряти окремо.
Які перевірки Playwright робить перед кліком і що вимикає force?
Перед дією інструмент вимагає, щоб локатор указував рівно на один елемент, а сам елемент був видимим, стабільним, увімкненим і здатним отримати подію; для введення тексту додається перевірка на редагованість. Означення варто знати точно. Видимим вважається елемент із непорожнім , у якого немає visibility: hidden; нульовий розмір робить його невидимим, а opacity: 0 — ні. Стабільним — той, чий bounding box не змінився протягом двох послідовних кадрів анімації. Здатним отримати подію — той, що є ціллю вказівника в точці кліку, тобто перевірка ловить прозорий оверлей, модалку чи тост зверху. Якщо умови не склалися до кінця таймауту, тест падає з TimeoutError. Опція force вимикає необовʼязкові перевірки, зокрема ту, що контролює справжню ціль кліку, — тому вона майже завжди не лікування, а спосіб зробити зеленим тест, який щойно знайшов реальний дефект інтерфейсу.
Що таке гонка станів і чому фіксована пауза її не усуває?
Гонка (race condition) — ситуація, коли результат залежить від порядку завершення асинхронних операцій, а гарантій щодо цього порядку немає. Оскільки кожна задача в браузері виконується до кінця й не переривається, гонка живе не всередині задачі, а на межі між ними: два запити вилетіли, а хто відповість першим — вирішує мережа, не код. Хрестоматійний приклад — живий пошук: користувач друкує ab, за мить дописує c, і якщо відповідь на коротший рядок прийде другою, вона затре правильні результати. Дефект тут в одному: перемагає той, хто відповів останнім, замість того, кого надіслали останнім; лікують це скасуванням застарілого запиту через AbortController або відкиданням неактуальних відповідей. Пауза в тесті природи явища не змінює — вона лише сподівається, що за N секунд усе встигне, і програє двічі: замало — падіння на повільному ранері, забагато — марно розтягнутий прогін. Тест, який не синхронізується зі станом застосунку, сам стає учасником гонки.
Чому setTimeout(fn, 0) не виконується миттєво і як це стосується флаку?
Бо задана — це мінімум, а не обіцянка: специфікація прямо відмовляється гарантувати виконання таймера точно за розкладом. Колбек стає задачею в черзі й чекає, поки звільниться потік, тож нуль означає «якнайшвидше після поточної роботи», а не «зараз». Поруч живе друга черга — мікрозадач; вона окрема, має вищий пріоритет і спорожнюється повністю після кожної задачі. Реакції промісів (.then) потрапляють саме туди, тоді як setTimeout, обробники подій DOM і XHR стають у чергу задач. Практичний наслідок: коли мережева відповідь уже прийшла, вміст оновиться не в ту саму мить, а після того, як відпрацює ланцюжок мікрозадач. І другий: довгу синхронну задачу ніхто не витісняє, тому вона затримує і обробку кліків, і черговий кадр — сторінка намальована, а на дії не реагує.
Чому локатор має бути рецептом пошуку, а не збереженим посиланням на вузол?
Тому що дерево під ним переписується весь час: JavaScript додає, замінює й видаляє вузли у відповідь на дані, дії користувача й таймери. Якщо зберегти посилання на конкретний елемент, а компонент після цього перемалюється, посилання вкаже на вузол, якого в документі вже немає, — це і є класичне «елемент застарів». У Selenium ця помилка зветься StaleElementReferenceException, і назву варто знати не заради співбесіди, а щоб упізнати її в лозі й загуглити: дока пояснює її одним реченням — «Elements do not get relocated automatically», тобто драйвер тримає ідентифікатор посилання й чекає знайти елемент у конкретному місці DOM. Звідси й правильне читання: це не «елемента немає на сторінці», а «моє посилання померло». Рецепт залежить від причини: змінився DOM — перезнайти; перемкнувся контекст (фрейм, вікно) — перезнаходження не допоможе, треба повернутися в контекст. Локатор, що розвʼязується щоразу в момент дії, такої проблеми не має: він просто знаходить актуальний вузол за описом. Звідси й порада не кешувати результати пошуку між кроками сценарію. Окремо варто памʼятати про контекст: якщо елемент не знаходиться, він може жити в iframe або Shadow DOM, куди звичайний пошук по документу не дістає.
Як будувати локатори, коли розмітка динамічна?
Динаміка бʼє двома способами. Перший — згенеровані імена класів на кшталт Button_root__a1b2c: переписує суфікс на кожному білді, тож такий селектор живе рівно до наступної збірки; частково рятує зіставлення за частиною атрибута — ^=, $=, *=. Другий — позиційність: :nth-child(3) указує на місце в дереві, а варто одному рядку довантажитись вище, і селектор веде вже на інший елемент. Стійка альтернатива — шукати так, як шукає користувач: за роллю елемента й доступним імʼям, яке складається з видимого тексту, aria-label або повʼязаного <label>. Ці характеристики не залежать від того, у скільки контейнерів загорнули кнопку. Коли семантики бракує, домовляються про data-testid — явний контракт між кодом і тестом, який не чіпають при зміні стилів.
Чому waitForResponse треба створювати до дії, яка тригерить запит?
Бо інакше тест влаштовує гонку сам собі. Якщо спершу клікнути, а потім почати чекати на відповідь, швидка відповідь може прийти в проміжку між цими двома рядками — і очікування стартує вже після того, як подія, на яку воно розраховане, минула. Далі тест чесно висітиме до таймауту й упаде, хоча застосунок відпрацював бездоганно. Правильний порядок: створити проміс очікування, виконати дію, потім дочекатися промісу — так вікно для промаху закривається. І фінальна перевірка все одно має бути про інтерфейс, а не про мережу: отримана відповідь ще не означає, що дані відмальовані на екрані.
Елемент не знаходиться, бо його немає в DOM. Які причини перевірити?
Перша — дані ще не приїхали: запит летить, вузол ще не створено. Друга — (lazy loading), і тут важливо розрізняти рівні: при loading="lazy" на зображенні чи iframe сам тег у дереві присутній, відкладено лише завантаження ресурсу, а от компонент, який рендериться через IntersectionObserver при попаданні у видиму область, фізично відсутній, доки до нього не догорнули. Третя — віртуалізація: нескінченний список тримає в DOM лише видиме вікно, тому перевірка «усі двісті рядків присутні» нездійсненна в принципі, і сценарій треба переписувати на пошук конкретного рядка з прокруткою. Четверта — інший контекст пошуку: iframe або Shadow DOM. У жодному з цих випадків правка селектора не допоможе.
Тест зелений локально й падає лише на CI. З чого почнеш?
Не з підняття таймауту — це заглушує симптом, а не причину. Спершу перевіряю гіпотезу «на CI просто повільніше»: відтворюю умови локально (throttling) мережі в DevTools, причому надійніше власним профілем із фіксованими значеннями швидкості й затримки, бо набір готових пресетів між версіями Chrome змінювався. Якщо на повільному профілі тест падає й локально, питання закрите: він і раніше не був стабільним, просто швидка мережа це маскувала. Далі шукаю винуватця з короткого списку підозрюваних: гонка паралельних запитів, незавершена гідратація, лінивий або віртуалізований контент, очікування, привʼязані до годинника замість стану. Лікування — переписати очікування на умови: конкретна мережева відповідь, змістовний елемент, прикладний маркер готовності. Сам собою тротлінг нічого не стабілізує: його роль — відтворити проблему й показати її, а виправляти доводиться очікування.
Три кейси про розрив між «браузер показав» і «застосунок готовий»: діагностика падіння «element not found» без правки селектора, відтворення гонки живого пошуку в Playwright і перевірка на повільному профілі, яка знаходить те, що потім знайде CI. Скрізь — що дивитися і які висновки з цього робити.
Кейс 1. «Елемент є на скріншоті» — діагностика за пʼять хвилин
Ситуація типова: не знаходить рядок замовлення, у трасі — рядок на місці. Спокуса переписати селектор велика, але поки не зʼясовано, що саме зламалось, будь-яка правка — вгадування. Порядок дій зворотний: спершу питання «чи існував вузол у момент пошуку», потім «чи мав він геометрію», потім «чи міг прийняти клік».
Відкрийте сторінку в тому самому стані й пройдіться DevTools:
// 1. чи є вузол у дереві саме зараз
document.querySelectorAll('[data-testid="order-row"]').length;
// 2. чи є у нього геометрія (самі нулі — display: none або нульовий розмір)
const el = document.querySelector('[data-testid="order-row"]');
el.getBoundingClientRect();
getComputedStyle(el).display;
// 3. хто насправді ловить клік у центрі елемента
const r = el.getBoundingClientRect();
document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
Далі — зіставлення результату з діагнозом:
| Що показала перевірка | Ймовірна причина | Що робити |
|---|---|---|
Вузлів 0, при повторному виклику через секунду вже 10 | Дані ще летіли: рядки малює відповідь API | Очікування на змістовний елемент або на конкретну мережеву відповідь |
Вузлів 0 і після прокрутки вниз стає більше | Віртуалізований список або ліниво відрендерений компонент | Прокрутити до потрібного рядка й шукати його, а не «усі рядки» |
Вузол є, getBoundingClientRect() дає самі нулі | display: none (такого вузла в дереві рендерингу немає взагалі) або нульовий розмір — інструмент вважає елемент невидимим в обох випадках | Чекати на видимість, а не на присутність у DOM |
Вузол є, розмір є, а elementFromPoint повертає інший елемент | Клік перехоплює оверлей, тост або модалка | Дочекатися зникнення перекриття; force тут — маскування дефекту |
| Усе перелічене гаразд, а клік нічого не змінює | Обробник ще не навішений — гідратація не завершилась | Синхронізуватися з маркером готовності застосунку |
| Вузол не знаходиться взагалі, хоч видно його на екрані | Він живе в iframe або Shadow DOM | Змінити контекст пошуку, а не селектор |
Ключове спостереження: у пʼятьох рядках із шести правка селектора не змінює нічого. «Не знайдено» — здебільшого діагноз про час, а не про адресу елемента.
Кейс 2. Playwright: відтворити гонку живого пошуку, а не ловити її на удачу
Класичний дефект: у полі пошуку зʼявляється ab, за мить дописується c, у мережу йдуть два запити — і якщо коротший відповідає другим, він затирає правильні результати. На швидкій мережі баг не відтворюється місяцями, тому чекати на випадковість не варто: задають самі, підмінивши мережу.
import { test, expect } from '@playwright/test';
test('повільна відповідь на застарілий запит не перезаписує результати', async ({ page }) => {
// штучно робимо «ab» повільнішим за «abc» — гонка стає детермінованою
await page.route('**/api/search*', async (route) => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'ab') {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
await route.fulfill({ json: { items: q === 'abc' ? ['Абонемент'] : ['Абажур', 'Абрикос'] } });
});
await page.goto('/catalog');
const search = page.getByRole('searchbox', { name: 'Пошук товарів' });
const results = page.getByRole('list', { name: 'Результати пошуку' });
// проміс на застарілу відповідь створюємо ДО введення
const staleArrived = page.waitForResponse(
(r) => new URL(r.url()).searchParams.get('q') === 'ab',
);
await search.fill('ab');
await search.fill('abc');
// правильні результати вже показані
await expect(results.getByRole('listitem')).toHaveText(['Абонемент']);
// тепер прилітає запізніла відповідь на «ab» — і не має нічого затерти
await staleArrived;
await page.waitForTimeout(200);
await expect(results.getByRole('listitem')).toHaveText(['Абонемент']);
});
Що тут важливо:
- Затримку задає тест, а не мережа. Без
routeцей сценарій зелений на швидкому Wi-Fi і червоний раз на тридцять прогонів на CI — тобто марний як перевірка. - Другий
expectбез паузи був би фальшивим. Web-first перевірка завершується, щойно умова виконана, тож поставлена одразу після дії вона зафіксує ще не зіпсований стан і пройде навіть на зламаному застосунку. Тут перевіряють не появу зміни, а її відсутність — стан має лишитися тим самим після приходу застарілої відповіді. Головну роботу робить рядокawait staleArrived: це і є спостережуваний сигнал, після якого відсутність зміни щось означає. Пауза після нього — наша практика, а не канон: дока Selenium короткої фіксованої паузи в такому сценарії ні виправдовує, ні забороняє, тож подавати її правилом не можна. Мінімізуйте її й прибирайте, щойно зʼявиться сигнал точніший за час. - Виправлення живе в застосунку, не в тесті. Скасування попереднього запиту через
AbortControllerабо відкидання відповідей, чий запит уже неактуальний. Тест лише робить дефект видимим.
Коли зміна в інтерфейсі надто тонка, щоб на неї спертися, синхронізуються на самій мережевій відповіді — і тут порядок рядків критичний:
// проміс створено ДО кліку: інакше швидка відповідь прослизне між рядками
const profileLoaded = page.waitForResponse(
(r) => r.url().endsWith('/api/profile') && r.ok(),
);
await page.getByRole('link', { name: 'Мій профіль' }).click();
await profileLoaded;
// і однаково завершуємо перевіркою того, що бачить користувач
await expect(page.getByRole('heading', { name: 'Мій профіль' })).toBeVisible();
Відповідь, що прийшла, ще не означає намальованих даних: між приходом JSON і оновленим DOM стоїть ланцюжок мікрозадач і черговий кадр рендерингу. Тому останній рядок — не формальність.
Кейс 3. Повільний профіль: знайти падіння раніше за CI
Тест зелений локально сто разів поспіль і червоний на кожному третьому прогоні в . Замість підняття — відтворення умов. У DevTools це меню Throttling у панелі мережі, і краще власний профіль із фіксованими цифрами, ніж пресет: набір пресетів між версіями Chrome змінювався, тож «Slow 3G» у двох людей може означати різне. У Playwright ті самі умови задають через CDP — але це протокол Chromium, тож у WebKit і Firefox такий шлях недоступний:
const cdp = await context.newCDPSession(page);
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
latency: 400, // мс до першого байта
downloadThroughput: (400 * 1024) / 8, // 400 кбіт/с у байтах за секунду
uploadThroughput: (400 * 1024) / 8,
});
Далі проходимо сценарій руками й дивимось на проміжні стани, яких на швидкій мережі просто не існує:
| На що дивитись | Що це виявляє |
|---|---|
| Чи є скелетон і чи не «моргає» порожній екран | Стани завантаження, яких на швидкому каналі просто не існує в часі |
| Чи лишається кнопка «Оплатити» активною, поки запит летить | Подвійне надсилання — класичний дефект, невидимий на швидкій мережі |
| Чи реагує кнопка на клік одразу після появи розмітки | Вікно гідратації: розмітка є, обробників ще немає |
| Чи змінився URL раніше, ніж зʼявився контент екрана | Перевірка, яка спирається лише на адресу, пройде на порожньому екрані |
| Що показує застосунок у режимі Offline | Деградація: чи є повідомлення про втрату мережі, чи просто порожнеча |
Головне — як читати результат. Якщо на повільному профілі тест падає, він не «зламався від мережі»: він і був нестабільним, а швидкий канал це приховував. сам по собі стабільності не додає — він переводить рідкісний у відтворюване падіння, після чого лишається переписати очікування з годинника на стан: конкретна відповідь, змістовний елемент, домовлений маркер готовності на кшталт aria-busy="false".
Фонові запити: AJAX, fetch, XHR
- Розумію, що «документ відкрився» і «дані на екрані» — дві різні події, і між ними є проміжок, у який провалюється перевірка.
- Знаю, що
fetchвважає успіхом будь-яку отриману HTTP-відповідь (тож404і500резолвляться), статус перевіряють черезresponse.ok— істинний лише для2xx, — а вбудованого немає: його роблять черезAbortController. - Тримаю в голові межу реджекту
fetch: відповіді не було взагалі — обрив мережі, некоректна схема, скасування; завчений перелік випадків тут не працює, бо він відкритий.
SPA vs MPA і клієнтська навігація
- Можу пояснити різницю SPA vs MPA не переліком властивостей, а наслідком: документ живе весь сценарій, тож опертися при переході нема на що, а стан у памʼяті, сховище (Web Storage) і сесія переїжджають далі — звідси вимога свіжого контексту між тестами.
- Знаю, що
pushStateіreplaceStateміняють адресу без запиту на сервер і подіїpopstateне породжують, а сам URL змінюється раніше за дані — тому перевіряю пару: адресу і появу змістовного елемента екрана. - Розрізняю рівні відкладеності: при
loading="lazy"тег у DOM (Document Object Model) присутній, відкладено лише ресурс, а от компонент, відрендерений ліниво, і рядок віртуалізованого списку в дереві просто відсутні, доки до них не догорнули.
CSR, SSR і гідратація
- Знаю різницю CSR vs SSR за одним критерієм — хто будує розмітку, браузер чи сервер, — і можу пояснити, чому клієнт без виконання JavaScript (
curl, простий парсер) вмісту сторінки з клієнтським рендерингом не побачить. - Можу пояснити гідратацію та її вікно: розмітка вже видима, обробники ще не навішені, тому клік провалюється в порожнечу; стандартного сигналу «гідратацію завершено» немає, а падіння через це плаваюче — на CI гідратація не встигає, локально встигає.
- Можу довести, що універсального сигналу готовності не існує:
DOMContentLoaded— лише розібраний HTML,load— підресурси й не повторюється при переході в SPA,readyStateзі значеннямcomplete— те саме, а «мережа затихла» ламається , вебсокетом і аналітикою.
Від DOM до пікселів
- Знаю послідовність конвеєра рендерингу — DOM плюс CSSOM → дерево рендерингу → геометрія (layout) → малювання (paint) → складання шарів (composite) — і розумію, чому присутність вузла в DOM найраніша й найслабша з гарантій.
- Розумію асиметрію видимості:
display: noneвикидає елемент із дерева рендерингу,visibility: hiddenлишає його там разом із зайнятим місцем; а результат layout залежить від розміру , тож той самий на десктопі й мобільному може вести до різних елементів.
Подієвий цикл і гонки
- Можу пояснити, чому асинхронний не перерве вже запущений синхронний код, куди потрапляють реакції (мікрозадачі) і куди
setTimeout, події DOM та XHR (задачі), і чому задана таймера — мінімум, а не обіцянка. - Можу пояснити гонку як залежність результату від порядку завершення операцій між задачами й навести живий пошук: перемагає той, хто відповів останнім, замість того, кого надіслали останнім; лікується скасуванням застарілого запиту або відкиданням неактуальних відповідей.
Локатори при динамічній розмітці
- Розумію локатор як опис, за яким елемент шукають наново в момент дії (а не збережене посилання на вузол — звідси «елемент застарів»), знаю крихкість згенерованих класів і
:nth-childі тримаю ієрархію вибору: роль і доступне імʼя → видимий текст чи лейбл →data-testid→ стабільний CSS-атрибут → XPath як останній засіб. - На «not found» відповідаю спершу на два питання — чи вузол існував у момент пошуку і чи той контекст я взагалі шукав (
iframe, Shadow DOM), — і лише після цього чіпаю селектор.
Очікування й синхронізація
- Знаю набір перевірок придатності перед дією: рівно один елемент, видимий, стабільний, увімкнений, отримує події — плюс редагований для введення тексту.
- Памʼятаю точні означення: видимий — непорожній без
visibility: hidden(opacity: 0вважається видимим, нульовий розмір — ні), стабільний — той самий bounding box два кадри анімації поспіль, отримує події — є ціллю вказівника в точці дії. - Розумію, що
forceвимикає необовʼязкові перевірки (зокрема справжню ціль кліку) і зазвичай ховає дефект, а не лікує тест. - Створюю проміс очікування мережевої відповіді до дії, що тригерить запит; знаю, чому фіксована пауза програє двічі, і памʼятаю, що виняток «а якщо спостерігаємо відсутність зміни» — наша практика, а не канон: джерело його не формулює, тож спершу шукаю спостережуваний сигнал, після якого відсутність зміни щось означає; не змішую явні очікування з неявним очікуванням Selenium, яке реагує лише на «елемент не знайдено».
Відтворення проміжних станів
- Вмію відтворити повільні умови (throttling), знаю, чому власний профіль надійніший за пресет, і читаю падіння правильно: тест не «зламався від мережі» — він і був нестабільним, а тротлінг лише інструмент діагностики, не спосіб стабілізації.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
AJAX — що це означає?