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 (залагоджений) — його результат заморожений і більше не зміниться. Це прибирає цілий клас багів «колбек викликали двічі».
Читають результат трьома методами:
.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();
}
}
Три факти, які треба тримати в голові:
async-функція завжди повертає Promise. Навіть якщо ти повертаєш звичайне число — воно автоматично «загорнеться» у виконаний Promise. Тому результатasync-функції теж требаawait-ити.awaitпризупиняє функцію, а не потік. Він чекає, поки Promise залагодиться, і віддає його значення (або кидає помилку, якщоrejected). Поки функція «спить» наawait, рушій вільний виконувати інший код — нічого не блокується.- Помилки ловляться звичайним
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) або коли важливий порядок побічних ефектів. Орієнтир простий:
Обережно з паралельністю проти спільного ресурсу: сто одночасних запитів можуть упертися в 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 має рівно три стани (
pending→fulfilledабо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)
Що таке Promise і в яких станах він буває?
Promise (обіцянка) — це обгортка над результатом асинхронної операції, якого в момент створення ще немає. Живе він в одному з трьох станів: pending (операція триває), fulfilled (успіх, є значення) і rejected (провал, є причина, зазвичай Error). Найважливіше — перехід зі стану очікування трапляється лише раз і фіксується назавжди: такий Promise називають settled, і його результат уже не переграти. Саме ця незмінність прибирає стару біду колбеків, коли обробник могли смикнути двічі й розсинхронити стан. Для автотесту це означає передбачуваність: результат асинхронного кроку не «мерехтить», його безпечно читати.
Чому колбеки виявилися незручними й що штовхнуло до Promise?
Дві причини. Перша поверхнева — «піраміда судного дня»: кожна залежна операція додає рівень вкладеності, і код сунеться вправо, доки не стає нечитабельним. Друга глибша — інверсія контролю: ти віддаєш свою функцію чужому коду й мусиш вірити, що він смикне її рівно раз і правильно передасть помилку. Обробку помилок доводилося вручну дублювати на кожному рівні, а звичайний try/catch навколо не рятував — колбек стріляє пізніше, коли зовнішній блок уже відпрацював. Promise забрали контроль назад: об'єкт сам сигналить про завершення, а помилки збираються в одному місці.
Чим відрізняються .then, .catch і .finally?
.then реагує на успіх і повертає новий Promise — саме тому виклики зчіплюються в ланцюг, а не громадяться. .catch перехоплює відхилення з будь-якого кроку вище по ланцюгу, тож одного .catch наприкінці досить, щоб зловити помилку звідусіль. .finally спрацьовує в будь-якому фіналі — сюди кладуть прибирання на кшталт «сховати лоадер» чи «закрити з'єднання». Тонкий момент: помилка, кинута всередині колбека, не вилітає назовні звичайним винятком, а переводить наступний Promise у rejected — тому й ловиться через .catch, а не через try/catch навколо ланцюга.
Що повертає async-функція і що насправді робить await?
async-функція завжди повертає Promise — навіть якщо всередині ти повертаєш звичайне число, воно автоматично загорнеться у виконаний Promise. Тому результат такої функції теж треба await-ити, інакше отримаєш обіцянку замість значення. await же розпаковує Promise: чекає, доки той залагодиться, і віддає значення або кидає помилку, якщо стан став rejected. По суті це синтаксичний цукор над .then, який дозволяє писати асинхронний код у вигляді звичайних послідовних рядків. Під капотом — ті самі Promise, просто без ланцюгів.
await блокує потік виконання?
Ні, і це ключове непорозуміння. await призупиняє тільки поточну async-функцію, а не весь рушій. Поки ця функція «спить», чекаючи на результат, event loop вільний крутити інший код — обробляти події, інші проміси, таймери. Тобто «зачекати» тут не дорівнює «заморозити все»: JS лишається однопотоковим і неблокувальним. Практичний наслідок для тестів: між двома сусідніми await можуть прослизнути інші мікрозадачі й оновлення стану, тож не варто уявляти, ніби рядки біжать строго впритул без жодної паузи.
Як обробляти помилки в async/await порівняно з колбеками?
У колбеках помилку доводилося перевіряти вручну першим аргументом (if (err)) на кожному рівні, а try/catch навколо не працював. З await усе повертається до звичного: якщо Promise відхилився, await кине справжній виняток, і його ловить нормальний try/catch. Це головна ергономічна перевага синтаксису — уся обробка помилок ланцюга збирається в один catch-блок, а finally гарантує прибирання незалежно від результату. У тесті це означає, що асинхронний збій не «губиться», а спливає туди, де його реально видно.
await у циклі проти Promise.all — коли що обирати?
await усередині циклу виконує операції строго по черзі: кожна чекає на попередню, і затримки складаються — десять запитів по 200 мс дадуть близько двох секунд. Якщо операції незалежні, це марна втрата часу: правильно запустити їх усі одразу й дочекатися разом через Promise.all, і тоді ті самі десять запитів укладуться приблизно в 200 мс. Послідовність лишають свідомо лише там, де крок справді спирається на результат попереднього (спершу створити користувача, потім його замовлення за отриманим id) або де важливий порядок побічних ефектів. Класичний приклад паралельності — насидати пачку тестових акаунтів одночасно замість очікування кожного окремо.
Чим Promise.all відрізняється від Promise.allSettled?
Promise.all працює за принципом fail-fast: щойно хоч один проміс відхилився, увесь all миттєво падає з цією ж причиною, а результати решти губляться (самі операції при цьому не скасовуються — просто їхні значення нікуди не потраплять). Promise.allSettled, навпаки, не відхиляється ніколи: він чекає, доки всі залагодяться, і повертає масив об'єктів-статусів — для кожного або значення, або причину відмови. Обирати all варто, коли потрібні геть усі результати за принципом «все або нічого». А allSettled — коли запускаєш пачку незалежних перевірок чи очищень і хочеш зібрати повну картину, а не впасти на першому збої.
У чому різниця між Promise.race і Promise.any?
Promise.race залагоджується найпершим результатом, що прийшов, — байдуже, успіх це чи помилка; переміг перший, хто фінішував. Promise.any розбірливіший: він чекає на перший успішний проміс і відхиляється, лише коли провалилися геть усі, повертаючи тоді AggregateError зі списком усіх помилок. Через це в них різні застосування: race класично роблять таймаутом — гонка корисної операції проти проміса, що відхилиться через N секунд; а any беруть, коли досить першого, що спрацював (наприклад, перша жива репліка з кількох). Плутати їх на співбесіді не варто — це типова перевірка на розуміння поведінки при частковому збої.
Чому forEach з async-колбеком не чекає на проміси?
Тому що Array.forEach взагалі не дивиться на значення, яке повертає колбек. Ти передаєш йому async-функцію, кожен виклик віддає Promise, але forEach їх ігнорує — усі колбеки стартують «у повітря», а код нижче біжить далі, не дочекавшись жодного. Виглядає як послідовна обробка з await, а насправді порядок і завершення ніхто не контролює. Лікується це for...of з await (коли треба по черзі) або await Promise.all(ids.map(...)) (коли можна паралельно). Та сама пастка чигає на map/filter з async-колбеком: вони повертають масив промісів, а не масив готових значень.
Що таке floating promise і чому через нього тест зеленіє завжди?
Floating promise — це асинхронний виклик без await: код не дочекався результату й полетів далі, лишивши обіцянку «плавати». У Playwright web-first assertion (наприклад, перевірка видимості) повертає Promise із вбудованими ретраями. Забудеш await перед нею — тест не стане чекати, ретраї не встигнуть відпрацювати, і перевірка «пройде» навіть тоді, коли кнопки на сторінці немає. Тобто тест стабільно зелений, але нічого не перевіряє — найнебезпечніший різновид фальшивого проходження. Ловить цей клас багів eslint-правило @typescript-eslint/no-floating-promises, і в тестовому проєкті його варто вмикати.
Що виведе код зі setTimeout(…, 0) і .then, і чому саме так?
Асинхронні задачі стоять у двох чергах: колбеки промісів (.then, продовження після await) — це мікрозадачі, а setTimeout/setInterval/події вводу — макрозадачі. Правило порядку жорстке: після кожної синхронної ділянки рушій спорожняє всю чергу мікрозадач і лише потім бере одну макрозадачу. Тому .then завжди відпрацює раніше за setTimeout(fn, 0), навіть якщо таймер стоїть у коді вище. У класичному прикладі з рядками console.log('1'), setTimeout, Promise.resolve().then, console.log('4') вивід буде 1, 4, 3, 2: спершу синхронні 1 і 4, далі мікрозадача 3, і аж потім макрозадача 2.
Чому фіксований sleep в автотестах — антипатерн?
Бо ти вгадуєш час замість того, щоб чекати на подію. Жорстка пауза (waitForTimeout(3000), sleep) має два однаково погані результати: або марно гальмує зелений прогін на секунди, яких не знадобилося, або все одно падає під навантаженням, коли операція цього разу зайняла трохи довше. Спокуса «полагодити» плаваючий тест затримкою лікує симптом, а не причину — сам floating promise чи неправильну синхронізацію. Правильно — очікувати конкретну умову: елемент став видимим, відповідь прийшла, стан змінився. Саме на цьому побудовані auto-waiting і web-first assertions, які підбирають час самі.
Один проміс у Promise.all відхилився. Що стається з рештою?
Promise.all миттєво переходить у rejected з причиною цього першого збою — це і є fail-fast. Але важливий нюанс: решта промісів не скасовується. Вони фізично продовжують виконуватися (запити летять, дані пишуться), просто їхні результати вже нікуди не потраплять, бо загальний проміс уже впав. Тому не варто вважати, що all «зупинив» усе інше — він лише перестав чекати на нього. Якщо тобі потрібні всі результати незалежно від окремих збоїв (скажімо, прибрати десять сутностей і не впасти, якщо однієї вже немає), бери Promise.allSettled замість all.
Що таке unhandled rejection і як його не наплодити в тестах?
Unhandled rejection — це відхилений Promise, чиєї помилки ніхто не перехопив. Найчастіше він виникає, коли в ланцюгу забувають return або await перед вкладеним промісом: його відхилення не має куди «стекти» й лишається необробленим. У тесті це підступно, бо збій може не завалити крок явно, а спливти пізніше й у чужому місці — або взагалі лише як попередження в лозі. Дисципліна проста: кожен асинхронний виклик має бути або await-нутий, або свідомо повернутий далі по ланцюгу. Те саме правило, що ловить floating promise, страхує й від проковтнутих reject.
Як обмежити паралельність, щоб не завалити стенд чи не впертися в rate limit?
Promise.all спокушає запустити все одразу, але сто одночасних запитів проти спільного ресурсу легко впруться в обмеження частоти (rate limit) або перевантажать тестове середовище — і частина операцій почне падати «випадково». Тому за масової паралельності потрібен ліміт одночасних операцій — пул (concurrency pool): наприклад, обробляти дані порціями по N або тримати «семафор», що не пускає більше N промісів у роботу водночас. Готового вбудованого комбінатора для цього немає, тож на співбесіді таку задачу часто дають на live coding — написати обмежувач паралельності самотужки. Орієнтир: незалежні операції — паралельно, але з дахом, а не «усі разом любою ціною».
Три кейси, де асинхронність вирішує, зелений тест чи полювання за примарою: підготовка тестових даних послідовно проти паралельно (і чому teardown краще на allSettled), три різновиди забутого await у Playwright, що роблять тест фальшиво-зеленим, і таймаут через Promise.race замість жорсткого sleep. Скрізь — що саме дивитися і чому.
Кейс 1. Підготовка даних: await у циклі, Promise.all і allSettled
Тесту треба десять акаунтів перед стартом. Наївний варіант — цикл із await, і кожне створення чекає на попереднє, хоча вони одне від одного не залежать.
// Послідовно: 10 запитів по ~200 мс складаються в ~2 секунди
const users = [];
for (const seed of seeds) {
users.push(await api.createUser(seed)); // кожен чекає на попередній
}
Операції незалежні, тож затримки складаються дарма. Запускаємо їх разом і чекаємо однією точкою:
// Паралельно: усі 10 стартують одночасно = ~200 мс
const users = await Promise.all(seeds.map((seed) => api.createUser(seed)));
Promise.all повертає масив результатів у тому самому порядку, що й вхідний масив, — тож users[0] відповідає seeds[0] незалежно від того, хто фізично відповів першим. Але для прибирання після тесту all — пастка:
test.afterEach(async () => {
// ПОГАНО: якщо одного юзера вже немає, весь all падає fail-fast,
// і решта може лишитися неприбраною
await Promise.all(users.map((u) => api.deleteUser(u.id)));
});
Правильний teardown має пережити часткові збої — тут якраз allSettled:
test.afterEach(async () => {
const results = await Promise.allSettled(users.map((u) => api.deleteUser(u.id)));
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) console.warn(`не прибрано: ${failed.length} з ${users.length}`);
});
Що дивитися і чому:
Promise.allдля незалежних операцій — головний виграш у швидкості. Але вмикати паралельність варто, лише коли крок справді не залежить від попереднього; інакше отримаєш гонку за результатом, якого ще немає.- Fail-fast
allнебезпечний саме в очищенні. Одна сутність, якої вже немає, валить весьdeleteUser-батч, і частина даних лишається на стенді — джерело409 Conflictна наступному прогоні.allSettledдоводить прибирання до кінця й дає повний звіт. - Порядок результатів
allзбігається з порядком входу, а не з порядком завершення — на це можна покладатися при звірянніusers[i]ізseeds[i]. - Не насидайте сотні сутностей одним
Promise.all. Проти спільного API це впреться в rate limit; тоді потрібен ліміт паралельності (порції по N), а не «усі разом».
Кейс 2. Три різновиди забутого await у Playwright
Найпідступніший баг автотесту — той, що робить його стабільно зеленим і водночас сліпим. Усі три приклади нижче «проходять» навіть тоді, коли застосунок зламаний.
// 1) Floating promise: web-first assertion без await
test('кнопка видима', async ({ page }) => {
await page.goto('/dashboard');
expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); // ← немає await
// тест зелений завжди: перевірка не дочекалась ретраїв, навіть якщо кнопки немає
});
// 2) forEach з async: колбеки стартують "у повітря"
test('видалити всіх', async ({ page }) => {
const ids = ['a', 'b', 'c'];
ids.forEach(async (id) => {
await api.deleteUser(id); // forEach НЕ чекає на ці проміси
});
await expect(page.getByTestId('list')).toBeEmpty(); // біжить ДО видалення
});
// 3) Проковтнутий reject: вкладений проміс без return/await
async function ensureUser(id: string) {
api.createUser(id); // ← без await: помилка створення нікуди не потрапить
}
Виправлення для кожного:
// 1) додати await — ретраї web-first assertion відпрацюють
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
// 2) послідовно (for...of) або паралельно (Promise.all), але не forEach
for (const id of ids) await api.deleteUser(id);
// або: await Promise.all(ids.map((id) => api.deleteUser(id)));
// 3) await-нути вкладений виклик, щоб reject став винятком
async function ensureUser(id: string) {
await api.createUser(id);
}
Що дивитися і чому:
- Забутий
awaitпередexpect— не сповільнення, а сліпота. Playwright-асершени повертають Promise із вбудованими ретраями; безawaitперевірка не встигає їх зробити й «зеленіє» на порожньому місці. Симптом — тест ніколи не червоніє, навіть коли фічу зламали. forEachігнорує повернутий проміс за побудовою. Ті саміasync-колбеки вfor...ofзawaitвиконаються по черзі, а вPromise.all(map(...))— паралельно;forEachне дає ні того, ні того.map/filterзasyncтеж пастка — вони повертають масив промісів, а не значень; забудеш обгорнути вPromise.all, і далі по коду отримаєш[Promise, Promise, …].- Увімкніть
@typescript-eslint/no-floating-promisesу тестовому проєкті — воно ловить усі три випадки статично, ще до прогону, і це найдешевший захист від фальшиво-зелених тестів.
Кейс 3. Таймаут через Promise.race замість sleep
Іноді треба обмежити асинхронну операцію часом — але не жорсткою паузою, а «або встигни за N секунд, або чесно впади». Це класичне застосування Promise.race: гонка корисної операції проти проміса, що відхиляється по таймауту.
function withTimeout<T>(promise: Promise<T>, ms: number, label = 'operation'): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`${label} перевищила ${ms} мс`)), ms),
);
return Promise.race([promise, timeout]); // хто перший залагодиться, той і виграв
}
// використання: очікуємо готовність бекенду, але не довше 5 секунд
await withTimeout(api.waitForHealthcheck(), 5000, 'healthcheck');
Порівняння двох підходів до «зачекати»:
| Підхід | Що робить | Проблема |
|---|---|---|
await sleep(3000) | завжди чекає рівно 3 с | марно гальмує на зеленому, падає під навантаженням |
withTimeout(op, 3000) | завершується, щойно op готова; падає лише як стеля | чекає на подію, а не на час |
| auto-waiting Playwright | ретраїть до умови в межах ліміту | вбудований, окремий race не потрібен |
Що дивитися і чому:
Promise.raceзалагоджується першим-ліпшим — і успіхом, і помилкою. Тут це і потрібно: якщоpromiseвстиг — беремо його значення; якщо раніше «вистрілив» таймер — отримуємоrejectз осмисленим повідомленням, а не мовчазне зависання.- Це не заміна auto-waiting. Для UI-перевірок Playwright уже ретраїть сам;
withTimeoutдоречний для власних асинхронних операцій без вбудованого очікування (кастомний polling, зовнішній сервіс, черга). - Таймер за собою не прибирається. Програний
setTimeoutлишається висіти до спрацювання; у довгих сесіях його варто чистити черезclearTimeoutуfinally, інакше назбираються зайві макрозадачі. sleepлишається антипатерном саме тому, що вгадує час;raceне вгадує тривалість операції — він лише ставить стелю, за якою чесно фейлить.
Promise: стани і ланцюг
- Можу назвати три стани Promise (
pending,fulfilled,rejected), пояснити, що перехід фіксується раз і назавжди (settled), і чому ця незмінність прибирає клас багів «колбек викликали двічі». - Знаю різницю між
.then(реагує на успіх, повертає новий проміс для ланцюга),.catch(ловить відхилення з будь-якого кроку вище) і.finally(спрацьовує завжди, для прибирання). - Можу пояснити, чому помилку в ланцюгу ловить
.catch, а неtry/catchнавколо нього. - Розумію, що таке callback hell і чому головна біда колбеків — інверсія контролю, а не лише вкладеність.
async/await
- Знаю, що
async-функція завжди повертає Promise, тож її результат теж требаawait-ити. - Можу пояснити, що
awaitпризупиняє тільки поточну функцію, а не потік — event loop лишається вільним. - Розумію, що
awaitрозпаковує значення проміса або кидає виняток, якщо тойrejected, тож помилки ловляться звичайнимtry/catch, на відміну від колбеків. - Розумію, що
async/await— синтаксичний цукор над тими самими Promise, а не окремий механізм.
Послідовно vs паралельно
- Знаю різницю між
awaitу циклі (затримки складаються) іPromise.all(операції стартують разом). - Можу пояснити, коли послідовність обов'язкова: крок залежить від результату попереднього або важливий порядок побічних ефектів.
- Розумію, чому масову паралельність треба обмежувати пулом, щоб не впертися в rate limit чи не перевантажити стенд.
Комбінатори Promise
- Можу пояснити, що
Promise.all— fail-fast: падає на першому відхиленні, а результати решти губляться (хоча самі операції не скасовуються). - Знаю, що
Promise.allSettledне відхиляється ніколи й повертає статус кожного проміса — і коли він доречніший заall. - Розумію різницю
Promise.race(перший будь-який результат) vsPromise.any(перший успішний, падає лише коли всі провалились ізAggregateError), і можу навести застосування:raceдля таймауту,anyдля «першої живої репліки»,allSettledдля пачки незалежних перевірок.
Мікро- і макрозадачі
- Знаю, що колбеки промісів (
.then, продовження післяawait) — мікрозадачі, аsetTimeout/події вводу — макрозадачі. - Можу пояснити правило: вся черга мікрозадач спорожняється перед наступною макрозадачею, тож
.thenвипереджаєsetTimeout(fn, 0).
Типові пастки в автотестах
- Розумію, що таке floating promise і чому забутий
awaitперед web-first assertion робить тест фальшиво-зеленим; знаю, що eslint-правило@typescript-eslint/no-floating-promisesловить його статично. - Можу пояснити, чому
forEachзasync-колбеком не чекає на проміси, і чим замінити (for...ofзawaitабоPromise.all(map)). - Знаю, чому hard wait (
sleep,waitForTimeout) — антипатерн, і чому правильно чекати на умову, а не на час. - Розумію, як виникає unhandled rejection через забутий
return/awaitі чому кожен асинхронний виклик має бути await-нутий або повернутий далі.
Скільки разів Promise може змінити стан із pending?
Питання
Три стани Promise