Асинхронне завантаження: SPA/MPA, CSR/SSR
Зміст
Колись веб-сторінка була простою: браузер надсилав запит, сервер повертав повний HTML, браузер його малював. Будь-яка дія користувача — клік на посилання, надсилання форми — означала новий запит і повне перезавантаження (reload) сторінки. Тест міг спокійно дочекатися події завантаження і бути певним, що сторінка «готова».
Сучасний веб працює інакше. Сторінка вантажиться шматками, контент домальовується вже після появи вікна браузера, навігація відбувається без перезавантаження, а частина елементів взагалі не існує в DOM, поки користувач до них не догорнув. Саме ця асинхронність — головне джерело нестабільності (flakiness) в автотестах. Щоб писати надійні тести, треба розуміти, як і коли контент з’являється на сторінці. Розберемо це від першопричин.
AJAX: коли сторінка перестала перезавантажуватися
AJAX (Asynchronous JavaScript and XML) — це підхід, за якого сторінка обмінюється даними із сервером у фоні, без повного перезавантаження. Термін популяризував Джессі Джеймс Ґарретт у лютому 2005 року в есе «Ajax: A New Approach to Web Applications», але сама технологія з’явилася раніше — на базі об’єкта XMLHttpRequest (XHR), який спершу реалізували в Microsoft для Outlook Web Access (як ActiveX-об’єкт XMLHTTP в Internet Explorer 5), а згодом як стандартний об’єкт XMLHttpRequest підхопили інші браузери.
Ідея проста. JavaScript на сторінці ініціює HTTP-запит асинхронно: браузер виконує його у фоні, не блокуючи основний потік і не заморожуючи інтерфейс. Коли приходить відповідь (найчастіше вже не XML, а JSON), спрацьовує колбек, який оновлює лише потрібну частину DOM. Візуально сторінка не «моргає», користувач залишається на місці, а дані підвантажуються.
Приклад класичного сценарію: користувач вводить текст у поле пошуку, і під ним з’являються підказки — без перезавантаження.
GET /api/suggestions?q=play HTTP/1.1
Host: example.com
Accept: application/json
{
"items": [
{ "id": 1, "title": "Playwright" },
{ "id": 2, "title": "Playground" }
]
}
Чому це важливо для тестів. У момент, коли автотест «бачить» сторінку, потрібного контенту в DOM може ще не бути — він приїде окремим запитом за кілька сотень мілісекунд. Звідси головне правило: не можна вважати, що після відкриття URL сторінка повна. Треба чекати саме на цільовий елемент або на завершення конкретного запиту, а не на абстрактну «готовність».
fetch проти XMLHttpRequest
Сьогодні AJAX-запити роблять двома способами: старим XMLHttpRequest і сучасним fetch. Обидва живі: fetch — стандарт де-факто в новому коді, XHR досі трапляється в легасі та деяких бібліотеках (наприклад, axios за замовчуванням і досі використовує саме XHR у браузері, а fetch-адаптер додав пізніше як опцію).
XMLHttpRequest — це об’єкт із подієвою моделлю. Ви створюєте його, відкриваєте запит, підписуєтеся на зміни стану і надсилаєте.
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/user/42");
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) { // 4 = DONE
if (xhr.status === 200) {
const user = JSON.parse(xhr.responseText);
console.log(user);
}
}
};
xhr.send();
XHR має п’ять станів (readyState):
| readyState | Константа | Що означає |
|---|---|---|
| 0 | UNSENT | Об’єкт створено, open() ще не викликано |
| 1 | OPENED | Викликано open() |
| 2 | HEADERS_RECEIVED | Викликано send(), отримано заголовки й статус відповіді |
| 3 | LOADING | Тіло відповіді завантажується (у responseText — часткові дані) |
| 4 | DONE | Запит завершено (успішно чи з помилкою) |
fetch — сучасний API на основі промісів (Promise). Він компактніший і читабельніший:
const res = await fetch("/api/user/42");
if (res.ok) {
const user = await res.json();
console.log(user);
}
Ключові відмінності:
| Характеристика | XMLHttpRequest | fetch |
|---|---|---|
| Модель | Події (callbacks) | Проміси / async-await |
| Помилка HTTP (4xx, 5xx) | Обробляєте самі через status | Не відхиляє проміс; res.ok = false |
| Мережева помилка | Подія error | Проміс відхиляється (reject) |
| Скасування запиту | xhr.abort() | AbortController + signal |
| Таймаут | Властивість timeout + подія ontimeout | Немає вбудованого таймаута fetch; через AbortController (+ setTimeout або AbortSignal.timeout()) |
| Прогрес завантаження | Події progress (є з коробки, окремо для завантаження й відвантаження) | Через читання потоку тіла (складніше); прогресу відвантаження нема з коробки |
| Стрімінг тіла | Обмежено | Response.body як потік (ReadableStream) |
Одна деталь fetch регулярно ловить у пастку і розробників, і тестувальників: fetch не відхиляє проміс на HTTP-помилках. Відповідь зі статусом 404 чи 500 — це для fetch цілком успішний результат, лише з res.ok === false. Проміс відхиляється тільки коли запит не відбувся фізично (немає мережі, збій DNS, некоректний URL, блокування через CORS). Тому код, що перевіряє лише try/catch без перевірки res.ok, мовчки проґавить помилку сервера.
Скасування запиту через AbortController — механізм, який прямо стосується флаку. SPA часто скасовує застарілі запити (наприклад, при швидкому наборі в пошуку), і ваш тест може «зловити» гонитву (race), коли відповідь на попередній запит приходить після нового.
const controller = new AbortController();
const promise = fetch("/api/search?q=pl", { signal: controller.signal });
controller.abort(); // проміс відхиляється з DOMException на ім'я AbortError
Для автотестів обидва механізми важливі з погляду перехоплення мережі (network interception). Інструменти на кшталт Playwright чи Cypress уміють ловити ці запити незалежно від того, зроблені вони через XHR чи fetch — бо обидва в підсумку йдуть через мережевий стек браузера. Це дозволяє або дочекатися конкретної відповіді, або підмінити її (mock), щоб прибрати залежність від бекенда.
// Playwright: дочекатися конкретної відповіді замість "сліпого" очікування
const responsePromise = page.waitForResponse(
(r) => r.url().includes("/api/suggestions") && r.status() === 200
);
await page.getByRole("searchbox").fill("play");
await responsePromise;
MPA проти SPA
Асинхронність не з’явилася сама по собі — вона наслідок того, як побудовано застосунок. Тут два полюси: багатосторінковий застосунок (MPA, Multi-Page Application) і односторінковий (SPA, Single-Page Application).
MPA — це «класика». Кожен екран — окремий HTML-документ з окремим URL. Перехід між сторінками — це повне навігаційне перезавантаження: браузер викидає стару сторінку і будує нову з нуля. AJAX у MPA теж буває, але кістяк — це серверні сторінки.
SPA — це один HTML-документ, який завантажується раз, а далі JavaScript перемальовує вміст сам, підвантажуючи дані через AJAX. Переходи «між сторінками» насправді не перезавантажують документ — змінюється лише URL і вміст. Приклади фреймворків: React, Vue, Angular, Svelte.
| Аспект | MPA | SPA |
|---|---|---|
| Кількість HTML-документів | Багато (по одному на екран) | Один |
| Навігація | Повне перезавантаження | Без перезавантаження, JS міняє DOM |
| Де формується HTML | Переважно на сервері | Переважно в браузері (але не завжди) |
| Стан між екранами | Скидається при переході | Живе в пам’яті, накопичується |
Подія load при переході | Спрацьовує щоразу | Спрацьовує один раз, на старті |
Для тестування різниця принципова.
У MPA після кожного переходу браузер проходить повний цикл завантаження, тож дочекатися завершення навігації відносно просто — є чіткі навігаційні події.
У SPA все складніше з двох причин. По-перше, «перехід» не є справжньою навігацією, тож старі сигнали завантаження не спрацьовують — треба чекати на появу контенту. По-друге, стан не скидається: якщо в MPA кожен тест починав з умовно чистого аркуша, то в SPA дані з попередніх дій (кеш у пам’яті, відкриті модалки, накопичені елементи) можуть просочуватися в наступні кроки. Це підвищує вимоги до ізоляції стану: чиста сесія, скинуте сховище, свіжий контекст браузера між тестами.
Client-side rendering (CSR)
CSR (client-side rendering) — це коли HTML фактично генерується в браузері силами JavaScript. Сервер віддає майже порожній каркас, а весь контент домальовується вже на клієнті після завантаження й виконання скриптів.
Первинна відповідь сервера при чистому CSR виглядає приблизно так:
<!doctype html>
<html>
<head><title>App</title></head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
Зверніть увагу: у <body> немає контенту, лише порожній <div id="root"> і скрипт. Реальний інтерфейс з’явиться тільки після того, як браузер завантажить bundle.js, виконає його, той зробить AJAX-запити за даними й уже потім побудує DOM.
Наслідок для тестів очевидний і болючий: у перші миті після відкриття сторінки в DOM немає нічого корисного. Якщо тест (або скрапер, або перевірка через «сирий» HTTP-запит без виконання JS) подивиться на початковий HTML, він побачить порожнечу. Тому перевіряти вміст CSR-сторінки можна лише в браузері, який виконує JavaScript, і лише після того, як контент з’явився. Локатори мають чекати на елемент, а не звертатися до нього одразу.
Server-side rendering (SSR)
SSR (server-side rendering) — протилежний підхід: сервер сам виконує JavaScript-фреймворк, генерує готовий HTML з контентом і віддає його браузеру. Користувач (і пошуковий робот) бачить наповнену сторінку одразу, ще до того, як завантажиться клієнтський JS.
При SSR первинна відповідь містить справжній контент:
<!doctype html>
<html>
<body>
<div id="root">
<h1>Профіль користувача</h1>
<p>Ім'я: Олександр</p>
</div>
<script src="/bundle.js"></script>
</body>
</html>
SSR застосовують заради швидшого першого показу і кращого SEO. Але тут криється тонкість, яку легко не помітити: HTML із контентом ще не означає, що сторінка інтерактивна. Кнопки видно, але клік по них поки нічого не робить — обробники подій ще не «під’єднані». Це підводить нас до наступного поняття.
Hydration (гідратація)
Hydration (гідратація) — це процес, коли клієнтський JavaScript «оживляє» вже готовий SSR-HTML: проходить по наявній розмітці, під’єднує обробники подій, відновлює внутрішній стан компонентів. Після гідратації статична сторінка стає повноцінним SPA.
Ключовий момент: між появою видимого HTML і завершенням гідратації є вікно часу, коли елемент видно, але він ще не працює. Клік по кнопці в цей момент або нічого не зробить, або поведеться непередбачувано.
Саме гідратація — одна з найпідступніших причин флаку в тестах над SSR-застосунками. Типовий сценарій:
- Тест відкриває сторінку.
- Бачить кнопку (вона є в SSR-HTML) — перевірка видимості проходить.
- Клікає — а обробник ще не під’єднаний.
- Нічого не відбувається, наступний крок падає.
Найгірше те, що падіння плаваюче: на швидкій машині гідратація встигає, на завантаженому CI — ні.
Що з цим робити. Перевірка видимості (toBeVisible) недостатня для SSR — вона підтверджує наявність у розмітці, а не готовність до взаємодії. Надійніше чекати на ознаку інтерактивності: змінений стан кнопки (зникнення атрибута disabled), появу елемента, що рендериться лише на клієнті, або на конкретний маркер готовності застосунку. Автоочікування Playwright частково рятує — воно перевіряє, що елемент стабільний і приймає події, — але воно не знає про вашу гідратацію, тож повністю покладатися на нього не варто. Конкретний надійний сигнал завершення гідратації залежить від фреймворка (Next.js, Nuxt, SvelteKit тощо) і його версії; стандартного DOM-атрибута «гідратацію завершено» не існує, тож маркер готовності треба з’ясовувати або домовляти для свого стека.
Client-side routing: навігація без перезавантаження
У SPA перехід між «сторінками» реалізує клієнтський роутер (client-side routing). Замість того щоб дати браузеру перезавантажити документ, роутер перехоплює клік по посиланню, скасовує стандартну навігацію і сам змінює адресу та вміст.
Технічна основа — History API: методи history.pushState() і history.replaceState() міняють URL у рядку адреси без запиту на сервер, а подія popstate спрацьовує, коли користувач переміщується по історії — тисне «назад»/«вперед» (або через history.back()/forward()/go()). Важлива деталь: самі виклики pushState/replaceState подію popstate не генерують — вона реагує лише на переміщення по історії.
// Роутер міняє URL без перезавантаження
history.pushState({ page: "profile" }, "", "/profile");
// Далі JS сам домальовує потрібний екран у #root
// Реакція на кнопки браузера "назад"/"вперед"
window.addEventListener("popstate", (e) => {
render(e.state);
});
Історично для того самого використовували ще й «hash-роутинг» — зміну частини URL після #, яка не викликає запиту на сервер. Він досі трапляється в старих застосунках.
Наслідок для тестів. При такій навігації не спрацьовує подія load і немає повного циклу завантаження документа. Якщо ваш тест після кліку на пункт меню чекає на «завершення навігації» в стилі MPA, він або не дочекається нічого, або продовжить зарано. Правильний підхід — чекати одночасно на дві речі: зміну URL і появу контенту нового екрана.
// Playwright: після кліку чекаємо і на URL, і на контент
await page.getByRole("link", { name: "Профіль" }).click();
await expect(page).toHaveURL(/\/profile/);
await expect(page.getByRole("heading", { name: "Профіль" })).toBeVisible();
Ще одна пастка: у SPA URL може змінитися раніше, ніж приїдуть дані для нового екрана. Тобто адреса вже /profile, а на місці контенту ще скелетон. Тому перевірка тільки URL — оманлива; потрібна ще й перевірка реального вмісту.
Lazy loading і як він ламає тест
Lazy loading (відкладене завантаження) — це стратегія «не вантажити, поки не треба». Вона діє на кількох рівнях:
- Зображення та iframe: атрибут
loading="lazy"каже браузеру не завантажувати ресурс, поки він далеко за межами екрана. - Код: динамічний імпорт
import()підвантажує шматок JS лише коли він знадобився (наприклад, при відкритті модалки). Це «розбиття коду» (code splitting). - Дані та DOM-елементи: компоненти рендеряться лише коли потрапляють у видиму область, зазвичай через
IntersectionObserver.
// Елемент з'являється в DOM лише коли догорнули до нього
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadAndRender(entry.target); // тільки тепер контент реально малюється
}
});
});
observer.observe(placeholder);
Чому це ламає тести. Тест шукає елемент за локатором, але цього елемента фізично немає в DOM, бо до нього не догорнули. Автоочікування чесно чекає до таймауту й падає з «element not found» — хоча елемент цілком робочий, просто ще не створений. Класична хибна діагностика: «локатор неправильний», хоча насправді проблема в тому, що елемент лінивий.
Рішення — привести елемент у видиму область перед взаємодією. Playwright робить це автоматично при діях (click, fill прокручують до елемента), але при перевірках над лінивим контентом іноді треба прокрутити явно:
await page.getByRole("listitem", { name: "Товар 200" }).scrollIntoViewIfNeeded();
await expect(page.getByRole("listitem", { name: "Товар 200" })).toBeVisible();
Окремо про ліниві зображення: перевірка «картинка завантажилась» не зводиться до наявності <img> у DOM. Атрибут loading="lazy" означає, що поки елемент за межами екрана, файл ще не тягнеться. Якщо тест перевіряє фактичне завантаження (наприклад, naturalWidth > 0), його треба спершу догорнути.
Infinite scroll і складність тестування
Нескінченна прокрутка (infinite scroll) — окремий випадок лінивого завантаження, коли нові порції контенту довантажуються в міру того, як користувач гортає вниз. Це зручно для стрічок, але для тестів це один із найважчих патернів.
Складнощів кілька:
- Потрібного елемента ще немає. Щоб дійти до 200-го елемента, треба спровокувати завантаження всіх попередніх порцій — а це серія прокруток і очікувань, а не один крок.
- Віртуалізація (virtualization). Продуктивні списки часто тримають у DOM лише видимі елементи, а ті, що вгорі й унизу, викидають. Тобто елемент, який ви щойно бачили, може зникнути з DOM, коли ви догорнете далі. Перевірити «всі 200 елементів присутні в DOM» неможливо — їх там ніколи немає всіх одночасно.
- Немає чіткого «кінця». Тесту важко зрозуміти, чи це кінець списку, чи наступна порція просто ще не приїхала.
- Гонитви (races). Швидка прокрутка може відправити кілька запитів на порції одночасно, і порядок їхнього прибуття не гарантований.
Практичні підходи: прокручувати циклічно до появи цільового елемента з обмеженням на кількість спроб; там, де можливо, підмінити відповідь API фіктивним коротким списком (mock), щоб зробити сценарій детермінованим; для перевірки логіки віртуалізації — свідомо працювати лише з видимим вікном, а не з усім набором.
// Прокручуємо, поки не з'явиться потрібний елемент (з лімітом спроб)
const target = page.getByText("Товар 200");
for (let i = 0; i < 20 && !(await target.isVisible()); i++) {
await page.mouse.wheel(0, 2000);
await page.waitForTimeout(300); // компроміс; краще чекати на відповідь API
}
await expect(target).toBeVisible();
Фіксований waitForTimeout тут — вимушений компроміс і потенційне джерело флаку; де можливо, його варто замінити на очікування конкретної мережевої відповіді або на появу нових елементів.
Чому елемент з’являється із затримкою
Зведемо докупи всі причини, чому елемент, який «має бути на сторінці», з’являється не одразу. Розуміння причини диктує правильне очікування.
| Причина | Що відбувається | Правильна реакція в тесті |
|---|---|---|
| AJAX-запит за даними | Контент приїжджає окремим запитом після завантаження сторінки | Чекати на елемент або на відповідь API |
| CSR-рендеринг | JS має завантажитись і побудувати DOM | Чекати на цільовий елемент, не на порожній каркас |
| Гідратація | HTML видно, але події ще не під’єднані | Чекати на ознаку інтерактивності |
| Client-side routing | URL змінився, контент ще рендериться | Чекати і на URL, і на контент |
| Lazy loading | Елемента немає в DOM, поки не догорнули | Прокрутити у видиму область |
| Анімації/переходи | Елемент є, але ще прозорий або зсунутий | Чекати на стабільність (перевіряти автоочікуванням) |
| Debounce/throttle | Реакція навмисно відкладена (напр., пошук) | Чекати на відповідь або на результат, не на таймер |
Спільний висновок: майже кожна затримка — це асинхронна операція з невизначеним часом. Тому будь-яке очікування з фіксованим часом (sleep, waitForTimeout) — це або зайва повільність, або флак. Надійне очікування завжди прив’язане до умови (елемент з’явився, відповідь прийшла, стан змінився), а не до годинника.
Skeleton/loading-стани і правильне очікування
Щоб приховати затримки, застосунки показують проміжні стани: спінер (spinner), індикатор прогресу або скелетон (skeleton) — сірі плейсхолдери у формі майбутнього контенту. Для користувача це добре. Для тесту — ще одна пастка.
Пастка в тому, що скелетон теж є елементом DOM, і наївний локатор може зачепитися саме за нього. Наприклад, якщо ви шукаєте картку товару за структурою, а скелетон має схожу структуру, тест «побачить» скелетон і піде далі — а справжні дані ще не приїхали. Тому перевірка має бути прив’язана до реального контенту, який не може бути частиною плейсхолдера: конкретний текст, ціна, ім’я, а не просто «є блок потрібної форми».
Два надійні підходи до очікування:
- Чекати на появу реального контенту. Веб-first перевірки в Playwright роблять це самі — вони повторюють спробу, поки умова не стане істинною або не спливе таймаут.
// Перевірка повторюється, поки текст не з'явиться (скелетон її не задовольнить)
await expect(page.getByText("₴1 299")).toBeVisible();
- Чекати на зникнення індикатора завантаження. Іноді надійніше дочекатися, поки скелетон/спінер зникне.
await expect(page.getByTestId("skeleton")).toBeHidden();
await expect(page.getByRole("heading", { name: "Каталог" })).toBeVisible();
Важливо, що ці два підходи взаємодоповнювані, а не взаємозамінні. «Скелетон зник» без «контент з’явився» ризиковано, якщо між зникненням плейсхолдера і появою даних є короткий проміжок. Найнадійніше — перевіряти саме кінцевий, змістовний стан.
Тут проявляється фундаментальна перевага сучасних інструментів: автоочікування (auto-waiting) і веб-first перевірки (web-first assertions). Замість того щоб питати «елемент є прямо зараз?» (і отримати флак), Playwright і Cypress періодично перевіряють умову протягом таймауту. Playwright перед дією ще й перевіряє «дієздатність» (actionability): що елемент видимий, стабільний (не рухається), приймає події та (для відповідних дій) увімкнений і придатний до редагування — а сам локатор перед цим має розв’язатися в елемент, прикріплений до DOM. Це гасить більшість гонитв «за замовчуванням» — але, як зазначено вище, воно не знає про специфічні для вашого застосунку стани на кшталт гідратації чи довантаження даних.
Чи існує надійний універсальний сигнал «сторінка готова»
Коротка чесна відповідь: ні, універсального надійного сигналу не існує. Це, мабуть, найважливіший практичний висновок усього розділу.
Розгляньмо кандидатів, яких зазвичай пропонують, і чому кожен недостатній.
Подія DOMContentLoaded спрацьовує, коли браузер завершив розбір HTML і побудував початковий DOM, не чекаючи на завантаження зображень і решти підресурсів. Проблема: у CSR-застосунку в цей момент DOM ще порожній (є лише каркас і скрипт). Ознака «HTML розібрано» не означає «контент є».
Подія load спрацьовує, коли завантажилися сам документ і всі його підресурси (зображення, стилі, скрипти). Проблема подвійна: по-перше, AJAX-запити за даними йдуть уже після load, тож контенту все одно може не бути; по-друге, при client-side навігації в SPA ця подія взагалі не спрацьовує повторно.
document.readyState послідовно набуває значень loading → interactive → complete. Значення interactive настає безпосередньо перед подією DOMContentLoaded (HTML розібрано, відкладені скрипти й підресурси ще вантажаться), а complete — коли документ і всі підресурси завантажені й подія load ось-ось спрацює. Тобто найкорисніше для нас значення несе ті самі обмеження, що й load.
| Сигнал | Що гарантує | Чому недостатній для «готовності» |
|---|---|---|
DOMContentLoaded | HTML розібрано, початковий DOM є | У CSR контенту ще немає; дані приходять пізніше |
load | Документ і підресурси завантажені | AJAX-дані йдуть після; у SPA не повторюється |
readyState === "complete" | Те саме, що load | Ті самі обмеження |
| «Мережа затихла» (network idle) | Певний час не було запитів | Оманливо при поллінгу, вебсокетах, аналітиці; у Playwright не рекомендований для перевірок |
Окремо про «мережу затихла» (network idle) — стан, коли протягом якогось часу не було мережевих запитів. Спокуса використати його як «сторінка готова» велика, але він ненадійний: застосунок може постійно щось опитувати (polling), тримати вебсокет, довантажувати аналітику — і мережа ніколи не «затихне». І навпаки, мережа може затихнути в момент, коли дані ще обробляються в JS. Тому документація Playwright прямо позначає очікування networkidle як небажане (DISCOURAGED) і радить не використовувати його для тестів, а покладатися на веб-перевірки готовності (web assertions) — тобто на очікування конкретних елементів.
Що ж тоді використовувати. Оскільки «готовність» — поняття, специфічне для конкретного застосунку, надійний сигнал теж має бути специфічним і детермінованим:
- Чекати на появу конкретного змістовного елемента цільового екрана (найпоширеніший і найнадійніший підхід).
- Чекати на зникнення відомого індикатора завантаження (скелетон, спінер) у парі з появою контенту.
- Чекати на конкретну мережеву відповідь через перехоплення (
waitForResponse), коли контент прив’язаний до відомого запиту. - За домовленістю з розробниками — покладатися на явний маркер готовності, який застосунок сам виставляє (наприклад, атрибут на кореневому елементі чи прапорець у
windowпісля завершення ініціалізації). Це найнадійніший варіант, але він вимагає підтримки з боку застосунку.
Головний принцип, який лишається після всіх нюансів: очікуйте на конкретний, змістовний стан, а не на абстрактну «готовність сторінки». Тест має чекати рівно на те, що йому потрібно для наступного кроку — саме цей елемент, саме цю відповідь, саме цей стан. Тоді асинхронність із джерела флаку перетворюється на керовану, передбачувану поведінку.
Що таке AJAX і що він змінив у роботі веб-сторінки?
AJAX (Asynchronous JavaScript and XML) — це підхід, за якого сторінка обмінюється даними із сервером у фоні, без повного перезавантаження. Механізм: JavaScript ініціює HTTP-запит асинхронно, браузер виконує його, не блокуючи основний потік і не заморожуючи інтерфейс, а коли приходить відповідь (сьогодні найчастіше JSON, попри «XML» у назві), колбек оновлює лише потрібну частину DOM. Технічна основа — об'єкт XMLHttpRequest, який спершу з'явився в Microsoft для Outlook Web Access, а сам термін популяризував Джессі Джеймс Ґарретт у 2005 році. Класичний приклад — підказки під полем пошуку: користувач друкує, дані приїжджають окремим запитом, сторінка не «моргає». Для автотестів наслідок принциповий: після відкриття URL не можна вважати сторінку повною — потрібний контент може приїхати окремим запитом за кілька сотень мілісекунд, тож чекати треба на цільовий елемент або на завершення конкретного запиту, а не на абстрактну «готовність».
Чим SPA відрізняється від MPA?
MPA (Multi-Page Application) — це багато окремих HTML-документів: кожен екран має свій URL, а перехід між ними — повне навігаційне перезавантаження, коли браузер викидає стару сторінку й будує нову з нуля. SPA (Single-Page Application) — один HTML-документ, який завантажується раз, а далі JavaScript сам перемальовує вміст, підвантажуючи дані через AJAX; переходи міняють лише URL і DOM. Звідси ключові відмінності: у MPA подія load спрацьовує на кожному переході, у SPA — один раз на старті; у MPA стан скидається між екранами, у SPA він живе в пам'яті й накопичується. Для тестування різниця пряма: у MPA є чіткі навігаційні події, на які можна чекати, а в SPA «перехід» не є справжньою навігацією — треба чекати на появу контенту. Плюс SPA підвищує вимоги до ізоляції стану: кеш у пам'яті, відкриті модалки й накопичені елементи можуть просочуватися з попередніх кроків у наступні.
Що таке client-side rendering і що побачить перевірка «сирим» HTTP-запитом на CSR-сторінці?
CSR (client-side rendering) — це коли HTML фактично генерується в браузері силами JavaScript: сервер віддає майже порожній каркас — умовно <div id="root"> плюс тег скрипта — а весь контент домальовується вже на клієнті. Реальний інтерфейс з'являється лише після того, як браузер завантажить бандл, виконає його, той зробить AJAX-запити за даними й побудує DOM. Тому «сирий» HTTP-запит без виконання JavaScript (curl, скрапер, перевірка через API-клієнт) побачить порожнечу — і це не баг, а природа CSR. Практичний висновок для AQA: перевіряти вміст CSR-сторінки можна лише в браузері, який виконує JS, і лише після появи контенту — локатори мають чекати на елемент, а не звертатися до нього одразу після відкриття URL.
Що таке server-side rendering і навіщо його застосовують?
SSR (server-side rendering) — протилежність CSR: сервер сам виконує JavaScript-фреймворк, генерує готовий HTML з контентом і віддає його браузеру, тож користувач і пошуковий робот бачать наповнену сторінку одразу. Застосовують його заради двох речей: швидший перший показ контенту і краще SEO, бо роботу не треба виконувати JS, щоб побачити текст. Але є тонкість, яку сильний кандидат назве сам: HTML із контентом ще не означає, що сторінка інтерактивна — кнопки видно, а обробники подій ще не під'єднані, доки не відпрацює гідратація. Для тестів це означає, що перевірка видимості елемента на SSR-сторінці проходить раніше, ніж елемент реально готовий до взаємодії.
Чим fetch відрізняється від XMLHttpRequest?
Головна відмінність — модель: XMLHttpRequest побудований на подіях і колбеках (стани readyState від 0 до 4, підписка через onreadystatechange), а fetch — на промісах і async/await, тому код компактніший і читабельніший. Далі відмінності точкові: HTTP-помилки fetch не вважає збоєм (проміс не відхиляється, лише res.ok стає false), скасування у XHR — метод abort(), у fetch — AbortController із signal; таймаут у XHR вбудований, у fetch його немає — роблять через AbortController або AbortSignal.timeout(); прогрес завантаження XHR дає з коробки, у fetch це складніше — через читання потоку тіла, зате fetch уміє стрімити тіло відповіді як ReadableStream. Обидва механізми живі: fetch — стандарт де-факто в новому коді, XHR трапляється в легасі, і, наприклад, axios у браузері за замовчуванням досі використовує саме XHR. Для автотестів важливо, що перехоплення мережі в Playwright чи Cypress ловить обидва однаково — і XHR, і fetch ідуть через мережевий стек браузера, тож waitForResponse і моки працюють незалежно від того, чим написаний фронтенд.
Як fetch поводиться при відповіді 404 чи 500 і чому це типова пастка?
fetch не відхиляє проміс на HTTP-помилках: відповідь зі статусом 404 чи 500 для нього — цілком успішний результат, просто з res.ok === false. Проміс відхиляється тільки тоді, коли запит не відбувся фізично: немає мережі, збій DNS, некоректний URL або блокування через CORS. Пастка в тому, що код, який обгортає fetch у try/catch без перевірки res.ok, мовчки проґавить помилку сервера — інтерфейс просто не оновиться, без жодного винятку. Для тестувальника це подвійний висновок: по-перше, «мовчазні» баги фронтенду на серверних помилках — типова знахідка саме через це; по-друге, у тестах, які перехоплюють мережу, статус відповіді треба перевіряти явно, а не покладатися на те, що «помилка сама вилетить».
Чим DOMContentLoaded відрізняється від load і що показує document.readyState?
DOMContentLoaded спрацьовує, коли браузер завершив розбір HTML і побудував початковий DOM — не чекаючи на зображення та решту підресурсів. load спрацьовує пізніше — коли завантажилися сам документ і всі його підресурси: зображення, стилі, скрипти. document.readyState проходить три значення: loading, потім interactive (безпосередньо перед DOMContentLoaded), потім complete (документ і підресурси завантажені, load ось-ось спрацює). Сеньйорська частина відповіді — чому жоден із цих сигналів не гарантує «сторінка готова»: у CSR-застосунку на момент DOMContentLoaded DOM ще порожній (є лише каркас і скрипт), AJAX-запити за даними йдуть уже після load, а при client-side навігації в SPA подія load взагалі не спрацьовує повторно. Тому для тестів ці події — сигнали про стадії завантаження документа, а не про наявність контенту.
Як SPA реалізує навігацію без перезавантаження сторінки?
Перехід між «сторінками» у SPA робить клієнтський роутер: він перехоплює клік по посиланню, скасовує стандартну навігацію браузера і сам змінює адресу та вміст. Технічна основа — History API: history.pushState() і history.replaceState() міняють URL у рядку адреси без запиту на сервер, а далі JS робить AJAX-запит за даними й домальовує потрібний екран. Важлива деталь, на якій ловлять на співбесідах: подія popstate спрацьовує лише при переміщенні по історії — кнопки «назад»/«вперед» або history.back()/forward()/go() — а самі виклики pushState/replaceState її не генерують. Історично для того самого використовували hash-роутинг — зміну частини URL після #, яка не викликає запиту на сервер; він досі трапляється в старих застосунках. Для тестів наслідок один: повного циклу завантаження документа немає, подія load не спрацьовує, тож чекати треба на зміну URL і на появу контенту нового екрана, а не на «завершення навігації» в стилі MPA.
Після кліку на пункт меню в SPA URL уже новий — чи достатньо перевірити URL, щоб іти далі?
Ні, і це типове джерело флаку: у SPA URL може змінитися раніше, ніж приїдуть дані для нового екрана — адреса вже /profile, а на місці контенту ще скелетон. Роутер спершу викликає pushState, а вже потім іде AJAX-запит і рендер, тож між зміною адреси й появою даних є вікно. Правильний підхід — чекати одночасно на дві речі: зміну URL і появу змістовного елемента нового екрана. У Playwright це виглядає як пара перевірок: await expect(page).toHaveURL(/\/profile/) плюс await expect(page.getByRole("heading", { name: "Профіль" })).toBeVisible(). Перевірка тільки URL — оманлива; перевірка тільки контенту — робоча, але URL додатково підтверджує, що ви на правильному екрані, а не побачили схожий елемент деінде.
Що таке lazy loading і чому тест падає з «element not found», хоча елемент насправді робочий?
Lazy loading (відкладене завантаження) — стратегія «не вантажити, поки не треба», і діє вона на кількох рівнях: атрибут loading="lazy" для зображень та iframe, динамічний імпорт import() для шматків коду (code splitting) і рендер компонентів лише при потраплянні у видиму область — зазвичай через IntersectionObserver. Наслідок для тестів: елемента фізично немає в DOM, поки до нього не догорнули, тож автоочікування чесно чекає до таймауту й падає з «element not found». Класична хибна діагностика — «локатор неправильний», хоча локатор у порядку, просто елемент лінивий. Рішення — привести елемент у видиму область перед взаємодією: дії Playwright (click, fill) прокручують до елемента самі, але перед перевірками над лінивим контентом іноді треба явний scrollIntoViewIfNeeded(). Окрема деталь про ліниві картинки: наявність <img> у DOM не означає, що файл завантажено — поки елемент за межами екрана, файл не тягнеться, тож перевірку на кшталт naturalWidth > 0 треба робити після прокрутки до елемента.
Яка пастка зі скелетонами і спінерами та як правильно чекати на контент?
Пастка в тому, що скелетон — сірий плейсхолдер у формі майбутнього контенту — теж є елементом DOM, і наївний локатор, який шукає «блок потрібної форми», зачепиться саме за нього: тест «побачить» картку й піде далі, а справжні дані ще не приїхали. Тому перевірка має бути прив'язана до реального контенту, який не може бути частиною плейсхолдера: конкретний текст, ціна, ім'я. Надійних підходів два: чекати на появу реального контенту (веб-first перевірки на кшталт toBeVisible повторюють спробу до успіху чи таймауту) і чекати на зникнення індикатора завантаження (toBeHidden на скелетоні). Важливо, що вони взаємодоповнювані, а не взаємозамінні: «скелетон зник» без «контент з'явився» ризиковано, якщо між зникненням плейсхолдера й появою даних є проміжок. Найнадійніше — перевіряти кінцевий, змістовний стан екрана.
Чому фіксовані очікування типу sleep чи waitForTimeout — антипатерн і що використовувати замість них?
Майже кожна затримка на сучасній сторінці — це асинхронна операція з невизначеним часом: AJAX-запит, рендер, гідратація, анімація, debounce. Фіксований час не вгадаєш: якщо він більший за реальну затримку — тест зайво повільний, якщо менший (а на завантаженому CI затримки більші) — тест флакає. Надійне очікування завжди прив'язане до умови, а не до годинника: елемент з'явився, конкретна відповідь прийшла, стан змінився. Інструментальна підтримка — автоочікування та веб-first перевірки (Playwright перед дією перевіряє «дієздатність»: елемент видимий, стабільний, приймає події) плюс waitForResponse для очікування конкретного запиту. Чесний виняток — цикл прокрутки в нескінченному списку, де waitForTimeout між прокрутками іноді вимушений компроміс, але навіть там краще чекати на відповідь API або появу нових елементів.
Що таке hydration і чому клік по видимій кнопці на SSR-сторінці може нічого не зробити?
Hydration (гідратація) — процес, коли клієнтський JavaScript «оживляє» вже готовий SSR-HTML: проходить по наявній розмітці, під'єднує обробники подій, відновлює внутрішній стан компонентів; після гідратації статична сторінка стає повноцінним SPA. Між появою видимого HTML і завершенням гідратації є вікно, коли елемент видно, але він ще не працює — клік або нічого не зробить, або поведеться непередбачувано. Типовий сценарій флаку: тест відкриває сторінку, перевірка видимості кнопки проходить (вона є в SSR-HTML), тест клікає — а обробник ще не під'єднаний, і наступний крок падає. Найгірше, що падіння плаваюче: на швидкій машині гідратація встигає, на завантаженому CI — ні. Тому toBeVisible для SSR недостатня — вона підтверджує наявність у розмітці, а не готовність до взаємодії; надійніше чекати на ознаку інтерактивності: зникнення атрибута disabled, появу елемента, що рендериться лише на клієнті, або явний маркер готовності. Автоочікування Playwright частково рятує, але про вашу гідратацію воно не знає; стандартного DOM-атрибута «гідратацію завершено» не існує — надійний сигнал залежить від фреймворка й версії, тож його треба з'ясовувати або домовляти для свого стека.
Як SPA впливає на ізоляцію стану між тестами?
У MPA кожен перехід — повне перезавантаження, тож кожен тест починав з умовно чистого аркуша: стан скидався сам. У SPA документ живе весь час, і стан накопичується в пам'яті: кеш даних, відкриті модалки, накопичені в DOM елементи — усе це може просочуватися з попередніх дій у наступні кроки. Практичний наслідок — тести, які проходять поодинці, але падають у наборі, або навпаки: залежність від порядку виконання. Тому SPA підвищує вимоги до ізоляції: чиста сесія, скинуте сховище, свіжий контекст браузера між тестами. У Playwright це природно лягає на окремий browser context на тест — він дає чистий стан без перезапуску браузера.
Чому infinite scroll — один із найважчих патернів для автотестування?
Складнощів одразу кілька, і сильний кандидат перелічить їх системно. Перша: потрібного елемента ще немає — щоб дійти до 200-го елемента, треба спровокувати завантаження всіх попередніх порцій, а це серія прокруток і очікувань, не один крок. Друга: віртуалізація — продуктивні списки тримають у DOM лише видимі елементи й викидають ті, що вгорі та внизу, тож елемент, який ви щойно бачили, може зникнути з DOM, а перевірити «всі 200 елементів присутні» неможливо в принципі — їх там ніколи немає всіх одночасно. Третя: немає чіткого «кінця» — тесту важко відрізнити кінець списку від порції, яка просто ще не приїхала. Четверта: гонитви — швидка прокрутка може відправити кілька запитів одночасно з негарантованим порядком прибуття. Практичні підходи: циклічна прокрутка до появи цільового елемента з лімітом спроб; підміна відповіді API коротким фіктивним списком (mock), щоб зробити сценарій детермінованим; для перевірки самої віртуалізації — свідомо працювати лише з видимим вікном, а не з усім набором.
Чи існує універсальний сигнал «сторінка готова» і на що тоді чекати на практиці?
Чесна відповідь — ні, універсального надійного сигналу не існує, і це найважливіший практичний висновок теми. Кожен кандидат недостатній: DOMContentLoaded гарантує лише розібраний HTML — у CSR у цей момент DOM порожній; load гарантує документ і підресурси — але AJAX-дані йдуть після нього, а в SPA він не повторюється при client-side навігації; readyState === "complete" несе ті самі обмеження, що й load. Окремо network idle («мережа затихла»): він оманливий при поллінгу, вебсокетах і аналітиці — мережа може не затихнути ніколи, а може затихнути, коли дані ще обробляються в JS; документація Playwright прямо позначає networkidle як небажаний (DISCOURAGED) і радить веб-перевірки готовності. Оскільки «готовність» — поняття, специфічне для застосунку, надійний сигнал теж має бути специфічним і детермінованим: поява конкретного змістовного елемента цільового екрана (найпоширеніший підхід), зникнення відомого індикатора завантаження в парі з появою контенту, конкретна мережева відповідь через waitForResponse, або — за домовленістю з розробниками — явний маркер готовності, який застосунок сам виставляє після ініціалізації. Головний принцип: тест чекає рівно на те, що потрібно для наступного кроку — саме цей елемент, саме цю відповідь, саме цей стан, а не на абстрактну «готовність сторінки».
Три кейси, де асинхронність вирішує, зелений тест чи флак: перевірка CSR-контенту, який приїжджає після порожнього каркаса (і чому скелетон обманює наївний локатор), гідратаційна гонитва в SSR (кнопку видно, а клік у порожнечу) і нескінченна прокрутка з віртуалізацією, де цільового елемента просто немає в DOM. Скрізь — що дивитися і чому.
Кейс 1. CSR + скелетон: чекати на контент, а не на «мережу затихла»
Класична пастка на CSR-сторінці: перший HTML — це порожній каркас (<div id="root"> і скрипт), а справжні дані приїжджають окремим AJAX-запитом уже після load. Наївний тест «відкрив URL і читає DOM» бачить порожнечу або, гірше, чіпляється за скелетон — сірий плейсхолдер тієї ж форми, що й майбутня картка.
Перша спокуса — «почекати, поки мережа затихне». Так робити не варто: за поллінгу, вебсокета чи аналітики мережа не затихне ніколи, а Playwright прямо позначає це очікування як небажане (DISCOURAGED).
// ПОГАНО: networkidle оманливий і крихкий
await page.goto('https://shop.example.com/product/42', { waitUntil: 'networkidle' });
const title = await page.locator('.card__title').textContent(); // може бути зі скелетона
Надійно — прив'язатися до змістовного стану. Два взаємодоповнювані сигнали: зникнення скелетона й поява реального тексту, якого в плейсхолдері бути не може.
import { test, expect } from '@playwright/test';
test('картка товару відрендерилась після CSR', async ({ page }) => {
await page.goto('https://shop.example.com/product/42');
// 1) індикатор завантаження зник
await expect(page.getByTestId('skeleton')).toBeHidden();
// 2) з'явився саме контент — конкретна ціна, а не «блок потрібної форми»
await expect(page.getByText('₴1 299')).toBeVisible();
});
Що дивитися і чому:
waitUntil: 'load'/networkidleне гарантує контенту. AJAX-дані йдуть після подіїload, тож «сторінка завантажилась» ще не означає «дані є». Перевірка має чекати на елемент, а не на подію документа.- Скелетон теж є в DOM. Локатор за структурою (
.card__title) може розв'язатися в плейсхолдер і піти далі із сірим текстом. Асерт прив'язуйте до контенту, який не може бути в скелетоні: ціна, ім'я, конкретний рядок. - «Скелетон зник» без «контент з'явився» ризиковано. Між зникненням плейсхолдера й появою даних буває короткий проміжок. Найнадійніше — перевіряти кінцевий, змістовний стан; зникнення індикатора лише доповнює його.
Кейс 2. Гідратація: кнопку видно, а клік провалюється
SSR віддає готовий HTML з контентом, тож toBeVisible() на кнопці проходить одразу. Але між появою розмітки й завершенням гідратації (hydration) є вікно, коли елемент видно, а обробники подій ще не під'єднані. Клік у цей момент нічого не робить — і наступний крок падає. Найгірше, що падіння плаваюче: на швидкій машині гідратація встигає, на завантаженому CI — ні.
// ФЛАКУЄ на CI: видимість ≠ інтерактивність під час гідратації
await expect(page.getByRole('button', { name: 'Купити' })).toBeVisible();
await page.getByRole('button', { name: 'Купити' }).click(); // обробник ще не висить
await expect(page.getByText('Додано в кошик')).toBeVisible(); // зрідка падає
Стандартного DOM-атрибута «гідратацію завершено» не існує, тож надійний сигнал доводиться з'ясовувати або домовляти для свого стека. Найкращий варіант — явний маркер готовності від застосунку; за домовленістю з розробниками, скажімо, атрибут data-hydrated="true" на кореневому елементі.
import { test, expect } from '@playwright/test';
test('додавання в кошик після завершення гідратації', async ({ page }) => {
await page.goto('https://shop.example.com/product/42');
// чекаємо на ознаку інтерактивності, а не лише на видимість
await expect(page.locator('#root')).toHaveAttribute('data-hydrated', 'true');
await page.getByRole('button', { name: 'Купити' }).click();
await expect(page.getByText('Додано в кошик')).toBeVisible();
});
Що дивитися і чому:
toBeVisible()для SSR недостатньо. Вона підтверджує наявність у розмітці, а не готовність до взаємодії. Чекайте на ознаку інтерактивності: зникненняdisabled, появу суто клієнтського елемента або узгоджений маркер готовності.- Автоочікування Playwright не знає про вашу гідратацію. Перевірка «дієздатності» (actionability) гасить багато гонитв за замовчуванням, але вона не в курсі, що обробник ще не під'єднано; повністю покладатися на неї не варто.
- Конкретний сигнал залежить від фреймворка. Для Next.js, Nuxt, SvelteKit і різних версій він різний — універсального немає. Тому маркер готовності узгоджуйте з розробниками, а не вгадуйте.
Кейс 3. Infinite scroll + віртуалізація: елемента немає в DOM
Нескінченна прокрутка — окремий випадок лінивого завантаження: порції довантажуються, поки користувач гортає. Тут дві незалежні пастки. Перша: до 200-го елемента треба спровокувати завантаження всіх попередніх порцій — це серія прокруток, а не один крок. Друга: віртуалізований список тримає в DOM лише видиме вікно, а решту викидає, тож перевірка «всі 200 елементів присутні» неможлива — їх там ніколи немає всіх одночасно. Автоочікування чесно чекає до таймауту й падає з «element not found», хоча елемент робочий — його просто ще не створено.
Прокрутка «наосліп» із фіксованим waitForTimeout — вимушений компроміс і джерело флаку:
// КОМПРОМІС: таймер замість умови = зайва повільність або флак
const target = page.getByText('Товар 200');
for (let i = 0; i < 20 && !(await target.isVisible()); i++) {
await page.mouse.wheel(0, 2000);
await page.waitForTimeout(300); // краще чекати на відповідь API
}
await expect(target).toBeVisible();
Детерміновіше — підмінити відповідь API коротким фіктивним списком (mock), щоб цільовий елемент був у першій порції й прокрутка стала непотрібною:
import { test, expect } from '@playwright/test';
test('картка товару доступна на детермінованому наборі', async ({ page }) => {
// мок прибирає залежність від пагінації й гонитв між порціями
await page.route('**/api/products?page=1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [{ id: 200, title: 'Товар 200', price: '₴499' }] }),
})
);
await page.goto('https://shop.example.com/catalog');
// елемент у першому вікні — прокрутка й таймери не потрібні
await expect(page.getByRole('listitem').filter({ hasText: 'Товар 200' })).toBeVisible();
});
Що дивитися і чому:
- «element not found» ≠ «локатор неправильний». За лінивого/віртуалізованого списку елемента фізично немає в DOM, поки до нього не догорнули. Класична хибна діагностика — правити локатор, хоча правити треба стратегію очікування.
waitForTimeoutзамініть на умову. Фіксований таймер — це або повільно, або флак. Де можливо, чекайте на конкретну мережеву відповідь (waitForResponse) чи на появу нових елементів, а не на годинник.- Гонитви між порціями. Швидка прокрутка шле кілька запитів одразу, і порядок їхнього прибуття не гарантований. Мок відповіді робить сценарій детермінованим і прибирає цей клас нестабільності.
- Віртуалізацію перевіряйте свідомо у видимому вікні. Не намагайтеся асертити весь набір — його в DOM ніколи немає повністю; працюйте лише з тим, що зараз відрендерено.
AJAX, fetch і XHR
- Розумію, чому AJAX (asynchronous JavaScript and XML) прибрав повне перезавантаження: JS у фоні шле HTTP-запит, а колбек/проміс оновлює лише частину DOM, тож у момент відкриття URL цільового контенту в DOM може ще не бути.
- Знаю різницю
XMLHttpRequestvsfetch: XHR — події таreadyState(0→4), fetch — проміси/async-await; обидва живі, обидва йдуть через мережевий стек браузера, тож перехоплюються однаково. - Можу пояснити головну пастку
fetch: він не відхиляє проміс на HTTP 4xx/5xx — це успіх ізres.ok === false; reject лише при фізичному збої (нема мережі, DNS, CORS). Томуtry/catchбез перевіркиres.okмовчки проґавить помилку сервера. - Знаю, що скасування запиту (
xhr.abort()/AbortController+signal) прямо стосується флаку: SPA рубає застарілі запити при швидкому наборі, і тест може зловити гонитву, коли стара відповідь приходить після нової. - Розумію, чому для тесту надійніше дочекатися конкретної відповіді (
waitForResponse) чи підмінити її (mock), ніж чекати «сліпо».
MPA/SPA і CSR/SSR
- Можу пояснити MPA vs SPA: MPA — багато HTML-документів, навігація = повне перезавантаження, стан скидається; SPA — один документ, JS перемальовує DOM, стан живе в памʼяті й накопичується.
- Розумію наслідок SPA для тестів: «перехід» не є справжньою навігацією (старі сигнали завантаження не спрацьовують → чекати на контент), а непорожній стан підвищує вимоги до ізоляції (чиста сесія, скинуте сховище, свіжий контекст).
- Знаю різницю CSR vs SSR: при CSR сервер віддає порожній каркас (
<div id="root">+ скрипт), контент малює JS у браузері; при SSR сервер сам генерує готовий HTML із контентом. - Розумію, чому CSR-сторінку не можна перевіряти «сирим» HTTP-запитом без JS: у початковому HTML немає нічого корисного, локатор мусить чекати на елемент.
Гідратація і клієнтський роутинг
- Можу пояснити hydration: клієнтський JS «оживляє» готовий SSR-HTML — підʼєднує обробники, відновлює стан; між появою видимого HTML і кінцем гідратації є вікно, коли елемент видно, але клік ще нічого не робить.
- Розумію, чому
toBeVisibleнедостатня для SSR (підтверджує розмітку, а не готовність), чому падіння плаваюче (швидка машина встигає, навантажений CI — ні) і що надійного стандартного DOM-атрибута «гідратацію завершено» не існує — маркер залежить від фреймворка. - Знаю основу client-side routing: History API (
pushState/replaceStateміняють URL без запиту на сервер), аpopstateреагує лише на переміщення по історії — самі викликиpushState/replaceStateїї не генерують. - Розумію, чому при клієнтській навігації подія
loadне спрацьовує і чому чекати треба одночасно на дві речі: зміну URL і появу контенту (URL може змінитися раніше, ніж приїдуть дані — на місці контенту ще скелетон).
Lazy loading, infinite scroll, віртуалізація
- Можу пояснити рівні lazy loading:
loading="lazy"для зображень/iframe, динамічнийimport()(code splitting) для коду,IntersectionObserverдля DOM-елементів — елемента фізично немає в DOM, поки до нього не догорнули. - Розумію класичну хибну діагностику «локатор неправильний», хоча елемент просто лінивий; рішення — привести його у видиму область (
scrollIntoViewIfNeeded) перед перевіркою. - Знаю, чому infinite scroll — один із найважчих патернів: цільового елемента ще нема, віртуалізація тримає в DOM лише видиме вікно (всіх N одночасно там ніколи немає), немає чіткого «кінця», можливі гонитви порцій.
- Розумію, чому фіксований
waitForTimeout/sleep— вимушений компроміс і джерело флаку; де можливо, його замінюють на очікування конкретної мережевої відповіді або появи нових елементів.
Очікування «готовності» сторінки
- Знаю, чому скелетон/спінер — пастка: скелетон теж є елементом DOM зі схожою структурою, тож перевірка має чіплятися за реальний змістовний контент (текст, ціну, імʼя), а не за «блок потрібної форми».
- Розумію взаємодоповнюваність двох підходів: «контент зʼявився» і «індикатор зник» — «скелетон зник» без «контент є» ризиковано; найнадійніше перевіряти кінцевий змістовний стан.
- Можу пояснити auto-waiting і web-first assertions: інструмент періодично перевіряє умову протягом таймауту; Playwright перед дією ще й перевіряє actionability (видимий, стабільний, приймає події, увімкнений), але про гідратацію чи довантаження даних він не знає.
- Розумію, чому надійного універсального сигналу «сторінка готова» не існує:
DOMContentLoaded(у CSR DOM ще порожній),load/readyState==="complete"(AJAX-дані йдуть після, у SPA не повторюється),networkidle(оманливий при polling/websocket/аналітиці, у Playwright DISCOURAGED). - Знаю правильну заміну: чекати на конкретний змістовний елемент, на зникнення відомого індикатора в парі з появою контенту, на конкретну відповідь через
waitForResponseабо на явний маркер готовності за домовленістю з розробниками — тобто на умову, а не на годинник.
Тест відкрив URL CSR-застосунку і одразу шукає заголовок каталогу, але падає з «element not found». Чому?
Питання
Що таке AJAX і чому після відкриття URL не можна вважати сторінку повною?