Resource library

QA How-To

Async await in tests: A QA Guide (2026)

Master async await in tests qa patterns for reliable TypeScript and Playwright tests, including assertions, concurrency, polling, cleanup, and debugging.

22 min read | 2,855 words

TL;DR

Async await in tests qa work is reliable when the test awaits one complete chain from setup through assertion and cleanup. Replace sleeps with observable conditions, assert rejections explicitly, parallelize only independent operations, and make every callback or event source expose a Promise with a deadline.

Key Takeaways

  • An asynchronous test must return or await the Promise that represents all work and assertions.
  • Await observable outcomes, not arbitrary delays, and let the test runner own its supported waiting model.
  • Use `Promise.all` only for operations that are truly independent and safe to run concurrently.
  • Assert rejected Promises with runner-supported APIs so expected failures cannot become unhandled rejections or false passes.
  • Put asynchronous cleanup in `finally` or runner hooks and verify it even when setup or assertions fail.
  • Treat timers, callbacks, events, and polling as explicit Promise boundaries with deadlines and useful diagnostics.
  • Debug missing `await` by tracing Promise ownership, event order, test completion, and unhandled rejection output.

Async await in tests qa guidance starts with one rule: the runner must know when every operation and assertion has finished. In JavaScript and TypeScript, an async function returns a Promise. A test is complete only when the runner receives or awaits the Promise that represents the full scenario.

Most asynchronous test failures come from broken ownership, not from async syntax. A Promise is created but not awaited, a callback is never converted, an assertion runs before state changes, cleanup starts too early, or unrelated operations are parallelized. This guide turns those failure modes into reviewable patterns for Node.js and Playwright tests.

TL;DR

Situation Reliable pattern Risky pattern
Promise-based action await action() Start it and ignore the Promise
Expected rejection await assert.rejects(...) Try and catch without failing when no error occurs
UI readiness Playwright locator assertion Fixed setTimeout sleep
Independent setup await Promise.all([...]) Sequential calls with no dependency
Dependent steps Await in business order Promise.all for create then read
Cleanup try/finally or awaited hooks Fire-and-forget deletion
Callback API Wrap once in a Promise Mix callback and async completion
Polling Deadline, interval, terminal errors Infinite loop or fixed delay

Think in one chain: arrange -> act -> observe -> assert -> clean. Every arrow that crosses time must either be awaited directly or represented by a Promise returned to the runner.

1. Async Await in Tests QA: Promise Ownership

Calling an async function immediately returns a Promise. Code inside runs until its first suspension, then continues later. await pauses only the current async function and schedules its continuation when the Promise settles. It does not block the JavaScript runtime. Other tasks can continue.

A runner such as Node's built-in node:test, Playwright Test, Vitest, or Jest treats a returned Promise as the test's completion signal. If the Promise rejects, the test fails. If the test body starts another Promise and returns without awaiting it, the runner may finish before that work or assertion. The later rejection can appear as an unhandled rejection, fail a different test, or be lost during process shutdown.

import test from 'node:test';
import assert from 'node:assert/strict';

async function loadUser(id: string): Promise<{ id: string; active: boolean }> {
  return { id, active: true };
}

test('loads an active user', async () => {
  const user = await loadUser('user-42');
  assert.equal(user.id, 'user-42');
  assert.equal(user.active, true);
});

This test returns the Promise created by its async callback to node:test. The awaited operation and assertions are inside that chain. An equivalent non-async style is test('name', () => loadUser(...).then(...)), but do not combine Promise return, done callback, and async function styles in one test. Multiple completion mechanisms create races and double-completion errors.

Code review question: who owns this Promise? The test should await it, a helper should return it to its caller, or a deliberately managed background service should track and stop it. An unexplained floating Promise is a defect candidate. TypeScript-aware lint rules such as @typescript-eslint/no-floating-promises can detect many cases when configured with type information.

2. Build Correct Async Test Helpers

A helper must return the value or Promise its caller needs. Adding async does not automatically await operations started inside the helper. The reliable helper returns after its entire contract is true.

type ApiResponse<T> = { status: number; body: T };

async function createProject(
  baseUrl: string,
  token: string,
  name: string,
): Promise<ApiResponse<{ id: string; name: string }>> {
  const response = await fetch(`${baseUrl}/projects`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${token}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({ name }),
  });

  const body = await response.json() as { id: string; name: string };
  return { status: response.status, body };
}

The caller can assert transport and domain results. The helper does not swallow errors, log a token, or start JSON parsing without awaiting it. For a non-JSON error response, a production client should inspect content type and preserve a safe response excerpt.

Avoid helpers that catch every error and return null. That converts a rejected Promise with a useful stack into a later property access failure. Catch only to recover, add meaningful context with cause, or translate at a domain boundary.

async function loadFixture(path: string): Promise<string> {
  try {
    return await import('node:fs/promises').then(fs => fs.readFile(path, 'utf8'));
  } catch (cause) {
    throw new Error(`Cannot read fixture: ${path}`, { cause });
  }
}

Do not mark a function async if it never awaits and does not need to normalize a returned value. It is legal, but unnecessary async wrappers can make stack traces and intent less clear. Conversely, returning a Promise from a non-async function is fine if the complete chain is returned.

3. Await Assertions and Expected Rejections

Asynchronous assertions often return Promises. They must be awaited. In Playwright, locator assertions such as expect(locator).toBeVisible() retry until the condition passes or times out. Omitting await can let the test finish before the assertion.

For negative API or service behavior, use the runner's rejection assertion. Node provides assert.rejects.

import test from 'node:test';
import assert from 'node:assert/strict';

async function parseRequiredPort(raw: string): Promise<number> {
  const port = Number(raw);
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new RangeError(`Invalid port: ${raw}`);
  }
  return port;
}

test('rejects an out-of-range port', async () => {
  await assert.rejects(
    () => parseRequiredPort('70000'),
    (error: unknown) => {
      assert.ok(error instanceof RangeError);
      assert.match(error.message, /Invalid port/);
      return true;
    },
  );
});

Passing a function avoids creating the Promise earlier than intended. The validator returns true after making focused assertions. Do not match an entire volatile error string when type and stable fragment express the contract.

A manual try and catch needs a guard:

let thrown: unknown;
try {
  await parseRequiredPort('0');
} catch (error) {
  thrown = error;
}
assert.ok(thrown instanceof RangeError, 'expected RangeError');

Without the final assertion, unexpected resolution becomes a false pass. Prefer the built-in rejection assertion when it expresses the requirement. Also distinguish synchronous throws from Promise rejection. assert.throws is for a function that throws before returning; assert.rejects is for a rejected Promise or async function.

4. Async Await in Tests QA for Playwright

Playwright actions and assertions are asynchronous. Await navigation, locators, downloads, popups, requests, and assertions. Playwright automatically waits for actionability on supported locator actions, so avoid adding sleeps before every click. Assert the observable user state.

import { test, expect } from '@playwright/test';

test('customer can submit an order', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Email').fill('buyer@example.test');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByRole('heading', { name: 'Order confirmed' }))
    .toBeVisible();
  await expect(page.getByTestId('order-status')).toHaveText('Processing');
});

The click and assertions are awaited. The heading is a user-visible completion signal. A test that calls page.waitForTimeout(3000) guesses when the application will be ready and becomes both slow and flaky. Use locator assertions, URL assertions, response events, or a documented domain state.

When an event is triggered by an action, start waiting before the action so a fast event is not missed.

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
await download.saveAs(test.info().outputPath('orders.csv'));

Do not write await page.waitForEvent('download') before clicking, because the code would wait for an event that the click has not triggered. Do not click first and register later, because a fast download can occur in the gap. The promise-before-action pattern coordinates the event without running two browser actions concurrently.

For locator strategy and supported waits, see the Playwright locator best practices.

5. Use Promise.all Only for Independent Work

Promise.all waits for every input Promise and preserves input order in the returned values. It rejects when one input rejects. Use it to reduce setup time only when operations are independent and the system supports their combined load.

const [buyer, product] = await Promise.all([
  api.createBuyer({ name: 'Async Buyer' }),
  api.createProduct({ sku: `sku-${crypto.randomUUID()}` }),
]);

const order = await api.createOrder({
  buyerId: buyer.id,
  productId: product.id,
});

Buyer and product creation are independent. Order creation depends on both IDs and remains afterward. Putting all three in one Promise.all would require accessing results that do not exist yet or hiding dependency through shared mutation.

Do not use Promise.all to send several commands to the same Playwright page. Browser actions often depend on focus, navigation, DOM changes, or event sequence. Promise.all([page.click(...), page.fill(...)]) does not model two users, it creates command races in one session. Use separate browser contexts and pages for truly concurrent actors.

Promise.all does not cancel other operations when one rejects. Remaining requests may continue and create data. Cleanup must track identifiers as they become available or use an API that supports cancellation. Promise.allSettled is useful during best-effort cleanup because it waits for every deletion and lets you report all failures. It should not replace fail-fast assertions in normal behavior testing.

Avoid array.forEach(async item => ...). forEach ignores returned Promises. Use for...of for sequential work or await Promise.all(items.map(async item => ...)) for safe concurrency. Bound concurrency for large datasets instead of creating thousands of requests at once.

6. Convert Callbacks and Events Into One Promise

Older Node libraries and custom application hooks may use callbacks or event emitters. Do not mix a test runner completion callback with an async test body. Wrap the API once at the boundary and make every test consume a Promise. Node's util.promisify helps with conventional error-first callbacks.

import { readFile } from 'node:fs';
import { promisify } from 'node:util';

const readFileAsync = promisify(readFile);

async function readJson<T>(path: string): Promise<T> {
  const text = await readFileAsync(path, 'utf8');
  return JSON.parse(text) as T;
}

For EventEmitter behavior, resolve on the desired event, reject on error, clean listeners in both paths, and add a deadline. Node's events.once already returns a Promise for one event and rejects when the emitter emits error in normal supported cases.

import { once, EventEmitter } from 'node:events';

async function waitForReady(
  emitter: EventEmitter,
  timeoutMs: number,
): Promise<unknown[]> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await once(emitter, 'ready', { signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

This uses the supported signal option. An abort produces an error rather than hanging forever. The timer is cleared in every outcome so it cannot keep the process active or fire during another test.

When testing a callback contract itself, keep callback assertions inside a Promise you return. Guard multiple callback calls if the contract says exactly once. A callback invoked twice after a Promise resolves can otherwise escape the test. Purpose-built spies or a counter plus a short documented settling mechanism may be required for exactly-once behavior.

7. Poll Asynchronous Systems With a Deadline

Some APIs return before a background workflow completes. The test may need to poll a read endpoint until a terminal state. Polling should use a monotonic deadline, bounded interval, meaningful accepted states, and immediate failure for terminal error states.

import { setTimeout as delay } from 'node:timers/promises';

type OrderStatus = 'QUEUED' | 'PROCESSING' | 'COMPLETE' | 'REJECTED';

async function waitForOrder(
  readStatus: () => Promise<OrderStatus>,
  timeoutMs = 10_000,
  intervalMs = 250,
): Promise<OrderStatus> {
  const deadline = performance.now() + timeoutMs;
  let last: OrderStatus | undefined;

  while (performance.now() < deadline) {
    last = await readStatus();
    if (last === 'COMPLETE') return last;
    if (last === 'REJECTED') {
      throw new Error('Order reached REJECTED');
    }
    await delay(intervalMs);
  }

  throw new Error(`Order did not complete, last status: ${last}`);
}

The timeout is illustrative and should match the product service-level expectation and test environment. performance.now() avoids wall-clock adjustments for elapsed time. The final error includes the last observed state. A production helper should include a safe order ID and correlation ID.

Do not retry every thrown error inside the loop. Authentication failure, invalid ID, and schema mismatch are deterministic. A narrow transport retry policy can classify eligible errors and preserve evidence. Avoid polling the UI when a stable API or event can prove the same backend state, but keep one user-facing UI assertion for the journey being tested.

The API eventual consistency testing guide expands the state-machine approach.

8. Design Timeouts, Cancellation, and Cleanup

Runner timeout, assertion timeout, request timeout, and domain deadline are different. One giant test timeout produces poor diagnostics. Give each boundary a reason and include operation context. Playwright has test, action, navigation, and expect timeouts with documented configuration. Node fetch accepts an AbortSignal; AbortSignal.timeout(ms) creates a signal that aborts after the duration in supported Node versions.

async function fetchHealth(url: string): Promise<Response> {
  return fetch(url, { signal: AbortSignal.timeout(5_000) });
}

A timeout does not always stop remote work. The server may continue processing an order even after the client aborts. Test cleanup should query by idempotency key or case ID and remove any created resource. Treat timeout as an uncertain outcome for mutations.

Use try/finally when a test creates a resource that must be removed regardless of assertion outcome.

test('updates project visibility', async () => {
  const project = await api.createProject({ visibility: 'private' });
  try {
    await api.updateProject(project.id, { visibility: 'public' });
    const actual = await api.getProject(project.id);
    assert.equal(actual.visibility, 'public');
  } finally {
    await api.deleteProject(project.id);
  }
});

If cleanup has several independent resources, Promise.allSettled can collect all outcomes, then throw an aggregated cleanup error only when it does not mask an earlier test failure. Runner hooks such as afterEach are also appropriate when fixtures own the resource. Ensure hooks are async and awaited. Fire-and-forget deletion can leave residue that breaks a later parallel test.

9. Diagnose Flaky Asynchronous Tests

The most useful signals are test start and end, operation start and settlement, stable case ID, request correlation ID, current state, timeout source, and unhandled rejection output. Avoid logging every secret-bearing request. Record a safe summary and preserve the cause chain.

A missing await often has these symptoms: the test passes unusually quickly, a later test receives an error, a page or context closes during an action, Target page, context or browser has been closed appears, or Node reports asynchronous activity after test completion. Search for floating calls to async helpers, unawaited Playwright expects, async forEach, and callback APIs.

An over-wait has different symptoms: suite duration rises, fixed sleeps dominate traces, and the state became ready much earlier. Replace time with conditions. In Playwright, inspect the trace and action log. In API tests, record the sequence of observed states. For event tests, verify the listener registered before the trigger.

A race can disappear under debugging because logs change timing. Add deterministic barriers at the relevant boundary rather than sleeps. For example, expose a test-only controllable dependency, use a local fake that waits on a Promise, or assert event order from recorded immutable events.

Set the runtime to surface unhandled rejections according to your supported Node behavior and keep linting type-aware. A green test with an unhandled rejection warning is not healthy. The flaky test quarantine guide can protect CI signal temporarily, but the owner still needs a root-cause fix.

10. Test Async Code Without Making Tests Slow

Separate pure decisions from timing. The polling helper above accepts readStatus, which can return a controlled sequence in a unit test. For more control, inject the delay function and clock rather than waiting real seconds. Do not introduce fake timers into every test because they can interact with network libraries, Promises, and runner internals. Use them at a small unit boundary.

async function retry<T>(
  operation: () => Promise<T>,
  attempts: number,
  sleep: () => Promise<void>,
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (attempt < attempts) await sleep();
    }
  }
  throw lastError;
}

A unit test can pass async () => {} as sleep and an operation that fails once then succeeds. Production code should also classify eligible errors and validate that attempts is positive. The dependency injection makes timing deterministic without changing global timers.

At integration level, use real HTTP against a controlled service or contract stub. At end-to-end level, use the real UI and runner waiting. Avoid mocking so deeply that Promise order differs from production. A synchronous fake for an asynchronous production API can hide missing awaits because it resolves before the test returns. Prefer fakes that return actual Promises and include a controlled deferred result for ordering tests.

One useful deferred helper is a Promise plus externally held resolve and reject functions. Use it only in tests, settle it in finally, and ensure no Promise remains pending after the test. This lets you prove that a component does not publish success before a dependency completes.

11. Async Await in Tests QA Review Checklist

Review from the outer test inward:

  1. Is the test callback async or returning the complete Promise chain?
  2. Is every async action and assertion awaited or intentionally tracked?
  3. Do helpers return after their documented outcome is true?
  4. Are expected rejections asserted with a guard against unexpected success?
  5. Are event listeners installed before the triggering action?
  6. Does Promise.all contain only independent, concurrency-safe work?
  7. Are array iterations using for...of or awaited mapped Promises?
  8. Does polling have a deadline, terminal errors, and last-state diagnostics?
  9. Are timeouts placed at the boundary they describe?
  10. Does cancellation account for remote side effects?
  11. Is cleanup awaited in finally or async hooks?
  12. Do logs and traces preserve enough order to diagnose races?
  13. Are fake async dependencies genuinely asynchronous where ordering matters?
  14. Does linting reject floating Promises in TypeScript code?

Do not add await to every expression mechanically. Await Promises that represent required work. Ordinary values can be awaited in JavaScript, but doing so hides a type or contract mistake. TypeScript return types such as Promise<Project> make intent visible.

Finally, keep one scenario per test. Large async flows create many possible failure points and expensive cleanup. Use API fixtures for prerequisites, then reserve the UI for the behavior under test.

Interview Questions and Answers

Q: What happens when an async test omits await?

The test can finish before the Promise settles. Assertions may never affect that result, and a later rejection can appear as an unhandled rejection or fail unrelated work.

Q: Does await block JavaScript?

It pauses the current async function until settlement. It does not block the runtime from processing other tasks and Promise continuations.

Q: When should you use Promise.all in a test?

Use it for independent operations that are safe under concurrency. Keep dependent business steps sequential and do not issue concurrent commands against one Playwright page.

Q: How do you test an expected rejected Promise?

Use the test runner's async rejection assertion, such as Node's assert.rejects, and await it. Assert stable error type and contract fields or message fragments.

Q: Why is async forEach risky?

forEach ignores the Promises returned by its callback. Use for...of for sequence or await Promise.all(items.map(...)) for deliberate concurrency.

Q: How do you wait for eventual consistency?

Poll the read model with a monotonic deadline, bounded interval, accepted transient states, immediate terminal errors, and a timeout message containing the last observed state.

Q: How do you clean async test data?

Await deletion in finally or an async runner hook. Track resources as they are created and use all-settled cleanup when several independent deletions must be attempted.

Q: How do you find a missing await?

I trace Promise ownership, inspect unusually early test completion, runner warnings, Playwright traces, async array callbacks, and unawaited assertions. Type-aware linting catches many floating Promises.

Common Mistakes

  • Starting a Promise without awaiting or returning it.
  • Combining an async test function with a callback completion style.
  • Forgetting to await Playwright locator assertions.
  • Using fixed sleeps instead of observable conditions.
  • Registering an event wait after the triggering action.
  • Running dependent setup inside Promise.all.
  • Sending simultaneous actions to one browser page.
  • Using forEach with an async callback.
  • Catching an expected error without failing when the Promise resolves.
  • Retrying every error inside a polling loop.
  • Assuming abort guarantees that a remote mutation stopped.
  • Starting cleanup without awaiting it.
  • Using synchronous fakes that hide real Promise ordering.
  • Increasing the global test timeout instead of diagnosing the boundary.

Conclusion

Async await in tests qa reliability comes from complete Promise ownership. The runner receives one chain that covers setup, action, observation, assertion, and cleanup. Events are registered before triggers, independent work is parallelized carefully, and every wait has a condition or deadline.

Choose one flaky asynchronous test and draw its Promise chain. Remove floating work, replace sleeps with observable state, and force the failure path to prove cleanup. That small exercise improves both test accuracy and debugging speed.

Interview Questions and Answers

What does async do to a JavaScript function?

It makes the function return a Promise. A returned value becomes a fulfilled result, and a thrown error becomes a rejection. The caller must await or return that Promise when its completion matters.

Does await block the event loop?

No. It suspends the current async function and allows the runtime to process other work. The continuation is scheduled after the awaited Promise settles.

What happens if a test misses await?

The runner may consider the test complete while work continues. A late assertion cannot reliably affect the result, and rejection may surface in another test or as an unhandled rejection. I trace and lint Promise ownership.

When is Promise.all appropriate in automation?

It is appropriate for independent setup or cleanup operations that can safely overlap. It is not appropriate for steps with data dependencies or simultaneous mutations of one browser page. I also account for the fact that other operations continue after one rejects.

How do you assert Promise rejection?

I await the runner's rejection assertion and verify stable error properties. Node's `assert.rejects` is one example. A manual catch must include an assertion that failure actually occurred.

Why should you not use async with forEach?

The array method discards callback return values, so the outer code does not wait. I use `for...of` for ordered work or `Promise.all` with `map` for bounded safe concurrency.

How do you test event-driven code?

I register the event Promise before triggering the action, reject on error, enforce a deadline or abort signal, and remove listeners in all outcomes. The test awaits that Promise as part of its completion chain.

How do you make polling reliable?

I use a monotonic deadline, reasonable interval, explicit transient and terminal states, and diagnostics with the last state and correlation ID. Deterministic authentication or schema errors fail immediately instead of being retried.

Frequently Asked Questions

Do async tests always need await?

They need to return or await every Promise that is part of the test contract. An async test callback returns its own Promise, but nested Promises still need to be awaited or deliberately tracked.

Why does my async test pass before the assertion runs?

The assertion is probably inside a Promise or callback that the test did not return or await. Trace the complete chain and enable type-aware no-floating-Promise linting.

Should I use sleep in asynchronous tests?

Prefer an observable condition, locator assertion, event, response, or bounded state poll. A fixed sleep waits too long when the system is fast and still fails when it is slower.

Can I use Promise.all in Playwright tests?

Use it for independent operations or to coordinate an event Promise with its trigger pattern. Do not send unrelated concurrent actions to one page, because they share mutable browser state.

How do I test an async function that should fail?

Await the runner's rejection assertion, such as `assert.rejects` in Node. Check the stable error type and contract, and ensure unexpected resolution fails the test.

What is wrong with forEach and async?

`Array.forEach` ignores Promise return values from its callback. Use a sequential `for...of` loop or await `Promise.all` over a mapped array when concurrency is safe.

How should async cleanup work?

Put it in an awaited `finally` block or async test hook. Track partially created resources and do not let an assertion failure skip deletion or browser cleanup.

Related Guides