QA Interview Hub · База знань · Тести
TypeScript
Асинхронність: Promise, async/await, паралельність
Promise, async/await і забутий await
async функція завжди повертає Promise. await призупиняє виконання функції, поки проміс не завершиться, і повертає його значення або кидає помилку.
Забутий await - найпоширеніша помилка в автотестах на TS. Наслідки залежать від місця:
test('transfer is created', async ({ page }) => {
page.getByRole('button', { name: 'Надіслати' }).click(); // без await
expect(page.getByText('Переказ успішний')).toBeVisible(); // без await
});
Тут клік може не встигнути виконатися, а web-first assertion повертає проміс, який ніхто не чекає. Тест може пройти зеленим, нічого не перевіривши, або впасти з дивною помилкою на кшталт "Target page, context or browser has been closed", бо тест закінчився, а дія ще йде.
Для звичайного expect(value).toBe(x) await не потрібен - це синхронна перевірка. Потрібен для web-first assertions (toBeVisible, toHaveText), expect.poll і expect(...).resolves/rejects.
Як захищаюсь:
- Правило ESLint
@typescript-eslint/no-floating-promises - ловить проміси, результат яких не оброблений. Воно потребує type-aware конфігурації лінтера. - Правило
@typescript-eslint/await-thenable - ловить await на не-промісі. - Тип повернення
Promise<void> у методах Page Object, щоб було видно, що метод асинхронний.
Ще одна пастка: return без await всередині try. Якщо написати return client.get() у try, помилка проміса не потрапить у catch цієї функції. return await у try - правильний варіант.
На співбесіді: Забутий await - класична причина хибно зелених тестів: перевірка повертає проміс, який ніхто не чекає. Я вмикаю правило no-floating-promises у лінтері і пам'ятаю, що web-first assertions у Playwright завжди потребують await.
Promise.all, allSettled, race, any
| Метод | Коли завершується | Результат |
|---|
Promise.all | всі успішні або перша помилка | масив значень або перша помилка |
Promise.allSettled | коли всі завершились | масив статусів fulfilled або rejected |
Promise.race | перший завершений будь-як | значення або помилка першого |
Promise.any | перший успішний | значення або AggregateError, якщо всі впали |
Promise.all - для незалежних кроків підготовки, які мають пройти всі:
const [sender, receiver] = await Promise.all([
api.createAccount({ currency: 'UAH', balance: 100_000 }),
api.createAccount({ currency: 'UAH', balance: 0 }),
]);
Нюанс: при помилці одного проміса інші не скасовуються, вони продовжують працювати, просто їхній результат ігнорується. Якщо там створення даних, можна отримати "сміття" в тестовому середовищі.
Promise.allSettled - коли потрібен результат кожного, наприклад для cleanup: видалити всі створені сутності і не зупинитися на першій помилці.
const results = await Promise.allSettled(createdIds.map(id => api.deleteAccount(id)));
const failed = results.filter(r => r.status === 'rejected');
if (failed.length) console.warn(`Cleanup failed for ${failed.length} items`);
Класичний патерн у Playwright з Promise.all - очікування події, яку запускає дія:
const [response] = await Promise.all([
page.waitForResponse(r => r.url().includes('/api/transfers') && r.request().method() === 'POST'),
page.getByRole('button', { name: 'Надіслати' }).click(),
]);
Очікування треба запустити до дії, інакше відповідь може прийти раніше, ніж почнемо слухати. Те саме можна написати так: спочатку const responsePromise = page.waitForResponse(...) без await, потім клік, потім await responsePromise.
На співбесіді: Promise.all падає на першій помилці і підходить, коли потрібні всі результати, allSettled чекає всіх і віддає статус кожного, що зручно для cleanup. race бере перший завершений, any - перший успішний. У Playwright я через Promise.all або збережений проміс підписуюсь на waitForResponse до кліку.
Послідовно чи паралельно, і пастка forEach
await у циклі for...of виконує операції послідовно. map плюс Promise.all - паралельно.
// послідовно: 10 запитів по 200 мс = ~2 с
for (const id of ids) {
await api.deleteAccount(id);
}
// паралельно: ~200 мс
await Promise.all(ids.map(id => api.deleteAccount(id)));
Послідовність потрібна, коли кроки залежать один від одного або порядок важливий: наприклад, депозит, потім переказ, потім перевірка балансу. Або коли API має rate limit. Паралельність - для незалежних операцій. Для сотень запитів я обмежую конкурентність (обробка чанками або бібліотека на кшталт p-limit), щоб не покласти тестове середовище.
Головна пастка - forEach з async колбеком:
ids.forEach(async id => {
await api.deleteAccount(id);
});
console.log('done'); // виконається ДО видалення
forEach ігнорує повернені проміси. Він не чекає їх, і помилки всередині стають unhandled rejection. Тест іде далі, перевірки виконуються на неочищених даних, а помилка може вилетіти вже в іншому тесті. Те саме з filter, some, find - вони не розуміють async-предикати: проміс завжди truthy.
// неправильно: всі елементи пройдуть фільтр
const active = accounts.filter(async a => (await api.getStatus(a.id)) === 'ACTIVE');
Правильно - спочатку отримати всі статуси через Promise.all, потім фільтрувати синхронно.
reduce з async теж працює послідовно тільки якщо явно чекати акумулятор, тому я його уникаю і пишу for...of.
На співбесіді: for...of з await виконує запити по черзі, Promise.all з map - паралельно, і вибір залежить від того, чи є залежність між кроками. forEach не чекає async колбеків, тому я ніколи його так не використовую - тільки for...of або Promise.all.
Обробка помилок в асинхронному коді
Помилка в async функції перетворюється на rejected promise. Її ловлять через try/catch навколо await або через .catch().
async function getBalance(accountId: string): Promise<number> {
try {
const res = await api.get(`/accounts/${accountId}`);
return res.balance;
} catch (err) {
throw new Error(`Failed to get balance for ${accountId}`, { cause: err });
}
}
Принципи, яких я дотримуюсь у тестовому коді:
- Не ковтати помилки.
catch (e) {} у хелпері перетворює падіння на хибно зелений тест або на незрозуміле падіння далі. - Додавати контекст.
Error з опцією cause (ES2022) зберігає оригінальну помилку і додає, що саме ми робили. - У
catch змінна має тип unknown, тому перевіряю err instanceof Error перед зверненням до message. - Для негативних тестів не писати
try/catch вручну, а використовувати await expect(promise).rejects.toThrow(...). Ручний try/catch без expect у гілці успіху пропустить випадок, коли помилки не було.
await expect(api.transfer({ amount: -100 })).rejects.toThrow(/amount/i);
У Playwright API-тестах request.post() не кидає помилку на 4xx/5xx за замовчуванням, а повертає відповідь. Статус треба перевіряти явно: expect(res.status()).toBe(400) або expect(res).toBeOK(). Є опція failOnStatusCode, яка змушує кидати на не-2xx.
finally використовую для гарантованого cleanup, але в Playwright краще покладатися на фікстури: код після use() виконується навіть якщо тест упав.
На співбесіді: Я не ковтаю помилки в хелперах і загортаю їх з контекстом через cause. Для негативних сценаріїв використовую expect(...).rejects, а в API-тестах Playwright явно перевіряю статус, бо request не кидає помилку на 4xx за замовчуванням.