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) — це мікрозадачі (microtasks). setTimeout, setInterval, події вводу — задачі (tasks) з черги задач.

    Про терміни. У статтях і на співбесідах цю чергу часто називають чергою «макрозадач» (macrotask) — щоб симетрично протиставити мікрозадачам. Усталеної галузевої назви тут немає: слова macrotask не вживають ні MDN, ні HTML Living Standard. MDN пише, що подієвий цикл ділить роботу на задачі (tasks) і мікрозадачі (microtasks). Термін варто розпізнавати на слух, але не вживати — далі в главі «задача» і «мікрозадача».

    Правило порядку: після кожної синхронної ділянки JS-рушій спорожняє всю чергу мікрозадач, і лише потім бере з черги задач наступну. Тобто .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 як правильну альтернативу.

    Джерела

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

    • MDN — Using promises (JavaScript Guide) — проміс як інверсія контролю проти колбеків, гарантії ланцюга й чотири інструменти композиції, що запускають операції одночасно.

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

    async/await

    • MDN — async functionasync завжди повертає проміс, await призупиняє функцію й повертає звичайний try/catch навколо асинхронного коду.
    • MDN — Using promises (JavaScript Guide) — проміс як інверсія контролю проти колбеків, гарантії ланцюга й чотири інструменти композиції, що запускають операції одночасно.
    • Playwright — Test assertions (expect.poll, toPass) з автоповтором повертають проміс, тож await перед expect обовʼязковий.

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

    • MDN — Using promises (JavaScript Guide) — проміс як інверсія контролю проти колбеків, гарантії ланцюга й чотири інструменти композиції, що запускають операції одночасно.
    • MDN — Promise — стани проміса, catch() як then() без обробника успіху й поведінка all/allSettled/any/race поштучно.

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

    • MDN — Promise — стани проміса, catch() як then() без обробника успіху й поведінка all/allSettled/any/race поштучно.
    • MDN — Using promises (JavaScript Guide) — проміс як інверсія контролю проти колбеків, гарантії ланцюга й чотири інструменти композиції, що запускають операції одночасно.

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

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

    Підсумок

    • TC39 — ECMAScript 2027 Language Specification (ECMA-262, чинний draft) — нормативне означення трьох взаємовиключних станів проміса.
    • MDN — Promise — стани проміса, catch() як then() без обробника успіху й поведінка all/allSettled/any/race поштучно.
    • MDN — Using promises (JavaScript Guide) — проміс як інверсія контролю проти колбеків, гарантії ланцюга й чотири інструменти композиції, що запускають операції одночасно.
    • MDN — async functionasync завжди повертає проміс, await призупиняє функцію й повертає звичайний try/catch навколо асинхронного коду.
    • typescript-eslint — no-floating-promises — правило, що вимагає щось зробити з кожним промісом — включно з масивом промісів.

    Можливі питання

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.