Skip to content
SDETRole
SDETRole

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:

  • await only works inside an async function (or at the top level of an ES module).
  • An async function always returns a promise, even if you return 5.
  • If you do not await a 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:

  1. Fetches https://jsonplaceholder.typicode.com/users/<id> for every id in parallel.
  2. Throws a clear error if any response is not OK.
  3. 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.all for 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.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →