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

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

    Асинхронне завантаження: 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" }
      ]
    }
    СерверJavaScriptКористувачСерверJavaScriptКористувачІнтерфейс не блокуєтьсяВводить "play" у поле пошукуGET /api/suggestions?q=play (у фоні)200 OK, JSON зі списком підказокОновлює лише список під полемСерверJavaScriptКористувачСерверJavaScriptКористувачІнтерфейс не блокуєтьсяВводить "play" у поле пошукуGET /api/suggestions?q=play (у фоні)200 OK, JSON зі списком підказокОновлює лише список під полем

    Чому це важливо для тестів. У момент, коли автотест «бачить» сторінку, потрібного контенту в 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КонстантаЩо означає
    0UNSENTОб’єкт створено, open() ще не викликано
    1OPENEDВикликано open()
    2HEADERS_RECEIVEDВикликано send(), отримано заголовки й статус відповіді
    3LOADINGТіло відповіді завантажується (у responseText — часткові дані)
    4DONEЗапит завершено (успішно чи з помилкою)

    fetch — сучасний API на основі промісів (Promise). Він компактніший і читабельніший:

    const res = await fetch("/api/user/42");
    if (res.ok) {
      const user = await res.json();
      console.log(user);
    }

    Ключові відмінності:

    ХарактеристикаXMLHttpRequestfetch
    МодельПодії (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.

    АспектMPASPA
    Кількість 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-застосунками. Типовий сценарій:

    1. Тест відкриває сторінку.
    2. Бачить кнопку (вона є в SSR-HTML) — перевірка видимості проходить.
    3. Клікає — а обробник ще не під’єднаний.
    4. Нічого не відбувається, наступний крок падає.
    Клієнтський JSSSR-HTMLТестКлієнтський JSSSR-HTMLТестГідратацію ще не завершеноВідкриває сторінкуКнопку видно (перевірка видимості проходить)Клікає по кнопціОбробник не під'єднаний — нічого не сталосяПізніше під'єднує обробники подійКлієнтський JSSSR-HTMLТестКлієнтський JSSSR-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);
    });
    СерверКлієнтський роутерКористувачСерверКлієнтський роутерКористувачСтандартну навігацію скасованоКлік по посиланню "Профіль"history.pushState('/profile') — URL зміненоAJAX-запит за даними екранаДані профілюДомальовує екран без перезавантаженняСерверКлієнтський роутерКористувачСерверКлієнтський роутерКористувачСтандартну навігацію скасованоКлік по посиланню "Профіль"history.pushState('/profile') — URL зміненоAJAX-запит за даними екранаДані профілюДомальовує екран без перезавантаження

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

    Складнощів кілька:

    1. Потрібного елемента ще немає. Щоб дійти до 200-го елемента, треба спровокувати завантаження всіх попередніх порцій — а це серія прокруток і очікувань, а не один крок.
    2. Віртуалізація (virtualization). Продуктивні списки часто тримають у DOM лише видимі елементи, а ті, що вгорі й унизу, викидають. Тобто елемент, який ви щойно бачили, може зникнути з DOM, коли ви догорнете далі. Перевірити «всі 200 елементів присутні в DOM» неможливо — їх там ніколи немає всіх одночасно.
    3. Немає чіткого «кінця». Тесту важко зрозуміти, чи це кінець списку, чи наступна порція просто ще не приїхала.
    4. Гонитви (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 routingURL змінився, контент ще рендеритьсяЧекати і на URL, і на контент
    Lazy loadingЕлемента немає в DOM, поки не догорнулиПрокрутити у видиму область
    Анімації/переходиЕлемент є, але ще прозорий або зсунутийЧекати на стабільність (перевіряти автоочікуванням)
    Debounce/throttleРеакція навмисно відкладена (напр., пошук)Чекати на відповідь або на результат, не на таймер

    Спільний висновок: майже кожна затримка — це асинхронна операція з невизначеним часом. Тому будь-яке очікування з фіксованим часом (sleep, waitForTimeout) — це або зайва повільність, або флак. Надійне очікування завжди прив’язане до умови (елемент з’явився, відповідь прийшла, стан змінився), а не до годинника.

    Skeleton/loading-стани і правильне очікування

    Щоб приховати затримки, застосунки показують проміжні стани: спінер (spinner), індикатор прогресу або скелетон (skeleton) — сірі плейсхолдери у формі майбутнього контенту. Для користувача це добре. Для тесту — ще одна пастка.

    Пастка в тому, що скелетон теж є елементом DOM, і наївний локатор може зачепитися саме за нього. Наприклад, якщо ви шукаєте картку товару за структурою, а скелетон має схожу структуру, тест «побачить» скелетон і піде далі — а справжні дані ще не приїхали. Тому перевірка має бути прив’язана до реального контенту, який не може бути частиною плейсхолдера: конкретний текст, ціна, ім’я, а не просто «є блок потрібної форми».

    Два надійні підходи до очікування:

    1. Чекати на появу реального контенту. Веб-first перевірки в Playwright роблять це самі — вони повторюють спробу, поки умова не стане істинною або не спливе таймаут.
    // Перевірка повторюється, поки текст не з'явиться (скелетон її не задовольнить)
    await expect(page.getByText("₴1 299")).toBeVisible();
    1. Чекати на зникнення індикатора завантаження. Іноді надійніше дочекатися, поки скелетон/спінер зникне.
    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 послідовно набуває значень loadinginteractivecomplete. Значення interactive настає безпосередньо перед подією DOMContentLoaded (HTML розібрано, відкладені скрипти й підресурси ще вантажаться), а complete — коли документ і всі підресурси завантажені й подія load ось-ось спрацює. Тобто найкорисніше для нас значення несе ті самі обмеження, що й load.

    СигналЩо гарантуєЧому недостатній для «готовності»
    DOMContentLoadedHTML розібрано, початковий DOM єУ CSR контенту ще немає; дані приходять пізніше
    loadДокумент і підресурси завантаженіAJAX-дані йдуть після; у SPA не повторюється
    readyState === "complete"Те саме, що loadТі самі обмеження
    «Мережа затихла» (network idle)Певний час не було запитівОманливо при поллінгу, вебсокетах, аналітиці; у Playwright не рекомендований для перевірок

    Окремо про «мережу затихла» (network idle) — стан, коли протягом якогось часу не було мережевих запитів. Спокуса використати його як «сторінка готова» велика, але він ненадійний: застосунок може постійно щось опитувати (polling), тримати вебсокет, довантажувати аналітику — і мережа ніколи не «затихне». І навпаки, мережа може затихнути в момент, коли дані ще обробляються в JS. Тому документація Playwright прямо позначає очікування networkidle як небажане (DISCOURAGED) і радить не використовувати його для тестів, а покладатися на веб-перевірки готовності (web assertions) — тобто на очікування конкретних елементів.

    Що ж тоді використовувати. Оскільки «готовність» — поняття, специфічне для конкретного застосунку, надійний сигнал теж має бути специфічним і детермінованим:

    • Чекати на появу конкретного змістовного елемента цільового екрана (найпоширеніший і найнадійніший підхід).
    • Чекати на зникнення відомого індикатора завантаження (скелетон, спінер) у парі з появою контенту.
    • Чекати на конкретну мережеву відповідь через перехоплення (waitForResponse), коли контент прив’язаний до відомого запиту.
    • За домовленістю з розробниками — покладатися на явний маркер готовності, який застосунок сам виставляє (наприклад, атрибут на кореневому елементі чи прапорець у window після завершення ініціалізації). Це найнадійніший варіант, але він вимагає підтримки з боку застосунку.

    Головний принцип, який лишається після всіх нюансів: очікуйте на конкретний, змістовний стан, а не на абстрактну «готовність сторінки». Тест має чекати рівно на те, що йому потрібно для наступного кроку — саме цей елемент, саме цю відповідь, саме цей стан. Тоді асинхронність із джерела флаку перетворюється на керовану, передбачувану поведінку.