QA Interview Hub · Knowledge base · Quizzes

TypeScript

Asynchrony: Promise, async/await, parallelism

Promise, async/await and a forgotten await

An async function always returns a Promise. await pauses the function until the promise finishes, and returns its value or throws an error.

A forgotten await is the most common mistake in TS automated tests. The consequences depend on the place:

test('transfer is created', async ({ page }) => {
  page.getByRole('button', { name: 'Send' }).click(); // no await
  expect(page.getByText('Transfer successful')).toBeVisible(); // no await
});

Here the click may not have time to execute, and the web-first assertion returns a promise that nobody awaits. The test may pass green without checking anything, or fail with a strange error such as "Target page, context or browser has been closed", because the test has ended while the action is still in progress.

For an ordinary expect(value).toBe(x) await isn't needed: it is a synchronous check. It is needed for web-first assertions (toBeVisible, toHaveText), expect.poll and expect(...).resolves/rejects.

How I protect myself:

  • The ESLint rule @typescript-eslint/no-floating-promises catches promises whose result isn't handled. It requires a type-aware linter configuration.
  • The rule @typescript-eslint/await-thenable catches await on a non-promise.
  • The return type Promise<void> in Page Object methods, so it is visible that a method is asynchronous.

One more trap: return without await inside try. If you write return client.get() in try, the promise's error won't get into this function's catch. return await in try is the right variant.

In an interview: A forgotten await is a classic cause of falsely green tests: the check returns a promise that nobody awaits. I turn on the no-floating-promises rule in the linter and remember that web-first assertions in Playwright always need await.

Promise.all, allSettled, race, any

MethodWhen it finishesResult
Promise.allall succeed or the first erroran array of values or the first error
Promise.allSettledwhen all have finishedan array of fulfilled or rejected statuses
Promise.racethe first to finish in any waythe first one's value or error
Promise.anythe first successfula value or an AggregateError if all failed

Promise.all is for independent preparation steps that must all pass:

const [sender, receiver] = await Promise.all([
  api.createAccount({ currency: 'UAH', balance: 100_000 }),
  api.createAccount({ currency: 'UAH', balance: 0 }),
]);

A nuance: when one promise fails, the others are not cancelled, they keep running, and their result is just ignored. If that involves creating data, you may get "garbage" in the test environment.

Promise.allSettled is for when you need each result, for example for cleanup: delete all created entities and don't stop at the first error.

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`);

A classic pattern in Playwright with Promise.all is waiting for an event that an action triggers:

const [response] = await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/transfers') && r.request().method() === 'POST'),
  page.getByRole('button', { name: 'Send' }).click(),
]);

The wait must be started before the action, otherwise the response may arrive before we start listening. The same can be written like this: first const responsePromise = page.waitForResponse(...) without await, then the click, then await responsePromise.

In an interview: Promise.all fails on the first error and fits when you need all the results, allSettled waits for everything and gives the status of each, which is handy for cleanup. race takes the first to finish, any the first successful. In Playwright I subscribe to waitForResponse before the click through Promise.all or a saved promise.

Sequential or parallel, and the forEach trap

await in a for...of loop runs operations sequentially. map plus Promise.all runs them in parallel.

// sequentially: 10 requests of 200 ms = ~2 s
for (const id of ids) {
  await api.deleteAccount(id);
}

// in parallel: ~200 ms
await Promise.all(ids.map(id => api.deleteAccount(id)));

Sequential is needed when steps depend on each other or the order matters: for example, a deposit, then a transfer, then a balance check. Or when the API has a rate limit. Parallel is for independent operations. For hundreds of requests I limit concurrency (processing in chunks or a library like p-limit), so as not to bring down the test environment.

The main trap is forEach with an async callback:

ids.forEach(async id => {
  await api.deleteAccount(id);
});
console.log('done'); // runs BEFORE the deletion

forEach ignores the returned promises. It doesn't wait for them, and errors inside become unhandled rejections. The test goes on, checks run on uncleaned data, and the error may pop up in another test. The same goes for filter, some, find: they don't understand async predicates, and a promise is always truthy.

// wrong: all the elements pass the filter
const active = accounts.filter(async a => (await api.getStatus(a.id)) === 'ACTIVE');

The right way is to first get all the statuses with Promise.all, and then filter synchronously.

reduce with async also works sequentially only if you explicitly await the accumulator, so I avoid it and write for...of.

In an interview: for...of with await runs requests one at a time, Promise.all with map in parallel, and the choice depends on whether there is a dependency between steps. forEach doesn't wait for async callbacks, so I never use it that way, only for...of or Promise.all.

Error handling in asynchronous code

An error in an async function turns into a rejected promise. It is caught with try/catch around await or with .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 });
  }
}

The principles I follow in test code:

  • Don't swallow errors. catch (e) {} in a helper turns a failure into a falsely green test or an unclear failure further on.
  • Add context. An Error with the cause option (ES2022) keeps the original error and adds what exactly we were doing.
  • In catch the variable has type unknown, so I check err instanceof Error before touching message.
  • For negative tests, don't write try/catch by hand, use await expect(promise).rejects.toThrow(...). A manual try/catch without an expect in the success branch will miss the case where there was no error.
await expect(api.transfer({ amount: -100 })).rejects.toThrow(/amount/i);

In Playwright API tests request.post() doesn't throw on 4xx/5xx by default, but returns the response. The status must be checked explicitly: expect(res.status()).toBe(400) or expect(res).toBeOK(). There is the failOnStatusCode option, which makes it throw on non-2xx.

I use finally for guaranteed cleanup, but in Playwright it is better to rely on fixtures: the code after use() runs even if the test failed.

In an interview: I don't swallow errors in helpers and wrap them with context through cause. For negative scenarios I use expect(...).rejects, and in Playwright API tests I check the status explicitly, because request doesn't throw on 4xx by default.