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

    08 · JavaScript/TypeScript для AQA

    Асинхронність: Promises, async/await і паралельність

    Зміст

    Майже кожен рядок автотесту асинхронний. Клік по кнопці, навігація на сторінку, запит до API, очікування елемента — усе це не виконується миттєво, а повертає обіцянку майбутнього результату. Тому питання «як влаштована асинхронність у JavaScript» — це не академія, а прямий шлях до розуміння, чому тест «пробігає повз» перевірку, падає через раз або зеленіє там, де насправді баг.

    Найдорожча помилка початківця в автоматизації одна: забутий await. Без нього тест не чекає на результат, летить далі, а перевірка або не спрацьовує зовсім, або спрацьовує на старому стані сторінки. Виглядає це як флак (flaky) чи «магія», а насправді — нерозуміння того, що функція повертає Promise, а не готове значення. Ця глава — про механізм, який стоїть за await, і про пастки, у які АQA потрапляють щодня. Механіку самого event loop докладно розібрано в главі «Виконання JavaScript та event loop»; тут — короткий recap рівно там, де він потрібен.

    Від колбеків до callback hell

    Історично асинхронність у JS будувалася на колбеках (callback) — функціях, які передаєш «на потім», щоб їх викликали, коли результат готовий.

    readFile('config.json', (err, data) => {
      if (err) return handle(err);
      parse(data, (err, cfg) => {
        if (err) return handle(err);
        connect(cfg, (err, db) => {
          if (err) return handle(err);
          // ...і так углиб
        });
      });
    });

    Проблем тут дві. Перша — «піраміда судного дня» (pyramid of doom): кожна залежна операція додає рівень вкладеності, код розповзається вправо й стає нечитабельним. Друга, важливіша, — інверсія контролю: ти віддаєш свою функцію чужому коду й покладаєшся на те, що він викличе її рівно один раз і коректно передасть помилку. Обробку помилок доводиться дублювати на кожному рівні (if (err)), і жоден try/catch навколо цього не спрацює — колбек викликається пізніше, коли зовнішній блок уже завершився. Саме ці болі й розв'язали Promise.

    Promise: три стани і ланцюжок

    Promise (обіцянка) — це об'єкт, який представляє результат асинхронної операції, ще не відомий у момент створення. Він завжди в одному з трьох станів:

    • pending (очікування) — операція триває, результату ще немає;
    • fulfilled (виконано) — операція успішна, є значення;
    • rejected (відхилено) — операція провалилась, є причина (зазвичай Error).

    Ключова властивість: перехід зі стану pending відбувається рівно один раз і назавжди. Promise, який уже виконався чи відхилився, називають settled (залагоджений) — його результат заморожений і більше не зміниться. Це прибирає цілий клас багів «колбек викликали двічі».

    new Promise()

    resolve(value)

    reject(reason)

    settled

    settled

    pending

    fulfilled

    rejected

    new Promise()

    resolve(value)

    reject(reason)

    settled

    settled

    pending

    fulfilled

    rejected

    Читають результат трьома методами:

    • .then(onFulfilled, onRejected) — реагує на успіх (і, за бажанням, на помилку). Повертає новий Promise, тому виклики зчіплюються в ланцюг.
    • .catch(onRejected) — ловить відхилення в будь-якому місці ланцюга вище. Одного .catch наприкінці досить, щоб перехопити помилку з будь-якого кроку.
    • .finally(onFinally) — виконується завжди, незалежно від результату; сюди кладуть прибирання (закрити з'єднання, зняти лоадер).
    fetchUser(id)
      .then(user => fetchOrders(user.id))
      .then(orders => render(orders))
      .catch(err => log(err))     // зловить помилку з будь-якого .then вище
      .finally(() => hideSpinner());

    Два нюанси, на яких валяться. Перший: .then повертає новий Promise — якщо всередині повернути ще один Promise, ланцюг «розгорнеться» й дочекається його (це називають chaining). Другий: помилка, кинута в колбеку, не «випадає» назовні, а переводить наступний Promise у стан rejected — тому й ловиться через .catch, а не через звичайний try/catch.

    async/await

    async/await — це синтаксичний цукор над Promise, який дає писати асинхронний код так, ніби він синхронний. Під капотом усе ті самі Promise, але без ланцюгів .then.

    async function loadDashboard(id: string) {
      try {
        const user = await fetchUser(id);
        const orders = await fetchOrders(user.id);
        return render(orders);
      } catch (err) {
        log(err);
      } finally {
        hideSpinner();
      }
    }

    Три факти, які треба тримати в голові:

    1. async-функція завжди повертає Promise. Навіть якщо ти повертаєш звичайне число — воно автоматично «загорнеться» у виконаний Promise. Тому результат async-функції теж треба await-ити.
    2. await призупиняє функцію, а не потік. Він чекає, поки Promise залагодиться, і віддає його значення (або кидає помилку, якщо rejected). Поки функція «спить» на await, рушій вільний виконувати інший код — нічого не блокується.
    3. Помилки ловляться звичайним try/catch. Це головна ергономічна перевага над колбеками: rejected-Promise усередині await перетворюється на виняток, який ловить нормальний catch.

    Саме через async/await написано практично весь код Playwright і CodeceptJS. У Playwright майже кожна дія — await page.click(...), await expect(locator).toBeVisible(). У CodeceptJS ланцюг I.* рекордер тримає внутрішньо, тож там await руками пишуть рідше — але механіка Promise під ним та сама.

    Послідовно чи паралельно: await у циклі vs Promise.all

    Найпоширеніша помилка продуктивності — await у циклі там, де операції незалежні.

    // Послідовно: 10 запитів по 200 мс = ~2 секунди
    const users = [];
    for (const id of ids) {
      users.push(await createUser(id));   // кожен чекає на попередній
    }

    Якщо createUser(id) не залежить від попереднього результату, цей код марно складає затримки одну за одною. Правильно — запустити всі одразу й дочекатися разом через Promise.all:

    // Паралельно: усі 10 стартують разом = ~200 мс
    const users = await Promise.all(ids.map(id => createUser(id)));

    Promise.all приймає масив Promise і повертає один Promise, що виконається, коли виконаються всі — з масивом результатів у тому самому порядку. Це класика підготовки тестових даних: насидати десять акаунтів паралельно замість того, щоб чекати кожен по черзі.

    Коли ж лишати послідовність? Коли крок справді залежить від попереднього (спершу створити користувача, потім — його замовлення за отриманим id) або коли важливий порядок побічних ефектів. Орієнтир простий:

    Так

    Ні

    Всі або нічого

    Зібрати і успіхи, і збої

    Багато async-операцій

    Крок N залежить
    від результату N-1?

    Послідовно:
    for...of з await

    Паралельно:
    масив у Promise.all

    Потрібні всі,
    чи терпимо часткові збої?

    Promise.all

    Promise.allSettled

    Так

    Ні

    Всі або нічого

    Зібрати і успіхи, і збої

    Багато async-операцій

    Крок N залежить
    від результату N-1?

    Послідовно:
    for...of з await

    Паралельно:
    масив у Promise.all

    Потрібні всі,
    чи терпимо часткові збої?

    Promise.all

    Promise.allSettled

    Обережно з паралельністю проти спільного ресурсу: сто одночасних запитів можуть упертися в rate limit або перевантажити стенд. Тоді потрібен ліміт паралельності (пул) — типова задача live coding.

    Комбінатори: all / allSettled / race / any

    Чотири статичні методи Promise розв'язують різні задачі — плутати їх на співбесіді не варто.

    МетодВиконається (fulfilled), колиВідхилиться (rejected), колиРезультат
    Promise.allусі виконалисьперший відхилився (fail-fast)масив значень
    Promise.allSettledзавжди, коли всі залагодилисьніколимасив статусів кожного
    Promise.raceперший, що залагодився, — успіхперший, що залагодився, — відхиленнязначення/причина першого
    Promise.anyперший виконався успішнолише коли всі відхилилисьзначення першого успішного

    Деталі, які й перевіряють:

    • Promise.all — fail-fast. Одне відхилення валить увесь all, а решта результатів губиться (самі операції не скасовуються, просто їхні значення нікуди не потраплять). Якщо треба дочекатися всіх і зібрати як успіхи, так і збої — бери allSettled.
    • Promise.allSettled (ES2020) ніколи не відхиляється. Для кожного елемента повертає об'єкт статусу: або зі значенням, або з причиною. Ідеально, коли запускаєш пачку незалежних перевірок і хочеш зібрати повну картину, а не впасти на першій.
    • Promise.race залагоджується першим-ліпшим результатом — байдуже, успіх це чи помилка. Класичне застосування — таймаут: гонка «корисна операція проти Promise, що відхиляється через N секунд».
    • Promise.any (ES2021) чекає першого успішного; відхиляється лише якщо провалились геть усі — тоді причиною буде AggregateError зі списком усіх помилок.

    Мікрозадачі vs макрозадачі: короткий recap

    Повний розбір — у главі «Виконання JavaScript та event loop», тут лише те, що впливає на тести. Асинхронні задачі стоять у двох різних чергах. Колбеки Promise (.then, продовження після await, queueMicrotask) — це мікрозадачі. setTimeout, setInterval, події вводу — макрозадачі.

    Правило порядку: після кожної синхронної ділянки рушій спорожняє всю чергу мікрозадач, і лише потім бере одну макрозадачу. Тобто .then виконається раніше за setTimeout(fn, 0), навіть якщо той стоїть у коді вище.

    console.log('1');
    setTimeout(() => console.log('2'), 0);   // макрозадача
    Promise.resolve().then(() => console.log('3'));  // мікрозадача
    console.log('4');
    // Порядок виводу: 1, 4, 3, 2

    Практичний наслідок для AQA: await віддає керування event loop, тож між двома await можуть «прослизнути» інші мікрозадачі й оновлення стану. Це нормально — але саме тому не можна вважати, ніби рядки виконуються «строго один за одним без пауз».

    Типові помилки

    Виглядає як робоча перевірка — насправді floating promise. Забутий await перед асинхронним викликом лишає «плаваючий» Promise: код летить далі, не дочекавшись результату.

    expect(page.getByRole('button')).toBeVisible();  // ← немає await

    У Playwright web-first assertion повертає Promise з вбудованими ретраями. Без await тест не дочікується цієї перевірки, ретраї не встигають відпрацювати, і тест «зеленіє» завжди — навіть коли кнопки немає. Той самий симптом ловить eslint-правило @typescript-eslint/no-floating-promises, і його варто ввімкнути в тестовому проєкті.

    Виглядає як послідовна обробка з await — насправді forEach ігнорує проміси. Класична пастка:

    ids.forEach(async (id) => {
      await deleteUser(id);   // forEach НЕ чекає на ці проміси
    });
    // код нижче виконається ДО того, як хоч один користувач видалиться

    Array.forEach не дивиться на значення, яке повертає колбек, тож усі async-колбеки стартують «у повітря», а зовнішній код не чекає на них. Треба for...of з await (послідовно) або await Promise.all(ids.map(...)) (паралельно). Те саме стосується map/filter з async-колбеком: вони повертають масив Promise, а не масив значень.

    Виглядає як фікс флаку — насправді hard wait. Спокуса «полагодити» плаваючий тест жорсткою затримкою (await page.waitForTimeout(3000), sleep) — антипатерн. Фіксована пауза або марно гальмує зелений прогін, або все одно падає під навантаженням: ти вгадуєш час, замість чекати на подію. Правильно — очікувати конкретну умову (елемент видимий, відповідь прийшла). Механіку таких очікувань розібрано в главі «Практичні сценарії AQA: флак і синхронізація»; засоби самого інструмента (auto-waiting, web-first assertions, expect.poll) — тема глави про перевірки Playwright у розділі про інструменти автоматизації.

    Виглядає як обробка помилки — насправді проковтнутий reject. Якщо в ланцюгу забути повернути (return) або await-нути вкладений Promise, його відхилення нікуди не потрапить і стане unhandled rejection. Кожен асинхронний виклик у тесті має бути або await-нутий, або свідомо повернутий далі по ланцюгу.

    Підсумок

    • Promise має рівно три стани (pendingfulfilled або rejected) і залагоджується (settled) один раз назавжди; .then/.catch/.finally будують ланцюг, у якому помилка «стікає» до найближчого .catch.
    • async-функція завжди повертає Promise, а await призупиняє функцію (не потік) до залагодження й перетворює reject на звичайний виняток для try/catch.
    • await у циклі — послідовно (затримки складаються); незалежні операції запускай через Promise.all; для «зібрати всі результати попри збої» — Promise.allSettled.
    • all — fail-fast, allSettled — ніколи не падає, race — перший будь-який, any — перший успішний.
    • Забутий await (floating promise) і async у forEach — головні причини фальшиво-зелених і плаваючих тестів; hard wait лікує симптом, а не причину.

    Що питають на співбесіді

    • «Які стани має Promise і чи можна їх змінити після залагодження?» — інтерв'юер перевіряє, чи ти розумієш незмінність settled-стану, а не просто завчив три слова.
    • «Чим Promise.all відрізняється від Promise.allSettled? А race від any?» — типове питання на знання поведінки при частковому збої; сильна відповідь одразу називає, хто fail-fast, а хто ні.
    • «Чому цей код з forEach і await не працює?» — практична пастка; дивляться, чи бачиш ти, що forEach ігнорує повернуті проміси, і чи запропонуєш for...of або Promise.all.
    • «Що виведе код зі setTimeout і .then?» — перевірка розуміння мікро- проти макрозадач; достатньо знати, що вся черга мікрозадач спорожняється перед наступною макрозадачею.
    • «Чому в автотестах не можна ставити фіксований sleep?» — очікують, що поясниш різницю між очікуванням часу й очікуванням події та назвеш auto-waiting як правильну альтернативу.

    Джерела

    • MDN — Using promises (ланцюги, обробка помилок, послідовність vs паралельність)
    • MDN — Promise (стани й статичні методи all/allSettled/race/any)
    • MDN — async function
    • Playwright — Auto-waiting і Assertions (чому await перед expect обов'язковий)
    • typescript-eslint — no-floating-promises (автоматичний захист від забутого await)