Lesson 2 of 9 · Programming for Testers
JavaScript Essentials
The JavaScript that test automation actually uses: destructuring, spread, modules, closures, error handling and, above all, promises and async/await.
beginner 8 min read Updated
You will learn to
- Use destructuring, spread and optional chaining to work with test data
- Explain how promises and async/await work and why missing await causes flaky tests
- Organise helpers into ES modules with import and export
- Handle errors with try/catch and write clear thrown errors
Prerequisites
- Programming Fundamentals for Testers
Playwright, Cypress, WebdriverIO, Jest and Vitest are all JavaScript tools, and most of the bugs in JavaScript test suites come from a handful of language features: asynchronous code, references versus copies, and loose handling of missing data. This lesson covers the JavaScript you need to write reliable automation, assuming you already know the basics from Programming Fundamentals for Testers.
All examples run in Node.js 22 or the JavaScript Lab.
Working with data: destructuring, spread and optional chaining
API responses and fixtures are nested objects. Three modern features make them pleasant to work with.
Destructuring
Destructuring pulls values out of objects and arrays into variables:
const response = { status: 201, body: { id: 'u_42', email: 'ana@example.com', roles: ['admin'] } };
const { status, body } = response;
const { id, email } = body;
const [firstRole] = body.roles;
console.log(status, id, email, firstRole); // 201 u_42 ana@example.com admin
You can rename and give defaults at the same time, which is handy for optional config:
const config = { baseUrl: 'http://localhost:3000' };
const { baseUrl, timeout: timeoutMs = 30_000 } = config;
// timeoutMs is 30000 because config.timeout is undefined
Spread and rest
The spread syntax ... copies the contents of an array or object. It is the cleanest way to build variations of test data from a valid base payload:
const validUser = { name: 'Ana', email: 'ana@example.com', age: 30 };
const missingEmail = { ...validUser, email: undefined };
const tooYoung = { ...validUser, age: 17 };
const withExtraField = { ...validUser, isAdmin: true };
Rest parameters do the reverse, collecting remaining values:
const { email, ...withoutEmail } = validUser;
// withoutEmail is { name: 'Ana', age: 30 }: the field is actually absent, not undefined
That difference matters for API tests: a field set to undefined is dropped by JSON.stringify, but a field set to null is sent as null. Decide deliberately which case you are testing.
References vs copies
Objects and arrays are passed by reference. Changing a “copy” made with = changes the original:
const base = { name: 'Ana', tags: ['vip'] };
const alias = base;
alias.name = 'Ben';
console.log(base.name); // 'Ben': same object
const shallow = { ...base };
shallow.tags.push('beta');
console.log(base.tags); // ['vip', 'beta']: spread copies only one level deep
const deep = structuredClone(base);
deep.tags.push('gamma');
console.log(base.tags); // ['vip', 'beta']: unaffected
Optional chaining and nullish coalescing
When a value might be missing, ?. stops at null or undefined instead of throwing, and ?? supplies a fallback only for null or undefined:
const user = { profile: null };
user.profile?.address?.city; // undefined, no TypeError
user.profile?.address?.city ?? 'Unknown'; // 'Unknown'
const retries = 0;
retries || 3; // 3: || treats 0 as missing (often a bug)
retries ?? 3; // 0: ?? only replaces null and undefined
Asynchronous JavaScript
This is the most important section in the lesson. Browsers, HTTP calls, file reads and timers are all asynchronous: the operation starts, JavaScript keeps running, and the result arrives later. Nearly every line of a Playwright test is asynchronous.
Promises
A promise is an object representing a value that will be available later. It is pending, then either fulfilled with a value or rejected with an error.
const promise = fetch('https://jsonplaceholder.typicode.com/todos/1');
console.log(promise); // Promise { <pending> }
fetch is built into Node 22 and browsers. It returns immediately with a pending promise; the HTTP response comes later.
async and await
await pauses an async function until a promise settles, then gives you its value (or throws its error). This lets asynchronous code read top to bottom:
async function getTodo(id) {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
if (!res.ok) {
throw new Error(`GET /todos/${id} failed with ${res.status}`);
}
return await res.json();
}
const todo = await getTodo(1); // top-level await works in ES modules (.mjs or "type": "module")
console.log(todo.title);
Rules to remember:
awaitonly works inside anasyncfunction (or at the top level of an ES module).- An
asyncfunction always returns a promise, even if youreturn 5. - If you do not
awaita promise, your code continues without waiting for it.
The missing await bug
This is the single most common cause of flaky JavaScript tests:
// BROKEN: click() is not awaited
test('adds item to cart', async ({ page }) => {
page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
The click is started but the test does not wait for it. Sometimes the assertion’s retries cover the gap and the test passes; sometimes the test ends first and the click errors because the page has closed. The fix is one word:
await page.getByRole('button', { name: 'Add to cart' }).click();
Running things in parallel
await in sequence waits for each operation in turn. When operations are independent, Promise.all runs them concurrently and waits for all of them:
const [user, orders] = await Promise.all([
fetch(`${api}/users/42`).then((r) => r.json()),
fetch(`${api}/users/42/orders`).then((r) => r.json()),
]);
Promise.all rejects as soon as any promise rejects. Use Promise.allSettled when you want every result regardless of failures, for example when checking the health of several services.
Waiting without sleeping
Beginners often “fix” timing problems with fixed sleeps:
await new Promise((resolve) => setTimeout(resolve, 3000)); // waits 3 seconds, always
This makes suites slow when the app is fast and still flaky when it is slower than expected. Modern frameworks provide condition-based waiting instead; in Playwright, web-first assertions like await expect(locator).toBeVisible() retry until the condition is true or a timeout is reached. Use fixed sleeps only as a last resort while investigating.
Error handling
Use try/catch to handle errors from code that may fail, and throw to signal a failure with a useful message.
async function createUser(api, payload) {
const res = await fetch(`${api}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.status !== 201) {
const text = await res.text();
throw new Error(`Expected 201 creating user, got ${res.status}: ${text}`);
}
return res.json();
}
try {
await createUser('http://localhost:3000', { name: 'Ana' });
} catch (err) {
console.error('Setup failed:', err.message);
throw err; // rethrow so the test fails loudly
}
Modules: import and export
Real suites split code into files: page objects, API clients, data builders. Modern JavaScript uses ES modules:
// helpers/users.js
export function buildUser(overrides = {}) {
return {
name: 'Test User',
email: `user.${Date.now()}@example.com`,
...overrides,
};
}
export const ADMIN_ROLE = 'admin';
// tests/users.test.js
import { buildUser, ADMIN_ROLE } from '../helpers/users.js';
const admin = buildUser({ role: ADMIN_ROLE });
In Node, a file is treated as an ES module when it ends in .mjs or when the nearest package.json has "type": "module". Playwright’s TypeScript setup handles this for you.
Closures, briefly
A closure is a function that remembers variables from the scope where it was created. You use them, often without noticing, in factories and counters:
function makeIdGenerator(prefix) {
let n = 0;
return () => `${prefix}-${++n}`;
}
const nextOrderId = makeIdGenerator('ORD');
nextOrderId(); // 'ORD-1'
nextOrderId(); // 'ORD-2'
n is private to the returned function, which is a clean way to generate unique test data within a run.
JSON in JavaScript
You will convert between JSON text and JavaScript objects constantly:
const text = '{"id":1,"active":true,"tags":["a","b"]}';
const obj = JSON.parse(text); // string -> object
obj.active; // true
const back = JSON.stringify(obj, null, 2); // object -> pretty-printed string
JSON.parse throws a SyntaxError on invalid JSON, so an unexpected HTML error page from an API will fail loudly. The JSON Formatter is useful for inspecting payloads by eye.
Practice exercise
In the JavaScript Lab or a Node .mjs file, write an async function getUserSummaries(ids) that:
- Fetches
https://jsonplaceholder.typicode.com/users/<id>for every id in parallel. - Throws a clear error if any response is not OK.
- Returns an array of objects shaped like
{ id, name, city }, using destructuring.
Call it with [1, 2, 3] and print the result. A good solution uses Promise.all, awaits every promise, checks res.ok, and does not swallow errors.
Show a model answer
const API = 'https://jsonplaceholder.typicode.com';
async function getUser(id) {
const res = await fetch(`${API}/users/${id}`);
if (!res.ok) throw new Error(`GET /users/${id} returned ${res.status}`);
return res.json();
}
async function getUserSummaries(ids) {
const users = await Promise.all(ids.map((id) => getUser(id)));
return users.map(({ id, name, address: { city } }) => ({ id, name, city }));
}
const summaries = await getUserSummaries([1, 2, 3]);
console.log(summaries);Why it is good: the three requests run concurrently, every promise is awaited through Promise.all, a failed status produces an error naming the endpoint and status, and nested destructuring extracts city cleanly. If you prefer to be defensive about a missing address, use user.address?.city instead of nested destructuring.
Key takeaways
- Destructuring, spread and optional chaining make test data easy to read and vary; remember spread copies only one level deep.
- Most JavaScript test flakiness comes from missing
await. Lint for floating promises. - Use
Promise.allfor independent work and condition-based waits instead of fixed sleeps. - Never swallow errors in test code; throw errors with messages that say what was expected and what happened.
- ES modules (
import/export) are how you organise helpers, page objects and API clients.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- Compare Promise.all, Promise.allSettled, Promise.race and Promise.any. When would you use each in test code?
- Checked vs unchecked exceptions: how should a test framework handle exceptions?
- Explain the event loop, and why await inside forEach does not wait.
- Write a retry helper with exponential backoff for a flaky async operation.