Skip to content
QA Knowledge Hub

JavaScript and TypeScript interview questions for testers

Updated 2026-11-13 ยท 23 min read

Thirty JavaScript and TypeScript questions a Playwright, Cypress or API tester gets asked, from let and const to generics. Every example was run in Node or type-checked, and the async traps that cause flaky tests get the most space.

Playwright, Cypress, WebdriverIO, Postman scripts and k6 all use JavaScript, so interviews for those roles test the language as well as the tool. The questions that separate candidates are almost always about asynchronous code: a missing await is the most common cause of a test that passes when it should fail.

JavaScript basics

1. Why does a tester need JavaScript or TypeScript?

Because the tools are written for it: Playwright's most used binding, Cypress (JavaScript only), WebdriverIO, Postman's test scripts and k6 load scripts. Even in a Java team, front-end developers write JavaScript, and reading their code helps you find where a bug lives. TypeScript is JavaScript with types checked before the code runs; most new Playwright projects use it.

2. What is the difference between var, let and const?

var is function-scoped and older; let and const are block-scoped. const stops you reassigning the variable, but an object held in a const can still change. The classic trap is a loop with timers:

for (var i = 0; i < 3; i++) setTimeout(() => console.log("var:", i));
for (let j = 0; j < 3; j++) setTimeout(() => console.log("let:", j));

const order = { id: 1001, status: "PLACED" };
order.status = "PAID";        // allowed: the object is not frozen
// order = {};                // TypeError: Assignment to constant variable.
console.log(order.status);

This prints PAID, then var: 3 three times, then let: 0, let: 1, let: 2. With var all three callbacks share one i; let creates a new binding per iteration. Use const by default and let when you must reassign.

3. What is the difference between == and ===, and what are truthy and falsy values?

=== compares without converting types; == converts first, which gives surprises. Falsy values are false, 0, "", null, undefined and NaN; everything else is truthy.

console.log(0 == "0", 0 === "0");         // true false
console.log(null == undefined, null === undefined); // true false
console.log("" == 0, [] == false);        // true true

const qty = 0;
console.log(qty || 1);   // 1  -- 0 is falsy, so a real quantity of 0 is lost
console.log(qty ?? 1);   // 0  -- ?? only replaces null or undefined

The || line is a real bug pattern: a default meant for "missing" also replaces a valid zero. Test with zero, empty string and null separately.

4. How do you copy an object, and what is a shallow copy?

Spread ({ ...obj }) and Object.assign copy only the top level; nested objects are still shared. structuredClone makes a deep copy.

const base = { name: "Asha", address: { city: "Pune" } };
const shallow = { ...base };
const deep = structuredClone(base);

shallow.address.city = "Indore";
console.log(base.address.city);  // Indore: the spread copy shares the nested object
console.log(deep.address.city);  // Pune: structuredClone copied it

In tests this shows up as shared fixture data: one test edits a "copy" of the default user and the next test sees the change.

5. How do arrow functions differ from normal functions?

They are shorter, and they do not have their own this: they use the this of the code around them. A normal function's this depends on how it is called.

const cart = {
  items: [499, 1299],
  totalArrow() { return this.items.reduce((sum, p) => sum + p, 0); },
  printLater() {
    setTimeout(function () { console.log("function:", this?.items); }, 0);
    setTimeout(() => console.log("arrow:", this.items), 0);
  },
};
console.log(cart.totalArrow());
cart.printLater();

Output: 1798, then function: undefined, then arrow: [ 499, 1299 ]. Inside the normal callback, this is not the cart (in Node it is the timer object). Arrow functions are the right choice for callbacks inside methods.

6. What are destructuring, spread, rest and default parameters?

Destructuring pulls values out of objects and arrays; rest (...) collects what is left; spread expands; defaults fill in missing arguments. Test data builders use all four.

const response = { id: 7, user: { name: "Asha", email: "[email protected]" }, roles: ["buyer", "seller"] };

const { id, user: { email }, roles: [firstRole, ...otherRoles] } = response;
console.log(id, email, firstRole, otherRoles);

function makeUser({ name = "Test User", role = "buyer", ...rest } = {}) {
  return { name, role, ...rest };
}
console.log(makeUser({ role: "admin", phone: "9000000001" }));
console.log(makeUser());

Output: 7 [email protected] buyer [ 'seller' ], then { name: 'Test User', role: 'admin', phone: '9000000001' }, then { name: 'Test User', role: 'buyer' }. The = {} default is what lets makeUser() be called with no argument.

7. Which array methods do you use most in test code?

map (transform), filter (keep some), reduce (combine into one value), find (first match), some and every (yes/no checks). They keep assertions on lists short and readable.

const products = [
  { name: "Running Shoes", price: 2999, stock: 40 },
  { name: "Leather Wallet", price: 799, stock: 0 },
  { name: "Steel Bottle", price: 349, stock: 12 },
];

const names = products.map((p) => p.name);
const inStock = products.filter((p) => p.stock > 0);
const stockValue = products.reduce((sum, p) => sum + p.price * p.stock, 0);
const wallet = products.find((p) => p.name.includes("Wallet"));
const anyFree = products.some((p) => p.price <= 0);
const allPriced = products.every((p) => Number.isInteger(p.price) && p.price > 0);

console.log(names, inStock.length, stockValue, wallet.price, anyFree, allPriced);

This prints the three names, 2, 124148, 799, false, true. every returns true for an empty array, so check the length too when "no results" would be a bug.

Likely follow-up: What does find return when nothing matches? undefined, not an error. Assert that the result exists before reading a property from it, or the failure message will be a confusing TypeError.

8. What is a closure? Give a tester's example.

A function that remembers the variables of the place where it was created, even after that code has finished. It gives you private state without a class.

function emailFactory(prefix) {
  let count = 0;                       // private to this factory
  return () => `${prefix}+${++count}@example.com`;
}

const nextEmail = emailFactory("asha");
console.log(nextEmail()); // [email protected]
console.log(nextEmail()); // [email protected]

Unique test emails like this stop "already registered" failures when tests run more than once against the same environment. Across parallel workers, add something per worker (a worker index or a random part), because each worker has its own counter.

9. What do optional chaining (?.) and nullish coalescing (??) do?

a?.b returns undefined instead of throwing when a is null or undefined. x ?? y gives y only when x is null or undefined.

const order = { id: 1001, payment: null };

console.log(`Order ${order.id} has ${order.items?.length ?? 0} items`);
console.log(order.payment?.upiId ?? "no UPI id");
// order.payment.upiId would throw: Cannot read properties of null

Output: Order 1001 has 0 items and no UPI id. Use them in helpers and reports. In assertions, be careful: ?. can turn a missing field (a bug) into a quiet undefined that a weak assertion accepts.

10. What is the difference between ES modules and CommonJS?

CommonJS is Node's original system: require() and module.exports, loaded synchronously. ES modules are the standard: import and export, and they allow await at the top level of a file. A package chooses with "type": "module" in package.json or with the .mjs and .cjs extensions. Mixing them is a common set-up error ("require is not defined in ES module scope", or "Cannot use import statement outside a module"); the fix is to make the project's module type and the file match.

Asynchronous JavaScript

11. How does the event loop decide the order things run in?

JavaScript runs one piece of code at a time. Synchronous code runs first; then queued promise callbacks (microtasks); then timers and I/O callbacks (macrotasks).

console.log("1 sync");
setTimeout(() => console.log("4 timer (macrotask)"), 0);
Promise.resolve().then(() => console.log("3 promise (microtask)"));
console.log("2 sync");

It prints in the numbered order. The practical point for testers: a browser action or HTTP call has not happened when the line that starts it returns. You must wait for the promise.

12. What is a promise, and how do you chain one?

An object for a result that arrives later. It is pending, then either fulfilled with a value or rejected with an error. .then handles success, .catch handles a rejection anywhere earlier in the chain, .finally runs either way.

const pay = (amount) =>
  new Promise((resolve, reject) => {
    setTimeout(() => (amount > 0 ? resolve({ status: "PAID", amount }) : reject(new Error("Invalid amount"))), 50);
  });

pay(499)
  .then((r) => { console.log(r.status); return pay(0); })
  .then(() => console.log("not reached"))
  .catch((e) => console.log("caught:", e.message))
  .finally(() => console.log("done"));

Output: PAID, caught: Invalid amount, done. Returning the second promise from .then is what keeps the chain in order.

13. What goes wrong if you forget await?

The function carries on before the work finishes, so you check a result that is not there yet, or the test ends before an assertion runs and still reports a pass.

const saveOrder = () => new Promise((resolve) => setTimeout(() => resolve("saved"), 100));
let status = "pending";

async function withoutAwait() {
  saveOrder().then((s) => (status = s));   // floating promise: nobody waits for it
  console.log("without await:", status);    // pending
}

async function withAwait() {
  status = await saveOrder();
  console.log("with await:", status);       // saved
}

await withoutAwait();
await withAwait();

In Playwright, a missing await before expect(locator).toHaveText(...) means the assertion may never be checked before the test finishes. Turn on a lint rule for floating promises (for example @typescript-eslint/no-floating-promises) so the tool catches it.

Likely follow-up: Why is it hard to notice? The test often passes, and a test that passes for the wrong reason looks exactly like a healthy one. Break the app on purpose once and confirm the test goes red.

14. What is the difference between Promise.all, allSettled, race and any?

const call = (name, ms, ok = true) =>
  new Promise((res, rej) => setTimeout(() => (ok ? res(name) : rej(new Error(name + " failed"))), ms));

console.log(await Promise.all([call("profile", 30), call("orders", 10)]));
try {
  await Promise.all([call("profile", 30), call("orders", 10, false)]);
} catch (e) {
  console.log("all rejects on first failure:", e.message);
}
const settled = await Promise.allSettled([call("profile", 30), call("orders", 10, false)]);
console.log(settled.map((s) => s.status));
console.log("race:", await Promise.race([call("slow", 50), call("fast", 5)]));
console.log("any:", await Promise.any([call("a", 5, false), call("b", 20)]));

Output: [ 'profile', 'orders' ], then orders failed, then [ 'fulfilled', 'rejected' ], race: fast, any: b. all fails fast; allSettled waits for everything and tells you which failed (good for cleanup of many test records); race settles with the first to finish, success or failure; any takes the first success. The same idea matters in Playwright: start waiting for an event (a download, a new tab) before the click that causes it, by storing the wait's promise first or by putting both in Promise.all, or the event can fire before anyone listens.

15. Why does await inside forEach not work?

forEach ignores the promises its callback returns, so nothing waits for them. Use for...of to go one at a time, or Promise.all with map to run in parallel and still wait.

const deleteUser = (id) => new Promise((r) => setTimeout(() => { console.log("deleted", id); r(); }, 20 * (4 - id)));
const ids = [1, 2, 3];

ids.forEach(async (id) => { await deleteUser(id); });
console.log("forEach finished?  It returned already, nothing was awaited");

await new Promise((r) => setTimeout(r, 100));
console.log("--- for...of: one at a time, in order");
for (const id of ids) await deleteUser(id);

console.log("--- Promise.all with map: in parallel, and awaited");
await Promise.all(ids.map((id) => deleteUser(id)));
console.log("all deleted");

The forEach line prints before any deletion, and the deletions finish in reverse order (3, 2, 1) because the shorter timers end first. In a cleanup hook, that means the test run can end with test data still in the database.

16. How do you handle errors in async code?

Wrap await in try/catch, or attach .catch to the promise. Check HTTP status yourself: fetch only rejects on network failure, not on a 4xx or 5xx response.

const BASE = process.env.BASE_URL ?? "http://localhost:3001";

async function getOrder(id) {
  const res = await fetch(`${BASE}/api/orders/${id}`);   // no token: expect 401
  if (!res.ok) throw new Error(`GET /api/orders/${id} returned ${res.status}`);
  return res.json();
}

try {
  await getOrder(1001);
} catch (e) {
  console.log("handled:", e.message);
}

Against ShopLite this prints handled: GET /api/orders/1001 returned 401. A rejection that nobody handles makes recent Node versions exit with an error, which in a test runner usually shows as a confusing failure in a different test.

17. Why is a fixed setTimeout a bad wait, and what do you write instead?

A fixed sleep is either too short on a slow day (flaky) or too long on every other day (slow). Wait for a condition with a deadline instead. Test tools have this built in (Playwright's auto-waiting and expect.poll); outside them, write a small polling helper:

const BASE = process.env.BASE_URL ?? "http://localhost:3001";

async function waitFor(check, { timeout = 5000, interval = 200, message = "condition" } = {}) {
  const deadline = Date.now() + timeout;
  let lastError;
  while (Date.now() < deadline) {
    try {
      const value = await check();
      if (value) return value;
    } catch (e) {
      lastError = e;               // keep the last reason, for the failure message
    }
    await new Promise((r) => setTimeout(r, interval));
  }
  throw new Error(`Timed out after ${timeout} ms waiting for ${message}${lastError ? `: ${lastError.message}` : ""}`);
}

// Example: wait until the app answers its health check
const ok = await waitFor(async () => (await fetch(`${BASE}/api/health`)).ok, { message: "ShopLite health" });
console.log("healthy:", ok);

The detail interviewers like: the timeout error says what it was waiting for and the last reason it failed.

TypeScript

18. What does TypeScript add, and what does it not do?

Types that are checked before the code runs: wrong argument types, misspelt properties and missing await results (using a Promise as if it were the value) are caught in the editor. The types are erased when the code runs, so TypeScript does not validate data coming from an API at runtime.

function lineTotal(price: number, qty: number): number {
  return price * qty;
}

console.log(lineTotal(499, 2));
// lineTotal("499", 2);  // tsc: Argument of type 'string' is not assignable to parameter of type 'number'.

The commented line is a real compiler message from tsc. If an API returns "price": "499" as a string, TypeScript will not notice at runtime; an assertion or a schema check must.

19. What is the difference between an interface and a type alias?

Both describe the shape of an object. An interface can be extended and can be declared again to add fields (declaration merging). A type alias can also name unions, literal types and other combinations that an interface cannot.

interface Product {
  id: number;
  name: string;
  price: number;        // paise
}

interface DigitalProduct extends Product {
  downloadUrl: string;
}

type Status = "PLACED" | "PAID" | "CANCELLED";
type ApiResult = Product | { error: string };

const ebook: DigitalProduct = { id: 9, name: "Test Design Notes", price: 19900, downloadUrl: "https://example.com/dl/9" };
const s: Status = "PAID";
function show(r: ApiResult): string {
  return "error" in r ? r.error : r.name;   // "in" narrows the union
}

console.log(ebook.name, s, show({ error: "Product not found" }), show(ebook));

Many teams use interfaces for object shapes and type for unions. Either answer is fine if you can say why.

20. What are union types and narrowing? Show an exhaustive check.

A union says a value is one of several types. Narrowing (with typeof, in, or a shared field like status) tells the compiler which one you have. A never check makes the compiler complain when someone adds a new case and forgets to handle it.

type Payment =
  | { status: "SUCCESS"; transactionId: string }
  | { status: "FAILED"; reason: string }
  | { status: "PENDING" };

function describe(p: Payment): string {
  switch (p.status) {
    case "SUCCESS":
      return `paid, txn ${p.transactionId}`;
    case "FAILED":
      return `failed: ${p.reason}`;
    case "PENDING":
      return "waiting for the bank";
    default: {
      const unreachable: never = p;   // a new status added later makes this line a compile error
      return unreachable;
    }
  }
}

console.log(describe({ status: "SUCCESS", transactionId: "T-0001" }));
console.log(describe({ status: "FAILED", reason: "UPI PIN incorrect" }));

I checked the claim: adding a REFUNDED status without a case makes tsc fail with "Type ... is not assignable to type 'never'". For a UPI-style payment helper this is exactly the guard you want.

21. What are generics? Give a test-code example.

A type parameter, so one function works for many types while keeping them precise. A typed API helper is the usual example.

const BASE = process.env.BASE_URL ?? "http://localhost:3001";

interface Page<T> {
  items: T[];
  total: number;
  page: number;
  pages: number;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

async function getJson<T>(path: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`);
  if (!res.ok) throw new Error(`GET ${path} returned ${res.status}`);
  return (await res.json()) as T;   // trusted only as far as the API keeps its contract
}

const first = await getJson<Page<Product>>("/api/products?limit=3");
console.log(first.items.map((p) => p.name), `${first.page}/${first.pages}`);

Against ShopLite this printed three product names and 1/9. Be honest about the as T: it tells the compiler to trust the response. It does not check it.

22. What is the difference between any and unknown?

any switches type checking off: you can do anything with it, and mistakes reach runtime. unknown means "not known yet": you must check the type before using it. Use unknown for parsed JSON and caught errors.

function parseQty(input: unknown): number {
  if (typeof input === "number" && Number.isInteger(input)) return input;
  if (typeof input === "string" && /^\d+$/.test(input)) return Number(input);
  throw new Error(`Not a quantity: ${JSON.stringify(input)}`);
}

const fromApi: unknown = JSON.parse('{"qty":"3"}').qty;
console.log(parseQty(fromApi));

const risky: any = JSON.parse('{"qty":"3"}');
console.log(risky.qty.toFixed);   // undefined: no compile error, and no runtime error until you call it

Output: 3, then undefined. Type assertions (value as Product) are similar to any in risk: they silence the compiler without checking anything.

23. What are utility types like Partial, Pick, Omit and Record? Use them in a data builder.

They build new types from existing ones: Partial makes every field optional, Pick keeps some, Omit drops some, Record maps keys to a value type.

interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  role: "buyer" | "admin";
}

type NewUser = Omit<User, "id">;
type UserUpdate = Partial<Pick<User, "name" | "phone">>;
type UsersByRole = Record<User["role"], User[]>;

let counter = 0;
function buildUser(overrides: Partial<NewUser> = {}): NewUser {
  counter += 1;
  return {
    name: "Asha Test",
    email: `asha+${counter}@example.com`,
    phone: "9000000001",
    role: "buyer",
    ...overrides,
  };
}

const admin = buildUser({ role: "admin" });
const patch: UserUpdate = { phone: "9000000002" };
const grouped: UsersByRole = { buyer: [], admin: [{ id: 1, ...admin }] };
console.log(admin.email, patch, grouped.admin.length);

The builder gives every test a valid user and lets each test change only the field it cares about, so a test reads as "an admin user" rather than a wall of fields.

24. How is TypeScript run in a test project? What does strict do?

Test runners such as Playwright and Vitest compile TypeScript themselves; you mostly run tsc --noEmit in CI to type-check. strict: true turns on the checks that catch real bugs, such as null and undefined handling and implicit any. Recent Node versions can also run .ts files directly by stripping the types: the Node documentation says this is on by default from v22.18.0 and v23.6.0 and stable from v24.12.0. Node does not type-check and ignores tsconfig.json, so you still need tsc for checking.

{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "strict": true,
    "noEmit": true
  }
}

25. Why might a project avoid enum?

Because an enum produces JavaScript code, unlike most TypeScript syntax, which is simply removed. Node's built-in type stripping rejects it (I got ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX running an enum with Node 24), and so do constructor parameter properties like constructor(private page: Page). The TypeScript option erasableSyntaxOnly reports both. A union of string literals, or an object marked as const, does the same job:

const OrderStatus = {
  Placed: "PLACED",
  Paid: "PAID",
  Cancelled: "CANCELLED",
} as const;

type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

function canCancel(s: OrderStatus): boolean {
  return s === OrderStatus.Placed;
}

console.log(canCancel(OrderStatus.Placed), canCancel("PAID"));

Enums are not wrong, and tools that compile TypeScript fully handle them. Say what your project's tooling supports.

Practical test code

26. Write an API test without any test framework installed.

Node has a built-in test runner and assertion module. Two real checks against ShopLite:

import { test } from "node:test";
import assert from "node:assert/strict";

const BASE = process.env.BASE_URL ?? "http://localhost:3001";

test("search is case-insensitive", async () => {
  const lower = await (await fetch(`${BASE}/api/products?q=shoes`)).json();
  const upper = await (await fetch(`${BASE}/api/products?q=SHOES`)).json();
  assert.ok(lower.total > 0, "expected at least one match");
  assert.equal(upper.total, lower.total);
});

test("an unknown product is a 404 with a message", async () => {
  const res = await fetch(`${BASE}/api/products/99999`);
  assert.equal(res.status, 404);
  const body = await res.json();
  assert.match(JSON.stringify(body), /not found/i);
});

Saved as products.test.ts and run with node --test products.test.ts, both pass against ShopLite with BUGS=none. With the bugs switched on, both fail, because ShopLite plants a case-sensitive search and a 200-with-empty-body for missing products. A test that fails on the broken build and passes on the fixed one is the proof that it checks something.

27. How do you read test data from a JSON file?

import { readFile } from "node:fs/promises";

const users = JSON.parse(await readFile(new URL("./users.json", import.meta.url), "utf8"));
for (const u of users) console.log(u.email, u.expectedStatus);

With a users.json next to the script containing two rows ([email protected] with the right password and 200, and with a wrong one and 401), it prints both. Building the path from import.meta.url makes it work whatever folder the command is run from, which matters in CI. Never put real passwords in data files; use the app's test accounts and read secrets from environment variables.

28. Write a page object in TypeScript.

import type { Locator, Page } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(page: Page) {          // no "constructor(private page: Page)": see question 25
    this.page = page;
    this.email = page.getByTestId("login-email");
    this.password = page.getByTestId("login-password");
    this.submit = page.getByTestId("login-submit");
    this.error = page.getByTestId("form-error");
  }

  async open(): Promise<void> {
    await this.page.goto("/#/login");
  }

  async loginAs(email: string, password: string): Promise<void> {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}

And a test using it, with the assertion in the test, not the page object:

import { test, expect } from "@playwright/test";
import { LoginPage } from "./login-page";

test("a wrong password shows an error", async ({ page }) => {
  const login = new LoginPage(page);
  await login.open();
  await login.loginAs("[email protected]", "not-the-password");
  await expect(login.error).toHaveText("Email or password is incorrect");
});

The test ids and the error text are ShopLite's real ones (they match its starter tests). Both files were type-checked against @playwright/test 1.65.0; they were not run in a browser here. Locators are created once in the constructor because a Playwright locator is lazy: it finds the element each time it is used.

29. Count, group and sort: the coding questions that come up most.

const orders = [
  { id: 1, status: "PAID", amount: 499 },
  { id: 2, status: "CANCELLED", amount: 1299 },
  { id: 3, status: "PAID", amount: 349 },
  { id: 4, status: "PLACED", amount: 2999 },
];

const countByStatus = orders.reduce((acc, o) => {
  acc[o.status] = (acc[o.status] ?? 0) + 1;
  return acc;
}, {});
console.log(countByStatus); // { PAID: 2, CANCELLED: 1, PLACED: 1 }

const byStatus = Object.groupBy(orders, (o) => o.status);
console.log(byStatus.PAID.map((o) => o.id)); // [ 1, 3 ]

const prices = [999, 10000, 2500, 349];
console.log([...prices].sort());                // [ 10000, 2500, 349, 999 ]  compared as text
console.log([...prices].sort((a, b) => a - b)); // [ 349, 999, 2500, 10000 ]

const isSortedAsc = (list) => list.every((v, i) => i === 0 || list[i - 1] <= v);
console.log(isSortedAsc([349, 999, 2500]), isSortedAsc([999, 349]));

The default sort() compares as strings, which is a real production bug: ShopLite plants exactly this in its price sort. isSortedAsc is how you check a "sort by price" feature from an API response. Object.groupBy is recent; on an older Node, use the reduce form. sort changes the array in place, which is why the copies use [...prices].

30. Write a retry helper with backoff. When should a test retry at all?

async function retry(fn, { attempts = 3, baseDelayMs = 200, retryIf = () => true } = {}) {
  let lastError;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await fn(attempt);
    } catch (e) {
      lastError = e;
      if (attempt === attempts || !retryIf(e)) break;          // do not retry what cannot heal
      await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1))); // 200, 400, 800 ms...
    }
  }
  throw new Error(`Gave up: ${lastError.message}`, { cause: lastError });
}

const transient = (e) => /^(502|503|504)/.test(e.message);

let calls = 0;
const flakyCall = async () => {
  calls++;
  if (calls < 3) throw new Error("503 Service Unavailable");
  return "ok";
};
console.log(await retry(flakyCall, { baseDelayMs: 10, retryIf: transient }), "after", calls, "calls");

let badCalls = 0;
try {
  await retry(async () => { badCalls++; throw new Error("400 Bad Request"); }, { baseDelayMs: 10, retryIf: transient });
} catch (e) {
  console.log(e.message, "after", badCalls, "call");
}

Output: ok after 3 calls, then Gave up: 400 Bad Request after 1 call. Retry set-up steps that hit a known unstable dependency, and only for errors that can heal. Do not wrap assertions in retries to make a red test green: that hides real intermittent bugs.

Quick quiz

  1. What does [10, 9, 1].sort() return?
  2. const config = { retries: 0 }. What is config.retries || 2, and what is config.retries ?? 2?
  3. Inside an async function, you write items.forEach(async (i) => await save(i)) and then console.log("done"). Has every item been saved when "done" prints?
  4. Which one gives you the result of every promise even when some fail: Promise.all or Promise.allSettled?
  5. True or false: if a function is typed to return Promise<Product>, TypeScript checks at runtime that the API really returned a product.
Answers
  1. [ 1, 10, 9 ]. The default sort compares strings. Pass (a, b) => a - b for numbers.
  2. 2 with || (zero is falsy) and 0 with ?? (zero is not null or undefined).
  3. No. forEach does not wait for the async callbacks. Use for...of with await, or await Promise.all(items.map(save)).
  4. Promise.allSettled.
  5. False. Types are erased before the code runs. Check the response with assertions or a schema.

Practise it

Start the free ShopLite app with BUGS=none, run the two API checks from question 26, then restart it with the bugs on and watch them fail. Its starter-tests/ folder is a TypeScript Playwright project: add the page object from question 28 and move one of the existing tests onto it. For the tool questions that usually follow, see the Playwright and Cypress pages.

Quick answers

Do I need TypeScript, or is JavaScript enough for a Playwright or Cypress role?

Read the job description. Many Playwright projects are written in TypeScript, so being able to read types, interfaces and generics helps even if you write mostly plain JavaScript. The language underneath is the same; TypeScript adds compile-time checks.

Which Node version were the examples run on?

Node v24.22.2 for the JavaScript and the .ts files (Node strips the types itself), and TypeScript 5.9.3 with strict mode for type-checking. The Playwright page object was type-checked against @playwright/test 1.65.0 but not run in a browser.

Will I be asked to write code in a JavaScript tester interview?

Often, yes: small array, string and async exercises are common. The examples in the coding section of this page are the kind of size to expect.

Sources

Want a project you can show in interviews?

The portfolio project kit walks you through testing a deliberately buggy app with Selenium or Playwright and API tests, then explaining it. The app itself is free.