Lesson 13 of 14

Lesson 13 — Async JavaScript for QA automation

Title: Promises, async / await, timers, and API-style workflows

Description: JavaScript uses asynchronous programming to handle operations like network requests, timers, and file access. Promises represent values that will be available later and can be handled with .then() and .catch(). async functions always return a Promise, and await pauses execution until that Promise is resolved, making async code easier to read. This lesson also covers error handling with try / catch / finally and the basics of how the event loop schedules tasks.

Why it matters for QA: Test frameworks like Playwright and other API clients rely on async behavior. If you forget to use await, tests may continue before data is ready, leading to flaky results or incorrect assertions against unresolved Promises.


A Promise in JavaScript is an object that represents a future result of an asynchronous operation.

It helps JavaScript handle things that take time, for example:

  • loading data from API
  • reading a file
  • waiting for a timer
  • database requests

Real-life idea

Imagine you order pizza 🍕

  • Pending → pizza is cooking
  • Resolved (fulfilled) → pizza arrived
  • Rejected → delivery failed

Promise States

A Promise has 3 states:

StateMeaning
pendingstill working
fulfilledoperation successful
rejectedoperation failed

Basic Syntax

javascriptjavascript
const promise = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("Done!");
  } else {
    reject("Error!");
  }
});

Using .then() and .catch()

javascriptjavascript
promise
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.log(error);
  });

1. Simple Example with setTimeout

javascriptjavascript
console.log("before");

setTimeout(() => {
  console.log("timer finished");
}, 1000);

console.log("after");

The output is:

texttext
before
after
timer finished

The timer callback runs later from the macrotask queue. Playwright actions and HTTP requests also finish later, so we usually handle them with Promises.


2. A Promise is a future result

A Promise can be pending, fulfilled, or rejected.

javascriptjavascript
const resultPromise = new Promise((resolve) => {
  resolve("passed");
});

resultPromise.then((result) => {
  console.log(result);
});

In automation code, await is usually better than manual .then() when the steps run one after another. It makes the code easier to read and helps avoid forgotten Promises.


3. async functions always return Promises

javascriptjavascript
async function getStatus() {
  return "ok";
}

const statusPromise = getStatus();
console.log(statusPromise); // Promise

getStatus().then((status) => {
  console.log(status); // "ok"
});

Because the function uses async, JavaScript automatically returns a Promise. So "ok" is returned as a Promise value.


4. await pauses inside an async function

javascriptjavascript
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function runCheck() {
  console.log("starting");
  await delay(500);
  console.log("finished");
}

runCheck();

await can be used inside async functions. Some environments also allow top-level await in modules.


5. The classic QA bug: forgetting await

javascriptjavascript
async function getUserName() {
  return "Alice";
}

async function testUserName() {
  const userNamePromise = getUserName();
  console.log(userNamePromise); // Promise, not "Alice"

  const userName = await getUserName();
  console.log(userName); // "Alice"
}

testUserName();

In Playwright this mistake looks like:

javascriptjavascript
// Wrong mental model: the click has been started, not completed.
page.getByRole("button", { name: "Save" }).click();

// Correct: the test waits for the action to finish or fail.
await page.getByRole("button", { name: "Save" }).click();

6. Handling async errors

Use try / catch when the test or helper can fail and you want to add context.

javascriptjavascript
async function fetchUser(id) {
  if (!id) {
    throw new Error("User id is required");
  }

  return { id, name: "Alice" };
}

async function run() {
  try {
    const user = await fetchUser("u-123");
    console.log(user.name);
  } catch (error) {
    console.error("Could not fetch user:", error.message);
    throw error;
  }
}

run();

Do not use empty catch blocks. Log the error or throw it again with more information so it is easier to debug test failures in CI.


7. Sequential vs parallel async work

Use sequential await when order matters.

javascriptjavascript
async function login() {
  console.log("login");
}

async function openDashboard() {
  console.log("dashboard");
}

async function runSequential() {
  await login();
  await openDashboard();
}

Use Promise.all when operations are independent.

javascriptjavascript
async function getProfile() {
  return { name: "Alice" };
}

async function getOrders() {
  return [101, 102];
}

async function loadData() {
  const [profile, orders] = await Promise.all([getProfile(), getOrders()]);
  console.log(profile.name, orders.length);
}

loadData();

Two timers in parallel — both Promises start at once; Promise.all resolves when all are done (about 2 seconds here, not 3):

javascriptjavascript
const task1 = new Promise((resolve) => {
  setTimeout(() => {
    resolve("Task 1 finished");
  }, 1000);
});

const task2 = new Promise((resolve) => {
  setTimeout(() => {
    resolve("Task 2 finished");
  }, 2000);
});

Promise.all([task1, task2]).then((results) => {
  console.log(results);
  // ["Task 1 finished", "Task 2 finished"] — same order as the array passed to Promise.all
});

QA note: Result order matches the input array order, even when task1 finishes last. If any Promise rejects, Promise.all rejects immediately (use Promise.allSettled when you need every outcome).


8. Six static methods on Promise

Besides new Promise(...), the Promise class provides helpers to combine or create Promises:

MethodWhat it does
allwaits all succeed
allSettledwaits all finish
racefirst finished wins
anyfirst success wins
resolvecreates success promise
rejectcreates failed promise

QA hints:

  • all — all must pass; one failure rejects the whole result (good when every response is required).
  • allSettled — use when you need a report per call (pass and fail), for example cleanup or polling several endpoints.
  • race — first settled Promise wins (success or failure); useful for timeouts.
  • any — first fulfilled Promise wins; rejects only if all fail.
  • resolve / reject — wrap a value or error in a Promise (handy in tests and stubs).
javascriptjavascript
// Already-resolved Promise (no async work)
const ready = Promise.resolve({ status: "ok" });
ready.then((data) => console.log(data.status));

// Already-rejected Promise
Promise.reject(new Error("setup failed")).catch((error) => {
  console.error(error.message);
});

9. fetch() — HTTP requests return a Promise

fetch(url) is built into browsers and Node (modern versions). It returns a Promise that resolves to a Response. The body is often JSON — call response.json(), which returns another Promise.

javascriptjavascript
fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log(data.title);
  })
  .catch((error) => {
    console.error("Request failed:", error.message);
  });

Same flow with async / await (common in test helpers):

javascriptjavascript
async function loadPost() {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/posts/1"
  );

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const data = await response.json();
  console.log(data.title);
}

loadPost();

QA note: fetch does not reject on 404 or 500 — only on network errors. Always check response.ok or response.status before asserting on the body. In Playwright you often use request from the test fixture instead of raw fetch, but the Promise model is the same: wait for the async call to finish before assertions.


10. Avoid forEach with async callbacks

forEach ignores the Promise returned by an async callback. It does not await it and does not pause the outer function.

await inside the callback only waits inside that callback, not in the caller. So the line after forEach runs immediately — a common source of flaky tests (assertions run before API calls or UI steps finish).

javascriptjavascript
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function deleteUser(id) {
  await delay(500); // simulate slow API call
  console.log(`deleted ${id}`);
}

async function wrongCleanup() {
  const userIds = ["u1", "u2", "u3"];

  userIds.forEach(async (id) => {
    await deleteUser(id); // await works only inside this callback
  });

  console.log("all deleted"); // runs right away — too early!
}

wrongCleanup();

Wrong output (note the order):

texttext
all deleted
deleted u1
deleted u2
deleted u3

The test thinks cleanup is done, but deletions are still in progress.

Prefer for...of when each step must finish before the next one:

javascriptjavascript
async function correctSequential() {
  const userIds = ["u1", "u2", "u3"];

  for (const id of userIds) {
    await deleteUser(id);
  }

  console.log("all deleted"); // runs after u1, u2, u3
}

Or Promise.all with map when operations are independent and you want to wait for all of them:

javascriptjavascript
async function correctParallel() {
  const userIds = ["u1", "u2", "u3"];

  await Promise.all(userIds.map((id) => deleteUser(id)));
  console.log("all deleted"); // runs after all three API calls
}

Official docs


Quick recap

TopicTakeaway for QA automation
PromiseRepresents work that may finish later
asyncMakes a function return a Promise
awaitWaits for a Promise to fulfill or reject
try / catchAdds context to async failures
Promise.allRuns independent async work together
Static methodsall, allSettled, race, any, resolve, reject
fetchReturns a Promise; check response.ok and await .json()
for...ofBest for ordered async loops

Suggested exercises

  1. Write delay(ms) and use it to log "start", wait 1 second, then log "end".
  2. Create async function getToken() that returns "abc" and log the resolved value with await.
  3. Write an async loop that prints three test names one by one using for...of.
  4. Rewrite the same loop with Promise.all and explain when this is safe.
  5. Call fetch on a public JSON API, log one field from the body, and handle a non-OK status with throw new Error(...).

Homework

Short tasks (about 15-20 minutes). Click a task title to reveal the prompt.

Task 1: await a helper

Create an async function getRunId() that returns "run-001". Call it inside another async function, store the result in runId using await, and print it with console.log.

Task 2: missing await diagnosis

Run this code and explain the output:

javascriptjavascript
async function value() {
  return 42;
}

console.log(value());

Then fix it so the console prints 42.

Task 3: try/catch context

Write an async function requireEmail(email) that throws new Error("Email is required") if email is empty. Call this function inside a try / catch block, log a clear error message, and then rethrow the error so it can be handled higher up.

Task 4: sequential async loop

Given const ids = ["u1", "u2", "u3"], use a for...of loop and await loadUser(id) for each item one by one.

Task 5: independent async setup

Create two async functions: createUser() and createOrder(). Run them together using Promise.all, wait for both results, and then log the returned values.