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:
| State | Meaning |
|---|---|
| pending | still working |
| fulfilled | operation successful |
| rejected | operation failed |
Basic Syntax
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Done!");
} else {
reject("Error!");
}
});
Using .then() and .catch()
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error);
});
1. Simple Example with setTimeout
console.log("before");
setTimeout(() => {
console.log("timer finished");
}, 1000);
console.log("after");
The output is:
before
after
timer finishedThe 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.
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
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
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
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:
// 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.
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.
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.
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):
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:
| Method | What it does |
|---|---|
all | waits all succeed |
allSettled | waits all finish |
race | first finished wins |
any | first success wins |
resolve | creates success promise |
reject | creates 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).
// 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.
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):
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).
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):
all deleted
deleted u1
deleted u2
deleted u3The test thinks cleanup is done, but deletions are still in progress.
Prefer for...of when each step must finish before the next one:
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:
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
- MDN — Promise
- MDN — async function
- MDN — await
- MDN — Promise.all
- MDN — Promise.allSettled
- MDN — Promise.race
- MDN — Promise.any
- MDN — fetch
Quick recap
| Topic | Takeaway for QA automation |
|---|---|
| Promise | Represents work that may finish later |
async | Makes a function return a Promise |
await | Waits for a Promise to fulfill or reject |
try / catch | Adds context to async failures |
Promise.all | Runs independent async work together |
| Static methods | all, allSettled, race, any, resolve, reject |
fetch | Returns a Promise; check response.ok and await .json() |
for...of | Best for ordered async loops |
Suggested exercises
- Write
delay(ms)and use it to log"start", wait 1 second, then log"end". - Create
async function getToken()that returns"abc"and log the resolved value withawait. - Write an async loop that prints three test names one by one using
for...of. - Rewrite the same loop with
Promise.alland explain when this is safe. - Call
fetchon a public JSON API, log one field from the body, and handle a non-OK status withthrow 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:
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.