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 за замовчуванням.