JS
…

JS Tutorial

JavaScript from zero to interview-ready

52 chapters written so a QA or SDET can prepare here: language core, async, JSON, HTTP, cookies, Node/npm, assertions, and a TypeScript map. Each chapter has a plain-words box, worked examples with printed results, common mistakes, and a practice link. Read in order; arrow keys turn the page.

Page 1 of 52 · Getting started · 14 min

What is JavaScript?

JavaScript is the language that makes a web page do something after it has loaded: react to a click, check a form, talk to an API, update a label. The same language also runs outside the browser. Node.js uses it to start test runners, read files, and call HTTP APIs. Playwright, Cypress, and most modern UI automation are JavaScript (or TypeScript that compiles to it). If you are a tester, this is not a “nice extra.” It is the language your failures are written in.

You write source text. A JavaScript engine (V8 in Chrome and Node, SpiderMonkey in Firefox, JavaScriptCore in Safari) parses that text and runs it. You do not compile a binary the way you might in Java. That is why a typo often shows up as a red error at run time, not as a build failure — unless you add TypeScript or a linter, which we cover later.

You do not need the whole language before you write anything. Read a chapter, say the idea out loud, look at the worked example, then keep going. Practice at the end of a chapter is optional typing, not a gate. The goal of this book is that a QA can prepare JavaScript here without bouncing to three other sites for the core.

1Where it runs

In a browser tab, JavaScript can read the document tree (the DOM), listen for clicks and typing, store small bits of data, and call servers with fetch. The page you see is HTML and CSS; JavaScript is the moving part. In Node.js there is no document unless you open a browser from a test. Instead you get files, processes, environment variables, and HTTP clients. Playwright tests run in Node and drive a real browser — two hosts in one job.

The language core is the same in both places: numbers, strings, functions, objects, promises, modules. What changes is the host toolbox. document.querySelector is undefined in a Node spec file. fs.readFile is undefined in a page script. When you see “document is not defined,” you ran browser code in Node. When you see “fs is not defined,” you did the opposite.

Engines implement a standard called ECMAScript (ES2015, ES2020, and so on). People still say “ES6” for the 2015 wave: let/const, arrows, classes, promises, modules. You will use those every day. This book teaches that core first, then the host APIs testers actually touch: fetch, URL, cookies, storage, the DOM.

2How this book is laid out

Chapters are meant to be read in order, like a short book, not like a random wiki. Getting started is the engine and the console. Language basics is values, operators, and conversion — the soil everything else grows in. Data and logic is functions, arrays, objects, and loops. Functions in depth is scope, hoisting, this, and closures: the topics interviewers mean by “do you really know JavaScript?”

Modern JavaScript is modules, JSON, TypeScript-in-one-sitting, and Node/npm so you can open a Playwright repo. Asynchrony is callbacks, timers, promises, the event loop, fetch, URLs, and forms. Browser and problem solving is the DOM, storage, cookies, algorithms, and how to write an assertion that fails usefully.

Each chapter has an intro, headed sections, at least one code sample, a plain-words box, worked examples with printed results, and a short “watch out” list. Practice links take you to the editor if you want to type. You can skip practice until a chapter feels shaky. You should not skip asynchrony.

JavaScript
console.log("Hello, JavaScript");

3Mental model

Every program, including a test, is four verbs: store, decide, repeat, package. Variables store. if and === decide. Loops and map repeat. Functions package. Objects and arrays are how you store several things. Promises are “this store will be filled later.” If a page feels abstract, ask those four questions about the example in front of you.

A test is a program that is allowed to fail. It stores a page or a response, decides whether the world matches, and throws when it does not. That is why this book keeps returning to ===, truthiness, and await. Framework docs assume you already have that model.

4How to read this tutorial

Scroll. One heading is one sitting. The sidebar is a jump list, not homework. Arrow keys move to the previous or next chapter. If you only have twenty minutes, finish the current heading, not “the whole of async.”

When you hit a code sample, predict the result before you look at the worked example. Predicting is studying. If you are wrong, read the heading again; do not just copy the snippet into muscle memory.

Do not skip Functions in depth or Asynchrony. Then read JSON, URLs, cookies, Node/npm, and assertions. Those are the chapters people otherwise open MDN for at 11 p.m. before an interview.

5Why QA people learn this language

Playwright, Cypress, WebdriverIO, and most API tools in Node are JavaScript (or TypeScript that compiles to it). When a test is red, the stack is JS: a missed await, a wrong ===, a JSON field that is undefined. Framework docs will not save you if you cannot read that error. This book is the language layer so you do not need three other sites for the core.

6ECMAScript vs the host

The language standard is called ECMAScript (ES2015, ES2020, …). Engines implement it. The host adds extra objects: document in a browser, fs in Node, page in Playwright. When someone says “ES6,” they mean the 2015 wave: let/const, arrows, classes, promises, modules. You will use those every day.

Read the story, then the code, then the result. You do not need to type yet.

1Say something

The computer runs this one line and writes a message in the developer console (not on the page itself).

JavaScript
console.log("The kettle is on");

Result · prints: The kettle is on

2A tiny decision

Store a number, ask a question, pick a message. This is a whole program in three lines.

JavaScript
const cups = 3;
const message = cups > 1 ? "Make a pot" : "Make a mug";
console.log(message);

Result · prints: Make a pot

3Host APIs change, language does not

const and functions work in Node and in the browser. document exists only in the browser.

JavaScript
const n = 2 + 2;
console.log(n);
// document.querySelector("h1") // browser only

Result · prints: 4

Watch out for

  • Mixing up Java and JavaScript — they are unrelated languages.
  • Trying to memorize every API before writing a single function. Practice first.

Keep reading

Next page: How JavaScript runs

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 2 of 52 · Getting started · 16 min

How JavaScript runs

JavaScript is single-threaded: one piece of your code runs at a time. An engine (V8, SpiderMonkey, JavaScriptCore) parses the file, creates an execution context, and walks it line by line on a call stack.

1The call stack

A call stack is a pile of “who called whom.” When a function starts, a frame goes on top: its parameters, its lets, where to continue when it returns. When it returns, the frame pops. If a() calls b() and b() calls c(), the pile is a, then b, then c. c finishes first. That is why a stack trace lists the inner call at the top and main-ish frames lower down.

Recursion is the same pile repeating. Each call gets its own n. Too many frames: RangeError. Loops do not add frames the same way, which is why a loop can run a million times and recursion at that depth cannot.

JavaScript
function a() {
  b();
}
function b() {
  return 1;
}
a();

2Host vs language

ECMAScript is the language: grammar, types, functions, promises. The host is the cafeteria tray of extra objects. Browsers add document, window, fetch, setTimeout. Node adds process, fs, and its own timers. Playwright adds test, expect, page. None of those are “JavaScript the language.” They are APIs on top.

You can learn the language once and carry it between hosts. You cannot copy-paste document into a Node spec. That sentence saves hours.

3Synchronous by default

Line 1 finishes before line 2, unless line 1 only *schedules* work (timer, promise, event). Scheduling is not finishing. console.log after fetch(...) without await logs before the response. The later async chapters are entirely about that gap. Until you get there, every sample in the early book is synchronous on purpose.

4A full turn of the engine

Imagine the engine as one cook. They take a ticket (your script), prep the ingredients (parse, create scopes), then cook step by step (the call stack). They cannot chop two onions at the same time. If the doorbell rings (a click, a timer, a network response), they finish the current chop, wipe their hands, then handle the door. That doorbell queue is the event loop, which has its own chapter. Until then, every example here is one chop after another.

When function a calls b, b is a new plate on top of the stack. b must finish before a continues. Recursion is more plates. Too many plates: RangeError, stack overflow. That is not “the computer ran out of RAM” in the casual sense; it ran out of call stack frames.

JIT compilation means the engine may rewrite hot code to run faster. Your mental model can ignore that. You still think: parse, then run lines, then schedule later work. Interviewers who ask “is JavaScript compiled or interpreted?” want “it’s compiled just-in-time, but we still reason about it as a runtime.”

5What “later” really means

console.log is now. setTimeout and promise then are later, after the current stack clears (event loop page). Until you meet those, assume every line waits for the previous line. That mental model is correct for all of the basics section.

JavaScript
console.log("now");
setTimeout(() => console.log("later"), 0);
console.log("still now");

Read the story, then the code, then the result. You do not need to type yet.

1Stack of plates (function calls)

main calls greet. greet must finish before main can print “done”.

JavaScript
function greet(name) {
  return "Hi, " + name;
}
function main() {
  const line = greet("Maya");
  console.log(line);
  console.log("done");
}
main();

Result · prints: Hi, Maya done

2One thing at a time

These three logs always appear in this order. Nothing else can sneak between them.

JavaScript
console.log("1. boil water");
console.log("2. add leaves");
console.log("3. pour");

Result · prints: 1. boil water 2. add leaves 3. pour

3Later is after the stack

timeout 0 still prints last.

JavaScript
console.log("1");
setTimeout(() => console.log("3"), 0);
console.log("2");

Result · prints: 1 2 3

Watch out for

  • Thinking JavaScript is multi-threaded because the UI stays responsive. The UI stays responsive because slow work is scheduled, not because two scripts run at once.
  • Mixing up compile time and run time. JS is compiled just-in-time, but your mental model can stay “parse, then run.”

Keep reading

Next page: console, debugger, and reading errors

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 3 of 52 · Getting started · 21 min

console, debugger, and reading errors

Before you memorize APIs, learn how to see what the program is doing. QA engineers spend more time reading a stack trace and a log than writing a new function. The browser DevTools console (and Node’s terminal) is the first instrument.

This page is the habit: log the right thing, freeze on a line, and decode a red error instead of guessing.

1console.log is a flashlight, not a test

console.log(value) prints to the console. You can log several values: console.log("user", user, "ok", ok). Objects appear expandable in DevTools — click to open fields. console.table(arrayOfObjects) draws a grid, which is gold for API lists.

console.error and console.warn look different and show up as issues in some CI logs. console.assert(condition, message) logs only when the condition is falsy — a tiny checkpoint, not a real test runner.

JavaScript
const user = { name: "Ada", role: "qa" };
console.log("who", user.name, user);
console.table([
  { id: 1, status: "open" },
  { id: 2, status: "done" },
]);
console.assert(user.role === "qa", "role should be qa");

Result · prints who, the object, a table of two rows; assert stays quiet because the role matches

2Read a stack trace from the bottom up… then the top

A TypeError: Cannot read properties of undefined (reading 'name') means something was undefined, then you asked for .name. The first file:line in the stack that is *your* code (not node_modules) is where to look. The line number is the read, not always the missing data — walk backwards: who was supposed to set user?

ReferenceError: x is not defined means the name was never declared in this scope (typo, wrong import, TDZ). SyntaxError means the file did not even parse — a missing comma or backtick. RangeError often means stack overflow (infinite recursion) or a bad array length.

JavaScript
function greet(user) {
  return "Hi, " + user.name;
}
greet(undefined);
// TypeError: Cannot read properties of undefined (reading 'name')

3debugger and breakpoints

Write the statement debugger; and run with DevTools open — execution pauses there. Or click the line number in Sources to set a breakpoint without editing. Step Over runs the next line. Step Into goes inside a function call. The Scope pane shows lets and consts right now.

In Playwright, page.pause() is the cousin: it opens the inspector on a live page. Same idea — freeze, look, continue.

JavaScript
function total(lines) {
  debugger; // DevTools must be open
  return lines.reduce((sum, n) => sum + n, 0);
}
total([10, 20]);

4Do not debug with alert()

alert blocks the whole page and is not available in Node. JSON.stringify(obj, null, 2) pretty-prints nested API JSON for a log. util.inspect in Node prints deeper objects. If a log shows Promise { <pending> }, you forgot await.

JavaScript
const payload = { items: [{ id: 1 }, { id: 2 }] };
console.log(JSON.stringify(payload, null, 2));

Result · prints indented JSON you can paste into a ticket

5A working method for a red test

Read the first frame of the stack that is your file, not node_modules. Note the line: that is where the engine threw, which may be a .name read, not where the undefined was born. Walk backward: who was supposed to set this? Log the actual value with a label: console.log("body", body). If it prints Promise, you forgot await. If it prints undefined, you have a path or a parse problem.

debugger or a breakpoint lets you see scope without spamming logs. In Playwright, page.pause() and the trace viewer are the cousins. Do not leave debugger in committed tests; CI will hang. console.table is underrated for arrays of objects from an API.

JSON.stringify(value, null, 2) is what you paste into a ticket. If stringify throws (circular), log selected fields. If the server sent HTML, stringify will not run — parse threw first; include the raw text slice in the error.

Read the story, then the code, then the result. You do not need to type yet.

1Log more than a blob

A label plus the value is searchable in DevTools.

JavaScript
const res = { status: 201, id: 9 };
console.log("create user", res.status, res.id);

Result · prints: create user 201 9

2stringify for a ticket

Pretty JSON pastes into a bug report.

JavaScript
const body = { error: "taken", field: "email" };
console.log(JSON.stringify(body, null, 2));

Result · prints indented JSON with error and field

3assert stays quiet when true

console.assert only speaks when the check fails.

JavaScript
console.assert(2 + 2 === 4, "math broke");
console.assert(2 + 2 === 5, "math broke");

Result · second line logs an assertion failure

Watch out for

  • Logging the promise instead of awaiting it — you will stare at Promise { pending } and think the API is empty.
  • Fixing a symptom on the wrong line because you did not open the first stack frame that is your file.
  • Leaving debugger; in committed test code so CI hangs.

Keep reading

Next page: Strict mode

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 4 of 52 · Getting started · 14 min

Strict mode

Strict mode is a safer dialect of JavaScript. Modules and class bodies are strict automatically. In old scripts you opt in with a string at the top of the file or function: "use strict";

1What changes

Undeclared assignment throws. this in a loose call is undefined. Duplicate parameter names are illegal. Octal literals like 012 throw. delete on a name throws. These are not style nits. They turn silent globals and silent this-is-window bugs into loud errors — the kind you can fix in a minute instead of a day.

JavaScript
"use strict";
function add(a, b) {
  return a + b;
}

2You already live in it

ES modules are strict. class bodies are strict. Your Playwright spec imported as ESM is strict. This site’s examples assume strict. If an old snippet uses this to mean window, it is not a pattern to copy. It is a fossil.

3Why testers should care

Sloppy mode will quietly create a global if you assign to a misspelled name. Your test might still pass on your machine because the global stuck around. Strict mode throws ReferenceError immediately. Modules and class bodies are already strict, so a Playwright spec file is strict. Do not learn examples from 2012 blog posts that rely on sloppy this === window.

this in a bare function call is undefined in strict mode. That is why const fn = obj.method; fn() dies. The this chapter repeats this because it is the most common “it works in the console snippet but not in my test” bug.

4A few more rules

Octal 012 is illegal. with (obj) is illegal. arguments and eval become less magical (you cannot assign to arguments in a way that aliases parameters). Duplicate object keys in old sloppy mode kept the last; engines already prefer the last in modern code. Treat strict as the air you breathe.

JavaScript
"use strict";
// 012;        // SyntaxError
// with (obj) {} // SyntaxError

Read the story, then the code, then the result. You do not need to type yet.

1A typo that should scream

In sloppy old JavaScript, this might create a global by accident. In strict mode it throws, which is what you want.

JavaScript
"use strict";
function setScore() {
  scor = 10; // missing "e" — not declared
}

Result · throws: ReferenceError (scor is not defined)

2this is not magically the window

A bare function call does not get a secret global this.

JavaScript
"use strict";
function show() {
  return this;
}
show();

Result · returns: undefined

3Bare this

In strict mode a loose call does not get the global object.

JavaScript
"use strict";
function who() { return this; }
console.log(who());

Result · prints: undefined

Watch out for

  • Putting "use strict" below other statements — it must be the first statement in the script or function.
  • Assuming this in a loose function is window. In strict mode it is undefined.

Keep reading

Next page: Variables

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 5 of 52 · Language basics · 18 min

Variables

A variable is a name you give to a value so you can use it again. That sounds small. It is the whole job of a program: remember something, compute from it, remember the result. Modern JavaScript has two honest tools for that: let, when the name will point at a new value later, and const, when it will not.

Names should read like English: totalPrice, isVisible, users. In tests, actual and expected, status, body, locator. Avoid data, temp, obj, and x1. A boolean name should start with is, has, or can. That is not fashion. Six months from now you will grep a failure and thank yourself.

const does not freeze the thing inside the box. It only forbids replacing the box. const user = { role: "guest" } still allows user.role = "admin". Shared test fixtures that get mutated are a classic source of flakes. Copy before you change, or build a fresh object per test.

1let and const

Prefer const. Switch to let only when you will reassign the name: a counter, a swapped pair, a running minimum. Reassignment is the signal. If you only mutate a property on an object, the name is still const.

Avoid var in new code. It is function-scoped, it hoists as undefined, and it does not respect a block if. The hoisting chapter shows why that hides bugs. Treat any var you see in old snippets as “this file is older than the habit we want.”

Reassigning a const throws TypeError. That is the language helping you. If you need a new value, pick a new name (const totalWithTax = total * 1.18) or use let on purpose. Creating a new name is often clearer than mutating.

JavaScript
const name = "Ada";
let score = 0;
score = 10;

2typeof

typeof returns a string tag: "string", "number", "boolean", "undefined", "function", "bigint", "symbol", or "object". It is a quick gate, not a full type system. typeof [] is "object". typeof null is "object" — a famous historical bug that will never be fixed because the web would break. Use Array.isArray for lists and value === null for null.

typeof fn is "function", which is why you can test a callback before calling it. typeof missingVar is "undefined" if the name exists in scope as undefined; a name that was never declared throws ReferenceError instead. Those two failures look similar in a log and are not the same.

JavaScript
typeof 42;       // "number"
typeof "hi";     // "string"
typeof undefined; // "undefined"

3undefined vs null

undefined means “nobody set this.” A missing argument is undefined. A missing object key is undefined. A function with no return yields undefined. null is a value you assign to mean “empty on purpose.” JSON has null. JSON does not have undefined: JSON.stringify drops keys whose value is undefined, and JSON.parse will never produce undefined in the tree (it produces null where the text said null).

APIs often use null for “no user” and omit a key for “not included in this view.” Write your assertions to match the contract, not your favorite of the two empties. In TypeScript later, T | null and T | undefined are different on purpose.

4A first program

The shape is always: names for what you know, a computation, a use of the result (log, return, assert). Quantity and price are known. Total is derived. console.log is how you see it before you have a test runner. Later, assertEqual(total, 120, "total") is the same shape with a louder failure.

If you cannot write this three-step program, do not jump to promises. Everything asynchronous still does this — it just fills one of the names later.

JavaScript
const quantity = 3;
const price = 40;
const total = quantity * price;
console.log(total); // 120

5Putting names to work in a test file

A good spec reads like a short story because the names are honest: const response = await request.post(...); const body = await response.json(); const isCreated = response.status() === 201. You can hear the assertions before you write them. Vague names (res1, data, tmp) force every reader, including you in two days, to re-decode the JSON.

const for the response, let only if you retry and reassign. If you mutate body.user.role in one test, either clone first or you have invented a flake. The next chapter on references is exactly this bug in slow motion.

6Names, destructure, and TDZ

Use camelCase: totalPrice, isOpen. const { name } = user is still a const binding. You cannot read let/const above their line (TDZ). const object = {} still lets you change object.score — const protects the binding, not the insides.

JavaScript
const user = { name: "Ada" };
user.name = "Bea"; // ok
// user = {} // would throw

7A tester’s naming habits

actual and expected in assertions. response, body, status for HTTP. locator or button if it is a page element. Avoid data, temp, obj. If a name is a boolean, start with is, has, or can: isVisible, hasError. const unless you reassign — that is not style snobbery; it tells the next reader the binding will not move.

JavaScript
const status = 200;
const isOk = status >= 200 && status < 300;
let retries = 0;
retries += 1;

8const, mutation, and test fixtures

const user = { role: "guest" } still allows user.role = "admin". Your fixture object is not frozen. If a test mutates a shared fixture, the next test inherits the mess — a classic flake. Copy with { ...user } or structuredClone before you change it, or build a fresh object per test.

JavaScript
const template = { role: "guest" };
function user(overrides) {
  return { ...template, ...overrides };
}
const admin = user({ role: "admin" });

Read the story, then the code, then the result. You do not need to type yet.

1A name that stays

Ada’s name does not change, so const is the honest choice.

JavaScript
const name = "Ada";
console.log(name);

Result · prints: Ada

2A score that changes

The label is still “score”, but the number inside gets replaced.

JavaScript
let score = 0;
score = score + 10;
score = score + 5;
console.log(score);

Result · prints: 15

3Ask what kind of value it is

typeof is a polite question: “what sort of thing is in this jar?”

JavaScript
typeof "tea";  // "string"
typeof 42;     // "number"
typeof true;   // "boolean"

Result · returns: "string", "number", "boolean"

4Fresh object per test

Spread so mutating admin does not poison template.

JavaScript
const template = { role: "guest" };
const admin = { ...template, role: "admin" };
console.log(template.role, admin.role);

Result · prints: guest admin

Watch out for

  • Reassigning a const — that throws. Create a new name or use let.
  • Using a variable before it is declared in the same block (temporal dead zone for let/const).

Keep reading

Next page: Booleans & truthiness

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 6 of 52 · Language basics · 18 min

Booleans & truthiness

A boolean is the answer to a yes/no question: true or false. Comparisons produce them (age >= 18). if, while, filter, and almost every assertion in a test runner consume them. If you only memorize APIs and skip this page, you will misread if (items) when items is [] (truthy!) or if (count) when count is 0 (falsy, but maybe a valid count).

Testers live here. expect(page.getByRole("button")).toBeVisible() is a boolean question with retries. API checks are boolean questions without retries: res.status === 201. The language underneath both is the same: a condition that is true or false, and a branch that runs when it is not.

This chapter is the rules so you can look at a condition and predict it before you run it. That skill is worth more than memorizing matcher names.

1Comparisons

=== and !== compare without converting types. 5 === "5" is false. That is what you want in tests: a string status from an input is not a number status from HTTP. == will coerce and tell you they are equal; that hides bugs. Use === unless you can write a comment that justifies == (the only common one is null == undefined, and even then many teams still prefer an explicit check).

> < >= <= work for numbers and for strings (dictionary order). "10" < "9" is true as strings because "1" compares before "9". Coerce to number before comparing quantities. Combine questions with && (and), || (or), and ! (not). Parentheses make mixed && and || readable; && binds tighter, but humans should not have to remember that in a review.

JavaScript
const isAdult = age >= 18;
const canGo = hasTicket && hasId;

2Truthy and falsy

Inside if (value), JavaScript does not require a boolean. It asks “is this falsy?” Falsy values: false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy: "0", "false", [], {}, function(){}. That is why if (items) is a bad check for “are there items?” — an empty array is truthy. Use items.length > 0 or Array.isArray(items) && items.length === 0 as the question you actually mean.

0 is a legal count and a legal price. if (count) skips the zero case. if (title) skips the empty string, which may or may not mean “missing.” Convert with Boolean(value) or !!value when you must store a real true/false, for example in a JSON fixture. Prefer named booleans: const hasItems = items.length > 0.

3Write the question you actually mean

if (user) does not mean “is this a logged-in user object?” It means “is this value truthy?” An empty object {} is truthy. null is falsy. if (items.length) treats 0 as false, which is correct for “are there items?” and wrong if you stored length in a confusing way. Spell it: items.length > 0, user !== null, status === 200.

Assertions should produce a boolean you could print. expect(true) with no message is a waste. expect(status === 201, "create user"). In Playwright, toBeVisible() is a boolean question with retries — still a boolean, plus time.

4Short-circuit && and ||

&& stops if the left side is falsy and returns that left value (not always true/false). || stops if the left side is truthy. That is why you can write user && user.name, and why title || "Untitled" treats "" as missing. Prefer ?? when 0 or "" are legal values — that is on the operators page.

JavaScript
true && "tea";   // "tea"
false && "tea";  // false
"" || "guest";   // "guest"
"Ada" || "guest"; // "Ada"

5Combining questions

! flips true/false. Parentheses keep mixed && and || honest: (hasTicket || isStaff) && !isBanned. Without parentheses, && binds tighter than || — write the parentheses anyway so a human can read it.

JavaScript
const canGo = (hasTicket || isStaff) && !isBanned;

6Comparisons testers write constantly

status === 200, text.includes("Welcome"), items.length > 0, Boolean(token). Prefer Number(status) === 200 if a header arrived as a string. For “did the list render?” ask length or a locator count, not if (list) — an empty array is truthy.

JavaScript
const status = 404;
const ok = status === 200;
const rows = [];
if (rows) console.log("truthy array");
if (rows.length === 0) console.log("no rows");

Result · prints truthy array then no rows

7De Morgan and readable negatives

!a && !b is the same as !(a || b). Write the version you can say out loud: if (!(isAdmin || isOwner)) return. Double negation !!value is Boolean(value). Use it when an API wants a real true/false, not a password string that happens to be truthy.

JavaScript
const isAdmin = false;
const isOwner = true;
const forbidden = !(isAdmin || isOwner);
console.log(forbidden);

Result · prints: false

Read the story, then the code, then the result. You do not need to type yet.

1A door check

Age 20 is at least 18, so isAdult is true.

JavaScript
const age = 20;
const isAdult = age >= 18;
console.log(isAdult);

Result · prints: true

2Two keys needed

&& means both sides must be true. One missing ticket and you stay out.

JavaScript
const hasTicket = true;
const hasId = false;
const canEnter = hasTicket && hasId;
console.log(canEnter);

Result · prints: false

3Empty is “no”

An empty string is falsy. The name “0” as text is still truthy — it is not empty.

JavaScript
Boolean("");    // false  (empty)
Boolean("0");   // true   (a character exists)
Boolean(0);     // false  (the number zero)

Result · false, true, false

4Stop at the first no

&& never asks the second question if the first is false. That is short-circuiting — useful when the second part would crash.

JavaScript
const user = null;
const name = user && user.name;
console.log(name);

Result · prints: null (does not read .name)

5Empty list is not “no list”

[] is truthy. Check length.

JavaScript
const items = [];
console.log(Boolean(items));
console.log(items.length === 0);

Result · prints: true true

Watch out for

  • Using == which coerces types (5 == "5" is true). Prefer ===.
  • Treating 0 as missing data when 0 is a valid number.

Keep reading

Next page: Operators

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 7 of 52 · Language basics · 18 min

Operators

Operators are the little verbs between values. They add, compare, assign, and join yes/no questions.

You already use them in tests: count > 0, status >= 400, a running total. Two of them cause most real-world bugs. + glues text together if either side is a string. || treats 0 and "" as “missing,” which is a problem when 0 is a real price.

% is remainder, not percent. 17 % 5 is 2, because 5 fits into 17 three times and 2 is left. That is “is this even?” (n % 2 === 0) and “is this a multiple of 3?”

?? is the kind default. It only fills in when the left side is null or undefined. title || "Untitled" will replace a real empty title. title ?? "Untitled" will not. Use ?? when 0 or "" are allowed — prices, counts, optional strings from an API.

1Arithmetic

+ - * / work as you expect for numbers. Division always can produce a fraction: 5 / 2 is 2.5, not 2. If you need a whole number, Math.floor, Math.trunc, or Math.round — they are not the same at negatives. % is remainder: useful for wrapping indices and for “multiple of.” ** is power: 2 ** 8 is 256.

Unary plus (+"42") coerces a numeric string to a number, same family as Number("42"). Unary minus negates. If the string is not numeric, you get NaN and every later calculation is poisoned. At a form boundary, prefer Number.parseInt(text, 10) or Number(text) plus Number.isFinite.

JavaScript
const remainder = 17 % 5;
const powered = 2 ** 3; // 8
const n = +"42";

2?? versus ||

|| returns the first truthy operand. That makes title || "Untitled" look clever until title is "" or count is 0. ?? returns the first defined operand: it skips only null and undefined. count ?? 1 keeps 0. title ?? "Untitled" keeps "".

Mix them on purpose, not by accident. A common tester pattern: const items = body.items ?? [] so map is safe, even when the key is missing. Do not use || [] if an empty list and a missing list must be distinguished — then check Array.isArray and fail with a message.

3Assign and bump

score += 10 is score = score + 10. The same pattern works with -= *= /= %=. ++n adds one before the value is used; n++ adds one after. Prefer n += 1 in new code — it is harder to hide a bug in.

JavaScript
let n = 5;
n += 3;      // 8
const a = ++n; // n is 9, a is 9
const b = n++; // b is 9, then n is 10

4Grouping and **

** is power: 2 ** 3 is 8. Use parentheses whenever mixing + and * would make a reader pause. Unary + turns a numeric string into a number; unary - negates.

JavaScript
const n = +"42"; // 42
(2 + 3) * 4;    // 20
2 ** 8;         // 256

5Remainder, parity, and paging

n % 2 === 0 is even. n % 2 === 1 is odd (watch negatives). Pagination: page * pageSize. Clamp with Math.min / Math.max. Ternary for a label: const tone = ok ? "pass" : "fail".

JavaScript
function pageSlice(items, page, size) {
  const start = page * size;
  return items.slice(start, start + size);
}
pageSlice(["a", "b", "c", "d"], 1, 2);

Result · ["c", "d"]

6??, ||, and empty API fields

A JSON field can be 0 (retry count) or "" (optional comment). title || "Untitled" would replace both. title ?? "Untitled" keeps 0 and "". When a missing key is undefined, both ?? and || fill in. Prefer ?? for numbers and strings that may be empty-but-valid.

JavaScript
const retries = 0;
console.log(retries || 3);
console.log(retries ?? 3);

Result · prints: 3 0

Read the story, then the code, then the result. You do not need to type yet.

1Shop maths

Three notebooks at 40 each, with 10 off the total.

JavaScript
const price = 40;
const qty = 3;
const total = price * qty - 10;
console.log(total);

Result · prints: 110

2Leftovers (remainder)

17 biscuits shared among 5 people: each gets 3, 2 remain. Those 2 are 17 % 5.

JavaScript
console.log(17 % 5);
console.log(20 % 5);

Result · prints: 2 0

3A default only when missing

?? keeps 0 (a real score). || would have treated 0 as missing.

JavaScript
const score = 0;
console.log(score ?? "no score");
console.log(score || "no score");

Result · prints: 0 no score

4Keep a legal zero

retries is 0. || would replace it; ?? keeps it.

JavaScript
const retries = 0;
console.log(retries || 5);
console.log(retries ?? 5);

Result · prints: 5 0

Watch out for

  • Dividing integers and expecting a truncated result — JS division is floating point. Use Math.floor if you need a whole number.
  • Using || for defaults when 0 is a legal value.

Keep reading

Next page: Type conversion

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 8 of 52 · Language basics · 17 min

Type conversion

JavaScript will convert types for you whether you asked or not. Input fields give strings. JSON numbers are numbers. Status codes are numbers. Mix them with + and you get "201" + 1 === "2011", which then fails an assertion that expected 202. This chapter is how to convert on purpose so that never happens in your helpers.

There is explicit conversion (Number, String, Boolean, parseInt) and implicit conversion (what + - == if do behind your back). Prefer explicit at the boundary: the moment a value enters your function from the DOM, process.env, or a query string. Inside the function, keep one type.

1To number, string, boolean

Number("42") is 42. Number("") is 0 — a trap when an empty input should be invalid, not free. Number("12px") is NaN. Number(true) is 1. String(9) is "9". String(null) is "null". Boolean(1) is true. Boolean([]) is true. Template literals stringify whatever you interpolate: ${[1,2]} is "1,2".

parseInt("12px", 10) is 12 because it reads from the left until digits stop. Always pass 10 as the radix so "08" is not treated as octal in old engines. parseFloat("3.14") keeps the decimal. Prefer Number when the whole string must be a number; prefer parseInt when a suffix is allowed (CSS pixels in a computed style).

JavaScript
Number("08"); // 8
String(false); // "false"
Boolean([]); // true

2Implicit conversion

The + operator prefers strings: "3" + 1 is "31". The - operator always does math: "3" - 1 is 2. * and / also coerce to number. This is why people say “+ is overloaded.” If you mean add, Number both sides. If you mean concatenate, String both sides. Do not leave it to the operator when the values came from the outside world.

== is implicit conversion’s loudest form. Skip it. The equality chapter is next; the habit starts here: convert, then ===.

3Number vs parseInt

Number("08") is 8. Number("") is 0. Number("12px") is NaN. parseInt("12px", 10) is 12 because it reads from the left until digits stop. parseInt("", 10) is NaN. Pick Number when the whole string should be a number; pick parseInt when a suffix is allowed.

JavaScript
Number("12");     // 12
Number("12px");   // NaN
parseInt("12px", 10); // 12
Number("");       // 0
parseInt("", 10); // NaN

4Boolean conversion

Boolean(value) or !!value. Falsy: false, 0, -0, 0n, "", null, undefined, NaN. Truthy: everything else, including "0", "false", [], and {}.

JavaScript
Boolean("0");  // true
Boolean(0);    // false
Boolean([]);   // true
Boolean(null); // false

5Input values are strings

DOM .value, query params, and CSV cells are strings. "10" + 1 is "101". Number("10") + 1 is 11. Trim first: Number(" 10 ") works; Number("10px") is NaN. In tests, coerce once at the boundary, then use numbers inside.

JavaScript
const raw = " 42 ";
const n = Number(raw.trim());
console.log(n + 1);
console.log("42" + 1);

Result · prints: 43 421

6Truthy traps in test data

"false" is a non-empty string — Boolean("false") is true. JSON false is boolean false. If an env var is the string "false", compare to the string or parse explicitly. Same for "0".

JavaScript
Boolean("false"); // true
JSON.parse("false"); // false
process.env?.FLAG === "true"; // env vars are strings

Read the story, then the code, then the result. You do not need to type yet.

1A form field is always text

An input box gives you "8", not 8. Convert before you add.

JavaScript
const typed = "8";
const n = Number(typed);
console.log(n + 2);

Result · prints: 10

2Glue vs maths

+ sees a string and glues. - always does maths, so it converts the string.

JavaScript
console.log("3" + 1);
console.log("3" - 1);

Result · prints: 31 2

3Env flags are strings

The string "false" is truthy. Compare explicitly.

JavaScript
const flag = "false";
console.log(Boolean(flag));
console.log(flag === "true");

Result · prints: true false

Watch out for

  • Using + to add numbers that might be strings from an input field — coerce with Number first.
  • Relying on == which converts before comparing. Use === unless you truly want coercion.

Keep reading

Next page: Equality & Object.is

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 9 of 52 · Language basics · 15 min

Equality & Object.is

JavaScript has three equality stories. You almost always want ===. == coerces types. Object.is is === with two fixes: NaN equals NaN, and +0 does not equal -0.

1=== and !==

Same type, same value. 5 === "5" is false. null === undefined is false. true === 1 is false. This is the comparison to use in if, filter, and tests. It is boring, and boring is correct. When a test fails on ===, the types are usually the story: string vs number, null vs undefined.

JavaScript
5 === 5;       // true
5 === "5";     // false
null === undefined; // false

2== coercion

== converts according to a table nobody remembers completely. "" == 0 is true. "0" == false is true. null == undefined is true. The rest will betray you with user input. Do not use == in new code. If you see it in a review, ask whether both sides were converted on purpose first.

3Object.is and Number.isNaN

x === NaN is always false, even when x is NaN. Number.isNaN(x) is the check. Object.is(NaN, NaN) is true. Object.is(+0, -0) is false, while +0 === -0 is true. You need Object.is in interviews and in very precise numeric code. Day to day, === plus Number.isNaN is enough.

4What to write in a test

Primitives: ===. Numbers that came from text: Number then ===. Objects: do not === unless you mean the same instance. Compare fields, or in Jest use toEqual for deep value equality. toBe is ===. Playwright’s expect(locator) is not ===; it retries until timeout. Mixing those two expects is an interview question: UI waits, API bodies usually do not.

null and undefined: be explicit. Number.isNaN for NaN. Object.is is the specialist for the two === bugs (NaN and -0). You will rarely need Object.is outside an interview, but you should recognize it.

5A few == traps worth seeing

You do not need the whole coercion table. Remember these and use === anyway: "" == 0, false == 0, null == undefined (and null == null). Arrays and objects are never == unless they are the same reference. Object.is(NaN, NaN) is the NaN test besides Number.isNaN.

JavaScript
"" == 0;        // true  (avoid)
false == 0;     // true  (avoid)
null == undefined; // true (the one people sometimes want)
[] == false;    // true  (avoid)
[] === false;   // false

Read the story, then the code, then the result. You do not need to type yet.

1Looks alike, not the same

The number 5 and the text "5" are cousins, not twins.

JavaScript
console.log(5 === "5");
console.log(5 == "5");

Result · prints: false true

2NaN is a broken number

You cannot find NaN with ===. Use Number.isNaN.

JavaScript
const broken = Number("hello");
console.log(broken === broken);
console.log(Number.isNaN(broken));

Result · prints: false true

3Status as number

"200" === 200 is false. Coerce once.

JavaScript
console.log("200" === 200);
console.log(Number("200") === 200);

Result · prints: false true

Watch out for

  • Using == with user input ("0" == false). Parse, then use ===.
  • Checking for NaN with ===.

Keep reading

Next page: Primitives vs references

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 10 of 52 · Language basics · 16 min

Primitives vs references

JavaScript values are either primitives (copied by value) or objects (copied by reference). Mixing the two up is the usual reason “I changed a copy and the original changed too.”

1Primitives

string, number, boolean, null, undefined, bigint, symbol copy by value. let b = a; b = 2 does not change a. Strings are immutable, so even “changing” a string is making a new string. That is why str[0] = "X" does not edit str.

JavaScript
let a = 1;
let b = a;
b = 2;
// a is still 1

2Objects and arrays

Objects, arrays, functions, dates, maps, sets are references. Two names, one object. Mutating through either name mutates the one object. const copy = original is not a copy. [...arr] and { ...obj } are shallow copies. Nested objects remain shared. This is the fixture-flake mechanism.

JavaScript
const original = { score: 1 };
const copy = original;
copy.score = 9;
// original.score is 9

3Shallow vs deep

Shallow: one level. Deep: structuredClone(value) for most JSON-like data. JSON.parse(JSON.stringify(obj)) is a blunt deep copy that drops functions, undefined, Date-as-Date, Map, Set, and throws on cycles. Use it only when you know the value is JSON-safe. In tests, prefer factories over cloning production objects.

4Fixtures and why tests flake

const copy = original does not copy. Both names are the same object. copy.role = "admin" changes original. If original is a module-level fixture, every test after the first sees admin. Build a factory: const user = (overrides) => ({ role: "guest", ...overrides }). Or structuredClone for nested data.

=== on two objects with the same fields is false. That is identity. Testers who write expect(body).toBe({ ok: true }) learn this the hard way (toBe vs toEqual). In plain JavaScript, compare body.ok === true.

5Functions and nested copies

Functions are references too — two names can point at the same function. structuredClone handles dates and maps; JSON round-trip does not. For a one-level copy of an array of objects, the objects inside are still shared.

JavaScript
const rows = [{ n: 1 }];
const copy = [...rows];
copy[0].n = 9;
rows[0].n; // 9  (same inner object)

Read the story, then the code, then the result. You do not need to type yet.

1Photocopy (primitive)

b gets a copy of 1. Changing b does not touch a.

JavaScript
let a = 1;
let b = a;
b = 99;
console.log(a, b);

Result · prints: 1 99

2Shared locker (object)

copy and original are two labels on the same bag.

JavaScript
const original = { tea: "masala" };
const copy = original;
copy.tea = "ginger";
console.log(original.tea);

Result · prints: ginger

3A real copy of a list

Spread makes a new bag and pours the items in. Pushing into one bag leaves the other alone.

JavaScript
const a = [1, 2];
const b = [...a];
b.push(3);
console.log(a);
console.log(b);

Result · prints: [1, 2] [1, 2, 3]

4Shared fixture bug

copy and original are the same object.

JavaScript
const original = { n: 1 };
const copy = original;
copy.n = 2;
console.log(original.n);

Result · prints: 2

Watch out for

  • Comparing two objects with === and expecting equal contents. === checks identity, not shape. Compare fields, or JSON if you accept that limitation.
  • Mutating an object you received as a function argument when the caller still needs the original.

Keep reading

Next page: Numbers & Math

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 11 of 52 · Language basics · 16 min

Numbers & Math

Every JavaScript number is a 64-bit floating point value (IEEE-754), including the ones that look like integers. 0.1 + 0.2 is not 0.3. Money should be integer paise or cents. IDs bigger than Number.MAX_SAFE_INTEGER (9,007,199,254,740,991) silently round — many APIs send those ids as strings on purpose.

NaN means “not a number,” and it is contagious: NaN + 1 is still NaN. NaN !== NaN, so you check with Number.isNaN. Number.isFinite is the right gate before you use a parsed value in math or in an assertion.

1Parse and round

Math.round goes to nearest (halves toward +∞ in the usual ES round). Math.floor always down. Math.ceil always up. Math.trunc drops the fraction toward zero. Math.max and Math.min take any count of arguments. toFixed(2) returns a string like "2.50" — good for display, bad for more math because you would Number() it again and meet floats.

parseInt("42px", 10) is the classic “digits at the front.” For money display, format at the edge; compute in integers. For random numbers in tests, prefer deterministic fixtures over Math.random() unless the test is about randomness itself.

JavaScript
Math.round(4.6); // 5
Math.max(3, 9, 4); // 9
Number.isNaN(Number.NaN); // true

2NaN

Invalid math becomes NaN: 0/0, Number("nope"), Math.sqrt(-1). NaN !== NaN by spec. Number.isNaN(x) is the check. The older isNaN("hello") is true because it coerces first — too eager. Number.isFinite(x) rejects NaN and Infinity.

0.1 + 0.2 === 0.3 is false. This is not a JavaScript-only joke; it is binary fractions. For currency, store integer minor units. For IDs above 2^53 − 1, keep strings. JSON.parse will turn 9999999999999999 into a rounded number if it was written as a JSON number — that is a contract bug, not your assertion being picky.

JavaScript
console.log(0.1 + 0.2 === 0.3);
console.log(Number.isNaN(Number("12px")));
console.log(Number.isFinite(Number("12")));

30.1 + 0.2 and money

0.1 + 0.2 is 0.30000000000000004. That is how binary floats work, not a JavaScript bug. For money, store integer paise/cents, or round only when displaying. Never compare floats with === after division; use a small epsilon or integers.

JavaScript
0.1 + 0.2 === 0.3; // false
Math.round((0.1 + 0.2) * 100) / 100; // 0.3
const paise = 10 + 20; // 30 paise — safe

4isFinite, random, toFixed

Number.isFinite(n) is the right gate before you use a parsed value. Math.random() is in [0, 1). toFixed(2) returns a string for display — do not add those strings as numbers.

JavaScript
Number.isFinite(Number("x")); // false
Math.floor(Math.random() * 6) + 1; // die 1..6
(2.5).toFixed(2); // "2.50"

5parseInt, Number, and IDs from URLs

User ids in paths are strings. Number("007") is 7. Keep them as strings if the API does (leading zeros, snowflake ids bigger than 2^53). Number.isSafeInteger(n) is false for those — use BigInt or keep the string. parseInt("08", 10) is 8; always pass radix 10.

JavaScript
Number("007"); // 7
parseInt("08", 10); // 8
Number.isSafeInteger(9007199254740993); // false

Read the story, then the code, then the result. You do not need to type yet.

1Round a bill

Math.round goes to nearest. floor always down. ceil always up.

JavaScript
console.log(Math.round(4.6));
console.log(Math.floor(4.6));
console.log(Math.ceil(4.1));

Result · prints: 5 4 5

2Read a number from messy text

parseInt reads from the left until the digits stop. Always pass 10 (decimal).

JavaScript
console.log(parseInt("42px", 10));
console.log(parseFloat("3.14"));

Result · prints: 42 3.14

3The biggest of a group

Like picking the tallest person in a line.

JavaScript
console.log(Math.max(3, 9, 4));
console.log(Math.abs(-7));

Result · prints: 9 7

4Display money, store paise

Keep integer paise in the program. Only toFixed when you show the label.

JavaScript
const paise = 199;
const label = (paise / 100).toFixed(2);
console.log(label);

Result · prints: "1.99"

5Always radix 10

parseInt without radix used to treat 08 as octal in old engines. Pass 10.

JavaScript
console.log(parseInt("08", 10));
console.log(Number("08"));

Result · prints: 8 8

Watch out for

  • parseInt without a radix — always pass 10.
  • Adding money with floats (0.1 + 0.2). For currency, work in integer paise/cents.

Keep reading

Next page: Strings

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 12 of 52 · Language basics · 19 min

Strings

A string is text. It is immutable: methods return new strings. str[0] = "X" does nothing useful. Template literals (backticks) interpolate ${value} and can span lines. Almost every failure message, URL, and locator name is string work.

Index 0 is the first character. slice(start, end) does not include end. split turns a list in a string into an array; join goes back. trim before you compare user input. Locale-aware case folding is a later problem; for ASCII tests, toLowerCase is enough.

1Build and inspect

length is UTF-16 code units, which is “characters” for typical English test data and a footgun for emoji. [0] or charAt(0) is the first unit. includes, startsWith, endsWith, toLowerCase, trim, repeat, padStart show up in formatters and in assertions on text content.

Template literals are the default way to build messages: expected ${expected}, got ${actual}. Concatenation with + still works and is how implicit conversion bites you. Prefer templates for anything a human will read in a report.

JavaScript
const name = "Maya";
const hello = `Hello, ${name}!`;
"javascript".slice(0, 4); // "java"

2Indexing

slice(start, end) copies from start up to but not including end. Negative start counts from the end: slice(-3) is the last three units. substring exists and is slightly different with negatives; in new code, slice is the one to remember.

Walking with a for loop and indices is legal. For “each character” in interview puzzles, [...str] or for...of is clearer, with the emoji caveat. Palindrome checks usually normalize with toLowerCase and a regex to drop spaces, then compare to a reversed copy.

3Split, join, replace

split(",") turns "ada,bea" into ["ada", "bea"]. split("") is every code unit. join(", ") builds a display list. replace swaps the first match; replaceAll or a /g regex swaps every match. If you forget that, only the first comma or the first "a" changes and your assertion looks possessed.

CSV in the real world has quoted commas. split(",") is not a CSV parser. For tests, prefer JSON fixtures. For locators, prefer getByRole over regex soup unless the accessible name truly varies.

JavaScript
"ada,bea,cam".split(","); // ["ada", "bea", "cam"]
["ada", "bea"].join(" · "); // "ada · bea"
"banana".replaceAll("a", "o"); // "bonono"

4Search without regex

includes, startsWith, endsWith, indexOf, and lastIndexOf cover most hunts. indexOf returns -1 when missing — do not if (indexOf(x)) because 0 is a valid index and is falsy. Use includes or indexOf !== -1.

JavaScript
"javascript".includes("script"); // true
"javascript".startsWith("java"); // true
"tea".indexOf("t"); // 0  (not missing!)

5Trim, pad, repeat, replaceAll

trim strips edges. padStart(4, "0") turns 7 into "0007". repeat(3) stacks the string. replaceAll swaps every exact substring (no regex needed).

JavaScript
"  hi  ".trim();           // "hi"
String(7).padStart(3, "0"); // "007"
"ha".repeat(3);             // "hahaha"
"banana".replaceAll("a", "o"); // "bonono"

6Template literals for error messages

Backticks interpolate. Use them in assert messages: expected ${expected} status, got ${status}. Multi-line strings are allowed. ${} can hold any expression. Nested templates are legal but ugly — extract a variable.

JavaScript
const status = 500;
const msg = `GET /users failed: HTTP ${status}`;
console.log(msg);

Result · prints: GET /users failed: HTTP 500

7Unicode, trim, and locators

"café".length can surprise you with combining marks; [...str].length counts code points better. trim() before comparing button labels you copied from the DOM — Playwright’s getByRole name is exact by default unless you use a regex. toLowerCase for case-insensitive includes.

JavaScript
const label = "  Save  ";
console.log(label.trim() === "Save");
console.log("Save".toLowerCase() === "save");

Result · prints: true true

Read the story, then the code, then the result. You do not need to type yet.

1Insert a name into a sentence

Backticks (`) let you drop a value into the middle with ${ }. That is a template literal.

JavaScript
const name = "Maya";
const hello = `Hello, ${name}!`;
console.log(hello);

Result · prints: Hello, Maya!

2Slice a piece

slice(0, 4) means “from 0 up to but not including 4.”

JavaScript
const word = "javascript";
console.log(word.slice(0, 4));
console.log(word.slice(-3));

Result · prints: java ipt

3Split a grocery line

split turns one string into a list. join does the reverse.

JavaScript
const line = "tea,milk,sugar";
const items = line.split(",");
console.log(items);
console.log(items.join(" · "));

Result · prints: ["tea", "milk", "sugar"] tea · milk · sugar

40 is a real index

indexOf("j") is 0. if (indexOf) would skip this match because 0 is falsy. includes is the clearer question.

JavaScript
const word = "java";
console.log(word.indexOf("j"));
console.log(word.includes("j"));

Result · prints: 0 true

5Useful failure text

Template literal keeps expected vs actual readable.

JavaScript
const status = 500;
console.log(`expected 200, got ${status}`);

Result · prints: expected 200, got 500

Watch out for

  • Expecting str[0] = "X" to change the string — it will not.
  • replace without /g only swaps the first match.

Keep reading

Next page: Symbols & BigInt

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 13 of 52 · Language basics · 15 min

Symbols & BigInt

Two extra primitive types show up in real APIs. Symbol makes unique keys (and well-known hooks like Symbol.iterator). BigInt holds integers bigger than Number can safely represent.

1Symbol

Symbol("id") is unique every time. Use it as an object key that will not clash with "id". Well-known symbols customize language behavior: Symbol.iterator, Symbol.toStringTag. JSON.stringify ignores symbol keys.

JavaScript
const id = Symbol("id");
const user = { name: "Ada", [id]: 7 };
user[id]; // 7

2BigInt

Write 10n or BigInt("9007199254740993"). You cannot mix BigInt and Number in arithmetic without converting. JSON does not have BigInt — you stringify them yourself.

JavaScript
const huge = 9007199254740993n;
huge + 1n;

3You will rarely type these, you will recognize them

Symbol("id") is a unique key that will not collide with a string "id". Rare in app code; used in protocols (Symbol.iterator). JSON.stringify drops symbol keys. BigInt is integers bigger than MAX_SAFE_INTEGER, written 10n. JSON.parse cannot produce BigInt; APIs use strings instead. Mixing 1 and 1n throws TypeError. Do not Number() a big id.

4Well-known symbols

Symbol.iterator is how for...of finds a walker. Symbol.toStringTag changes Object.prototype.toString. You will rarely create your own symbols on day one; you will see them in library code as hidden keys that will not clash with "id".

JavaScript
const box = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
  },
};
[...box]; // [1, 2]

Read the story, then the code, then the result. You do not need to type yet.

1A hidden id on an object

Two Symbol("id") calls are two different stickers, even if the label looks the same.

JavaScript
const id = Symbol("id");
const user = { name: "Ada", [id]: 7 };
console.log(user.name);
console.log(user[id]);

Result · prints: Ada 7

2Bigger than a safe Number

The n at the end marks a BigInt. Add only to other BigInts.

JavaScript
const huge = 9007199254740993n;
console.log(huge + 1n);

Result · prints: 9007199254740994n

Watch out for

  • 1n + 1 — TypeError. Convert explicitly.
  • Expecting Symbol("x") === Symbol("x"). Each call is a new symbol. Symbol.for("x") is the shared registry.

Keep reading

Next page: Conditionals

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 14 of 52 · Data & logic · 17 min

Conditionals

A conditional picks a path. if / else if / else is the readable default. A ternary returns a value from a one-line choice. switch compares one value to several exact cases and will fall through to the next case if you forget break.

Write the most specific test first. Keep branches short. If a branch grows past a handful of lines, extract a function so the failure path is still visible. In tests, prefer failing with a message that includes actual and expected instead of a silent else.

1if and else

The engine takes the first branch whose condition is truthy. Order matters: check the 90+ grade before the 75+ grade or everyone in the A band also matches B. else is the leftover. If you have no leftover that is safe, omit else and throw in a final guard so unexpected input is loud.

Braces are not optional once a branch has two statements. People add a second line later and forget braces; only the first line stays in the if. Always write { }. Extract a function when a branch needs a comment to explain it.

JavaScript
if (score >= 90) grade = "A";
else if (score >= 75) grade = "B";
else grade = "C";

2Ternary and switch

A ternary is an expression: it produces a value. const label = isOpen ? "open" : "closed" is fine. Nested ternaries are how reviews stall; use if. Do not use a ternary for side effects (logging, clicking). That is what if is for.

switch uses ===. case 200: will not match "200". Group cases that share a body. Always default. Fall-through (two case labels, one body) is legal and should be commented, otherwise the next reader thinks you forgot break.

JavaScript
switch (status) {
  case 200:
  case 201:
    return "ok";
  case 404:
    return "missing";
  default:
    return "other";
}

3switch in full

switch (value) compares with ===. Each case needs break (or return) or execution falls into the next case — sometimes on purpose for shared work. Always write default for the leftovers.

JavaScript
function label(day) {
  switch (day) {
    case "Sat":
    case "Sun":
      return "weekend";
    case "Mon":
      return "start";
    default:
      return "weekday";
  }
}

4Guard clauses

Return early for the boring cases so the rest of the function is the happy path. This beats a tall tower of nested else.

JavaScript
function area(w, h) {
  if (w <= 0 || h <= 0) return 0;
  return w * h;
}

5Decision tables in tests

A chain of if/else if is a decision table. Name the cases. For many exact strings, switch. For ranges (HTTP 2xx), if. Extract function statusFamily(code) so the test reads expect(statusFamily(res.status())).toBe("success").

JavaScript
function statusFamily(code) {
  if (code >= 200 && code < 300) return "success";
  if (code >= 400 && code < 500) return "client";
  if (code >= 500) return "server";
  return "other";
}

Read the story, then the code, then the result. You do not need to type yet.

1Exam grades

The first matching test wins. 82 never reaches the C branch.

JavaScript
const score = 82;
let grade;
if (score >= 90) grade = "A";
else if (score >= 75) grade = "B";
else grade = "C";
console.log(grade);

Result · prints: B

2A one-line choice

A ternary is an if that produces a value instead of choosing a block.

JavaScript
const isOpen = false;
const sign = isOpen ? "Come in" : "Closed";
console.log(sign);

Result · prints: Closed

3HTTP family

Ranges, not a switch of 50 codes.

JavaScript
function ok(code) {
  return code >= 200 && code < 300;
}
console.log(ok(201), ok(404));

Result · prints: true false

Watch out for

  • Forgetting braces when adding a second line to an if.
  • Missing break in switch so two cases run.

Keep reading

Next page: Functions

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 15 of 52 · Data & logic · 20 min

Functions

A function is a named recipe: inputs (parameters), steps (the body), an output (return). If you forget return, JavaScript still “returns” — it returns undefined. That is why a test can say expected 10, received undefined: the math ran, nobody handed the result back.

Functions are values. You can pass them into map, into addEventListener, into test("name", async () => { ... }). That is not an advanced trick. That is daily Playwright. The later chapters on closures and this exist because those callbacks close over variables and sometimes lose this.

Write small functions with names that are sentences: isExpired(date), parseBody(text), clickSave(page). If a function does three jobs, split it. Debugging a 12-line helper is cheaper than debugging a 80-line test.

1Declaration and arrow functions

function double(n) { return n * 2; } is a declaration. It is hoisted as a complete function, which is why some files call main at the top and define helpers below. const square = (n) => n * n is an expression assigned to const. It is not callable above that line. One-expression arrows return that expression; block arrows need return.

Default parameters run when the argument is undefined (including a missing argument), not when it is 0 or "": greet(name = "friend"). That is the ?? idea at the function door. Rest parameters (...nums) collect leftover arguments into a real array.

JavaScript
function double(n) {
  return n * 2;
}
const square = (n) => n * n;

2Return early

Guard clauses keep the happy path flat: if (!Number.isFinite(n)) return 0; then the rest of the function is the interesting work. Deeply nested if/else is how you miss a return and send undefined to a caller.

One function, one job. parseBody should parse, not also click a button. clickSave should click, not also assert the whole dashboard. Tests that read like a paragraph of helpers are easier to debug than one 80-line arrow inside test().

3Functions are values

apply(fn, value) calls whatever you passed. map, filter, addEventListener, setTimeout, and test() all take functions. You can return a function from a function — that is the next closure chapter. You can store them in objects as methods.

When you pass obj.method as a callback, you pass the function without the object. this may be undefined later. Bind it, wrap it in an arrow, or keep the call as () => obj.method(). The this chapter is dedicated to that failure.

JavaScript
function apply(fn, value) {
  return fn(value);
}
apply((n) => n * 2, 5); // 10

4Helpers that testers actually keep

parseBody(text), expectStatus(res, 201), login(page, user) are functions. Each returns something or throws with a message. They take the minimum arguments. They do not click and assert five pages. When a test is red, you open the helper and the stack line points at one job.

Passing functions around is daily work: test.each(rows)(async (row) => {}), locator.filter({ hasText }). The later this chapter is the only extra rule: do not pass a method without binding if it uses this.

5Defaults and rest

greet(name = "friend") fills in a missing argument (and only undefined, not ""). Rest (...nums) gathers leftover arguments into a real array. The old arguments object is array-like and does not exist in arrows — use rest.

JavaScript
function sum(label, ...nums) {
  const total = nums.reduce((a, n) => a + n, 0);
  return `${label}: ${total}`;
}
sum("bill", 10, 20, 5); // "bill: 35"

6What comes back

No return means undefined. return; also means undefined. You can return any value, including a function (that is a closure, later). A function is just a value — store it in a const, put it in an array, pass it in.

JavaScript
function skip() {}
skip(); // undefined
const ops = [(n) => n + 1, (n) => n * 2];
ops[1](3); // 6

7Pure helpers vs functions with side effects

A pure function’s output depends only on inputs — easy to unit test. Logging, fetch, and page.click are side effects. Testers: keep parse and format pure; keep “talk to the browser” at the edge. That is how you test URL builders without Playwright.

JavaScript
function qs(params) {
  return new URLSearchParams(params).toString();
}
qs({ q: "tea" }); // "q=tea"  — no network

Read the story, then the code, then the result. You do not need to type yet.

1Double a number

The name of the ingredient inside the recipe is n. You can call it with any number.

JavaScript
function double(n) {
  return n * 2;
}
console.log(double(4));
console.log(double(10));

Result · prints: 8 20

2A short arrow recipe

When the body is one expression, you can skip return and the braces.

JavaScript
const square = (n) => n * n;
console.log(square(5));

Result · prints: 25

3A missing ingredient has a default

If nobody passes a name, use “friend”.

JavaScript
function greet(name = "friend") {
  return `Hi, ${name}`;
}
console.log(greet("Sam"));
console.log(greet());

Result · prints: Hi, Sam Hi, friend

4Rest gathers the extras

label is the first argument. 1, 2, 3 land in nums.

JavaScript
function tagged(label, ...nums) {
  return label + nums.reduce((a, n) => a + n, 0);
}
console.log(tagged("#", 1, 2, 3));

Result · prints: #6

5Pure query builder

No fetch here — easy to unit test.

JavaScript
function qs(obj) {
  return new URLSearchParams(obj).toString();
}
console.log(qs({ q: "tea", page: "1" }));

Result · prints: q=tea&page=1

Watch out for

  • Forgetting return — the function then yields undefined.
  • Using an arrow function as a method that needs this (arrows do not bind their own this).

Keep reading

Next page: Scope

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 16 of 52 · Functions in depth · 18 min

Scope

Scope is “where a name can be seen.” JavaScript has global scope, function scope, and (since let/const) block scope. Inner scopes can read outer names. Outer scopes cannot see inner ones.

1Function and block

A block is { }. let and const live in the block. var lives in the function (or script). An if (flag) { let inner = 1 } cannot be read after the if. var older inside that if can. That is why var in a loop callback is a notorious bug, and why we do not use var.

JavaScript
function demo(flag) {
  if (flag) {
    let inner = 1;
    var older = 2;
  }
  // inner is not visible here
  // older is visible (var)
}

2The scope chain

Lookup walks outward. It is lexical: determined by where the function was written, not who called it. this is the opposite (call-site). Mixing those two models is how people get lost. Closures are lexical lookup still working after return.

3Global names

In a browser script (not a module), function foo() {} and var x become window properties. let and const at top level do not. Modules never leak into window. Tests that poke window.foo are testing an accident. Prefer imports.

4IIFE (the old pattern)

(function () { ... }()) runs immediately and hid names before modules existed. You will still see it in old bundles and in some inline scripts. New code uses modules or a plain block { const x = 1 }. You do not need to write IIFEs. You need to not fear them when you read them.

5Read a name like the engine does

Start in the current braces. If the name is not there, step out one function. Keep going until global. That chain is fixed when the function is created (lexical scope), not when it is called. Closures are this lookup still working after the outer function returned.

let in a for loop is a new binding per iteration. var is one binding for the whole function. That is why setTimeout in a var loop prints 3, 3, 3 and a let loop prints 0, 1, 2. Modules do not put their top-level lets on window. Scripts can. Prefer modules.

6Shadowing

An inner const name hides an outer name for that block. The outer name is still there when the block ends. Do not reuse the same name in three nested layers — pick total and innerTotal.

JavaScript
const name = "outer";
{
  const name = "inner";
  console.log(name); // inner
}
console.log(name); // outer

7Module scope vs global

Each ES module file has its own top-level scope. A const in helper.js is not visible in spec.js unless you export it. That is why Playwright fixtures are imported, not magically global. IIFE is the old way to fake a module in a single script tag.

Read the story, then the code, then the result. You do not need to type yet.

1Inside the if stays inside

inner lives only between those braces. Uncommenting console.log(inner) would throw.

JavaScript
function demo() {
  if (true) {
    const inner = "only here";
    console.log(inner);
  }
  // console.log(inner); // would error
}
demo();

Result · prints: only here

2The inner function can see the outer jar

makeGreeter still “remembers” name after it returns. That memory is a closure — next pages go deeper.

JavaScript
function makeGreeter(name) {
  return function () {
    return "Hi, " + name;
  };
}
const hi = makeGreeter("Riya");
console.log(hi());

Result · prints: Hi, Riya

Watch out for

  • Declaring a loop counter with var and using it in a callback — every callback sees the last value. Use let.
  • Forgetting that an inner function can see outer lets, including ones you did not mean to capture.

Keep reading

Next page: Hoisting & the TDZ

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 17 of 52 · Functions in depth · 19 min

Hoisting & the TDZ

Hoisting is the engine registering declarations before it runs your lines. It is not literally moving code. Function declarations are fully available above their line. var exists as undefined above its line. let and const exist but cannot be read until their line — that gap is the temporal dead zone (TDZ).

1Function declarations

function greet() {} is fully available above its line in the same scope. That is why a file can call main() at the top and define function main() below. It is a style, not a requirement. It does not apply to const greet = () => {}.

JavaScript
greet();
function greet() {
  return "hi";
}

2var

var score is created at the top of the function as undefined. console.log(score) before var score = 10 prints undefined, not an error. The assignment still happens on its line. This hides typos and “I thought this ran later” bugs. Do not use var.

JavaScript
console.log(score); // undefined, not an error
var score = 10;

3let, const, and the TDZ

The names exist in the block from the start, but reading them before the declaration line throws ReferenceError. That gap is the temporal dead zone. It is a feature. “let is not hoisted” is a myth; it is hoisted and uninitialized.

JavaScript
console.log(total); // ReferenceError
const total = 3;

4Function expressions are not hoisted as functions

const add = (a, b) => a + b follows const rules. add(1, 2) above that line is a TDZ error, not a working call. class User {} is the same family as let. If you need to call a helper above its line, use a function declaration.

JavaScript
add(1, 2); // TypeError or ReferenceError
const add = (a, b) => a + b;

5What to do in real code

const and let. Declare before use. No var. Do not design a file that depends on hoisting except for a cluster of function declarations under a small main(). Testers: if a helper is undefined at the top of a spec, you imported wrong or you used an arrow above its const.

6A timeline you can draw

First the engine creates bindings for the scope. Function declarations are filled in completely. var is set to undefined. let and const exist but are uninitialized (TDZ). Then lines run. If a line reads a let before its declaration, throw. If a line reads a var before assignment, you get undefined and a subtle bug.

const add = (a, b) => a + b is not a function declaration. It is const. Calling add above that line is TDZ or a temporal dead zone ReferenceError. class declarations are TDZ too. The practical rule: declare, then use. Use function declarations only as a style for helpers below main in the same file.

7A mixed file

Declarations are registered first, assignments run in order. That is why a function declaration can sit below main(), and why const add = (a, b) => a + b cannot be called above that line.

JavaScript
main();
function main() {
  console.log(add(1, 2));
}
function add(a, b) {
  return a + b;
}

8Interview output questions

They will show console.log(a); var a = 1 and want undefined. They will show console.log(b); let b = 1 and want ReferenceError. They will show a function declaration called above its line and want success. You now have the rules; say “TDZ” out loud for let/const.

Read the story, then the code, then the result. You do not need to type yet.

1A declared function works above its line

The engine already registered greet as a real function.

JavaScript
console.log(greet());
function greet() {
  return "ready";
}

Result · prints: ready

2var is empty, not missing

You get undefined, which hides the bug. That is why we do not use var.

JavaScript
console.log(score);
var score = 10;

Result · prints: undefined (then score becomes 10)

3let refuses to be used too early

This throw is a good thing. Swap the two lines and it works.

JavaScript
console.log(total);
const total = 3;

Result · throws: ReferenceError (TDZ)

Watch out for

  • Saying “let is not hoisted.” It is hoisted; it just stays in the TDZ.
  • Calling a const arrow function above its definition.
  • Using a var from an inner block and thinking it is local to that block.

Keep reading

Next page: Recursion

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 18 of 52 · Functions in depth · 17 min

Recursion

A recursive function solves a problem by calling itself on a smaller piece, until a base case returns without another call. Trees, nested arrays, and some math (factorial, Fibonacci) fit this shape. A loop is often clearer; recursion is the right tool when the data is nested.

1Base case first

The stop condition must not call the function again. factorial(n) stops at n <= 1. Missing it is infinite recursion and a RangeError. Wrong size (always n, never n-1) is the same crash. Write the base case, then the smaller call, then combine.

JavaScript
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

2Nested data

A node with children: handle the leaf, map recursion over children, concat. Comments with replies, menus with submenus, JSON with nested items. Deep trees: explicit stack loop. JavaScript does not give you portable tail-call optimization you can bet a production crawler on.

3When to recurse in tests and interviews

Nested comments, nested menus, nested JSON: a function that handles a node and maps over children is the natural shape. Write the leaf first (no children → return [node] or 0). Then combine. If the tree can be thousands deep, use an explicit stack loop; JavaScript will not reliably tail-call optimize.

factorial and Fibonacci are teaching examples. Fibonacci naive recursion is exponential; interviews like you to notice and use a loop or memoization. In production test code, nested.forEach plus recursion is fine for small payloads.

4Trust the smaller call

Write the base case. Assume the recursive call is correct for a smaller input. Combine. That is the whole method. A stack trace of factorial(4) is 4 → 3 → 2 → 1, then multiplies on the way back.

JavaScript
function sumTo(n) {
  if (n <= 0) return 0;
  return n + sumTo(n - 1);
}
sumTo(4); // 10

5Nested comments / trees in JSON

A comment has replies: [{ text, replies: [...] }]. Recursion walks it. For huge trees, a loop plus an explicit stack is safer. Interviewers like flatten([1,[2,[3]]]).

JavaScript
function flatten(arr) {
  return arr.flat(Infinity);
}
flatten([1, [2, [3]]]); // [1, 2, 3]

Read the story, then the code, then the result. You do not need to type yet.

1Countdown

Print n, then call yourself with n - 1, until you hit 0.

JavaScript
function countdown(n) {
  if (n <= 0) {
    console.log("go");
    return;
  }
  console.log(n);
  countdown(n - 1);
}
countdown(3);

Result · prints: 3 2 1 go

2Sum a nested list

If the item is a number, add it. If it is a list, sum that list first.

JavaScript
function sumTree(node) {
  if (typeof node === "number") return node;
  let total = 0;
  for (const child of node) total += sumTree(child);
  return total;
}
console.log(sumTree([1, [2, 3], 4]));

Result · prints: 10

Watch out for

  • Missing the base case.
  • Recursing on the same size input (infinite calls).

Keep reading

Next page: Arrays

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 19 of 52 · Data & logic · 19 min

Arrays

An array is an ordered list. The first item is index 0. length is how many items there are, not the last index. The last index is length - 1. Off-by-one errors (walking to length inclusive) give you undefined, and then .name on undefined blows up with a TypeError you have already seen in stack traces.

Some methods change the array you already have (push, pop, splice, sort). Some return a new array and leave the original alone (map, filter, slice, concat, toSorted). In tests and in interview puzzles, default to returning a new array unless the problem says to mutate. Shared fixtures plus sort() in place is how one test poisons the next.

1Read and change

nums[0] is the first item. nums.at(-1) or nums[nums.length - 1] is the last. push/pop work at the end (cheap). unshift/shift work at the start (renumber everything — fine for small test lists, poor for huge ones). includes and indexOf search with ===.

join builds a string. concat and slice copy. splice deletes or inserts in place and returns the removed items — easy to confuse with slice. Read the first letter: slice copies, splice mutates.

JavaScript
const colors = ["red", "green"];
colors.push("blue");
colors.join(", ");

2Copy vs mutate

[...arr] and arr.slice() are shallow copies: nested objects are still shared. sort() and reverse() mutate by default; toSorted and toReversed are the non-mutating versions in modern engines. If a test helper sorts a shared fixture, the next test sees the sorted order.

Interview prompts often say “do not mutate the input.” Even when they do not, returning a new array is the safer default. structuredClone is the deep copy when you truly need one.

3Loop vs methods

A for loop can do anything. Once you can write it, prefer map/filter/reduce for transforms because the name tells the next reader the intent. Use a loop when you must break early, when you need indices, or when the body has several side effects.

forEach looks like a loop but you cannot break. for...of plus if + break is clearer. The next chapter is the methods in full.

4API lists, one pass at a time

Bodies like { items: [ ... ] } are arrays. Guard with Array.isArray. Then map emails, filter open tickets, find the row whose id matches. sort() mutates — copy first if the array is a shared fixture. for...of plus await is the async version of a loop; map plus Promise.all is the parallel version. forEach plus async is a trap (it will not wait).

5slice vs splice

slice(start, end) photocopies a range (end not included) and leaves the original. splice(start, deleteCount, ...items) edits the original: remove, insert, or both. When you are not sure, use slice or spread.

JavaScript
const a = ["a", "b", "c", "d"];
a.slice(1, 3);     // ["b", "c"] — a unchanged
const b = ["a", "b", "c"];
b.splice(1, 1, "X"); // b is ["a", "X", "c"]

6Find and at

includes tells you membership. find returns the first matching item (or undefined). findIndex returns the index or -1. at(-1) is the last item without length - 1.

JavaScript
[10, 20, 30].includes(20); // true
[10, 20, 30].find((n) => n > 15); // 20
[10, 20, 30].at(-1); // 30

7map / filter on API lists

bodies.map((row) => row.id) is the id list. .filter((row) => row.status === "open") is the open bugs. .find((row) => row.email === email) is one user or undefined — then assert it exists. These beat index loops in specs.

JavaScript
const rows = [{ id: 1, status: "open" }, { id: 2, status: "done" }];
const openIds = rows.filter((r) => r.status === "open").map((r) => r.id);
console.log(openIds);

Result · prints: [1]

Read the story, then the code, then the result. You do not need to type yet.

1A fruit shelf

After push, there are three fruits. join makes a sentence.

JavaScript
const fruits = ["mango", "guava"];
fruits.push("banana");
console.log(fruits[0]);
console.log(fruits[fruits.length - 1]);
console.log(fruits.join(", "));

Result · prints: mango banana mango, guava, banana

2Copy, don’t steal

slice() with no arguments photocopies the list. The original stays put.

JavaScript
const a = [10, 20];
const b = a.slice();
b[0] = 99;
console.log(a);
console.log(b);

Result · prints: [10, 20] [99, 20]

3Photocopy then edit

slice copies. splice on the copy leaves the shop list alone.

JavaScript
const shop = ["tea", "milk", "sugar"];
const next = shop.slice();
next.splice(1, 1);
console.log(shop);
console.log(next);

Result · prints: ["tea", "milk", "sugar"] ["tea", "sugar"]

4IDs of open rows

filter then map — the tester’s two-step.

JavaScript
const rows = [{ id: 1, open: true }, { id: 2, open: false }];
console.log(rows.filter((r) => r.open).map((r) => r.id));

Result · prints: [1]

Watch out for

  • Using pop when you meant to read the last item without removing it.
  • Confusing slice (copy) with splice (mutate).

Keep reading

Next page: Objects

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 20 of 52 · Data & logic · 19 min

Objects

An object is a labeled record: a user, a ticket, a JSON body. Keys are strings (or symbols). Values can be anything, including nested objects and arrays. Almost every API you assert is an object or a list of objects. Learning objects well is learning how to read production JSON.

Dot access (user.email) is for keys you typed. Bracket access (user[key]) is for keys in a variable. Optional chaining (user.address?.city) reads nested fields without throwing when a 400 body omitted address. Missing and undefined are not the same as the key existing with value null — JSON uses null on purpose.

1Access and update

user.name and user["name"] are the same key. Use brackets when the key is in a variable or is not a valid identifier (user["full-name"]). Assignment adds or overwrites. delete user.city removes the key. Mutating a nested object through two names (copy and original) is the reference chapter — both names are the same object.

Destructuring is unpacking: const { name, age } = user. Defaults: const { role = "guest" } = user. Rename: const { email: userEmail } = user. Playwright’s async ({ page, request }) => is this, on a fixtures object.

JavaScript
const user = { name: "Riya", age: 24 };
user.city = "Pune";
const { name, age } = user;

2Keys, values, in

Object.keys, Object.values, and Object.entries turn a record into arrays you can map. "age" in user is true for inherited keys too. Object.hasOwn(user, "age") (or Object.prototype.hasOwnProperty.call) is own keys only. For JSON bodies, keys are own.

Optional chaining: user.address?.city is undefined if address is null or undefined, and does not throw. Combine with ?? for a default. Do not chain into a call you did not mean to skip: user.getName?.() is a different question (“was the function there?”).

3JSON is objects with a dress on

After JSON.parse you have a plain object. No methods unless you attach them. Nested fields fail with TypeError if you forgot ?. and a 400 body omitted them. Object.keys is for “what did they send?” Length of keys is not a substitute for checking required fields. Compare the contract, not the whole object identity.

4Optional chaining and defaults

user.address?.city is undefined if address is missing, instead of a throw. Combine with ??: user.address?.city ?? "unknown". Optional chaining also works on calls: user.greet?.().

JavaScript
const user = { name: "Ada" };
user.address?.city; // undefined
user.address?.city ?? "unknown"; // "unknown"

5Copy and merge

const copy = { ...user } is a shallow copy. const merged = { ...defaults, ...overrides } — later keys win. Nested objects are still shared; structuredClone(user) deep-copies JSON-like data. Object.keys / values / entries turn the drawer labels into arrays for loops.

JavaScript
const defaults = { tea: true, sugar: 1 };
const cup = { ...defaults, sugar: 2 };
Object.entries(cup); // [["tea", true], ["sugar", 2]]

6Walking a nested API body

data.user?.address?.zip ?? null. Object.keys(body).sort() if you snapshot keys. Destructure in the test: const { id, email } = await res.json() then expect(id).toBeTruthy(). If a field is extra, you usually ignore it unless the contract forbids additional properties.

JavaScript
const body = { user: { email: "a@b.c" } };
const email = body.user?.email;
const zip = body.user?.address?.zip ?? null;
console.log(email, zip);

Result · prints: a@b.c null

Read the story, then the code, then the result. You do not need to type yet.

1A person card

Add a city later. Pull two fields out with destructuring.

JavaScript
const user = { name: "Riya", age: 24 };
user.city = "Pune";
const { name, city } = user;
console.log(name, city);

Result · prints: Riya Pune

2When the key is in a variable

Dots cannot do this. Use brackets.

JavaScript
const key = "age";
const user = { name: "Sam", age: 31 };
console.log(user[key]);

Result · prints: 31

3Missing branch, no crash

address is absent. ?. stops. ?? supplies a label.

JavaScript
const user = { name: "Ada" };
console.log(user.address?.city ?? "no city");

Result · prints: no city

4Safe nested read

Missing address does not throw.

JavaScript
const body = { user: { name: "Ada" } };
console.log(body.user?.address?.zip ?? "none");

Result · prints: none

Watch out for

  • Copying an object with assignment (const b = a) — both names point to the same object.
  • Assuming JSON key order is random. Insertion order is kept for string keys.

Keep reading

Next page: Loops

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 21 of 52 · Data & logic · 17 min

Loops

A loop repeats work until you say stop. for is the counter. while is “keep going until this becomes false.” for...of walks values without you managing an index. break leaves the loop. continue skips the rest of this round and starts the next.

In tests, a loop over test.each data is healthy. A loop that polls the DOM with sleep is how flakes are born — prefer locators that wait. In interview problems, a single pass with a Map beats a nested loop if you are asking “have I seen this?”

1for, while, for...of

for (let i = 0; i < arr.length; i++) is the index form. The condition runs before each round; the third clause runs after. Off-by-one is usually <= length or < length - 1 when you meant the other. for...of (const item of arr) is the value form and works on any iterable (arrays, strings, sets, maps’ keys).

while is for unknown counts: keep paging until next is null, keep retrying until status !== 429 or attempts exceed 3. If you forget to change the condition, you infinite-loop and CI hangs. for...in walks keys, including inherited — rarely what you want for arrays. Prefer for...of or methods.

JavaScript
let sum = 0;
for (let i = 1; i <= 5; i++) sum += i;

for (const n of [10, 20]) sum += n;

2forEach

arr.forEach((item, index) => { ... }) is a loop dressed as a method. return inside forEach skips one item, like continue, not like break. You cannot stop early. It also does not await async callbacks — it will fire them and not wait. For async work, for...of plus await, or map plus Promise.all.

3break, continue, labels

break leaves the loop. continue skips to the next round. You can label an outer loop (outer: for ...) and break outer to leave both — rare; usually a function return is clearer.

JavaScript
for (const n of [1, 2, 3, 4]) {
  if (n === 2) continue;
  if (n === 4) break;
  console.log(n);
}
// 1 then 3

4for...in is for keys, not lists

for...in walks enumerable keys, including inherited ones, as strings. On an array you get "0", "1", and maybe extra. Use for...of for values, Object.keys for an object’s own keys.

JavaScript
const user = { name: "Ada", age: 36 };
for (const key of Object.keys(user)) {
  console.log(key, user[key]);
}

5await inside a loop (tests)

for (const id of ids) { await deleteUser(id); } is sequential and correct if the API forbids parallel deletes. forEach does not wait for async callbacks — a famous bug. Use for...of with await, or Promise.all when order does not matter.

JavaScript
async function deleteAll(ids) {
  for (const id of ids) {
    await deleteUser(id);
  }
}
// ids.forEach(async (id) => await deleteUser(id)); // does NOT wait

Read the story, then the code, then the result. You do not need to type yet.

1Add 1 through 5

i starts at 1. While i is at most 5, add i, then bump i.

JavaScript
let sum = 0;
for (let i = 1; i <= 5; i++) {
  sum += i;
}
console.log(sum);

Result · prints: 15

2Each guest on a list

for...of gives you the value, not the index. Use this when you do not need the number.

JavaScript
const guests = ["Ada", "Bea"];
for (const name of guests) {
  console.log("Welcome, " + name);
}

Result · prints: Welcome, Ada Welcome, Bea

3Skip and stop

continue skips 2. break never reaches 4.

JavaScript
const out = [];
for (const n of [1, 2, 3, 4]) {
  if (n === 2) continue;
  if (n === 4) break;
  out.push(n);
}
console.log(out);

Result · prints: [1, 3]

4for...of + await is sequential

Each delete waits. forEach(async ...) would not.

JavaScript
async function run(ids) {
  const out = [];
  for (const id of ids) out.push(id);
  return out;
}
run([1, 2]).then(console.log);

Result · prints: [1, 2]

Watch out for

  • Off-by-one: i <= arr.length walks one past the end (undefined).
  • Infinite while loops when the condition never changes.

Keep reading

Next page: map, filter, reduce

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 22 of 52 · Modern JavaScript · 19 min

map, filter, reduce

map, filter, and reduce are how modern JavaScript transforms lists without a handwritten index. Interviewers ask them because they show you can think in data, not only in i++. Testers use them on API arrays: keep open tickets, pick emails, sum amounts.

map always returns an array of the same length. filter returns a (possibly shorter) array of items that passed the test. reduce threads one accumulator through the list and returns that one value — a number, a string, an object, even another array. Start reduce with an initial value so empty lists do not throw.

1map and filter

map transforms each item and returns a new array of the same length. Use it to pick a field: users.map((u) => u.email). filter keeps items whose callback is truthy: tickets.filter((t) => t.status === "open"). Chaining filter then map is a readable pipeline. Do not use map when you meant forEach (you are ignoring the returned array and causing extra allocations for no benefit).

find returns the first match or undefined. some returns whether any item matches. every returns whether all match. includes is === search. findIndex is the index or -1. These beat handwritten flags in a for loop for the same questions.

JavaScript
const doubled = [1, 2, 3].map((n) => n * 2);
const evens = [1, 2, 3, 4].filter((n) => n % 2 === 0);

2reduce

reduce((acc, item) => nextAcc, initial) is “start with initial, then feed each item.” Sum: (acc, n) => acc + n with initial 0. Group: build an object keyed by status. Flatten one level: (acc, item) => acc.concat(item) with initial [].

If you omit initial and the array is empty, reduce throws. Always pass initial in production helpers. If a reduce is hard to name, it may want to be a loop. Interviewers still love a clean sum and a frequency map.

3find, some, every, flat

find is the first match or undefined. some is “at least one.” every is “all of them.” flat(2) unwraps nested arrays two levels. flatMap maps then flattens one level.

JavaScript
[1, 2, 3].some((n) => n > 2);  // true
[1, 2, 3].every((n) => n > 0); // true
[1, [2, [3]]].flat(2);         // [1, 2, 3]

4sort mutates

arr.sort() changes arr. Without a compare function it sorts as strings ("10" before "2"). Copy first: [...arr].sort((a, b) => a - b) for numbers. toSorted() (newer) returns a copy.

JavaScript
const nums = [10, 2, 1];
[...nums].sort((a, b) => a - b); // [1, 2, 10]
nums; // still [10, 2, 1]

5reduce for grouping test data

Group rows by status into an object of arrays. Start with {}. Testers use this to build parameterized tables from a JSON dump.

JavaScript
const rows = [{ s: "open" }, { s: "open" }, { s: "done" }];
const by = rows.reduce((acc, row) => {
  acc[row.s] = [...(acc[row.s] ?? []), row];
  return acc;
}, {});
console.log(Object.keys(by), by.open.length);

Result · prints keys and 2

Read the story, then the code, then the result. You do not need to type yet.

1Double every price

map always returns a new array of the same length.

JavaScript
const prices = [10, 20, 30];
const doubled = prices.map((n) => n * 2);
console.log(doubled);

Result · prints: [20, 40, 60]

2Keep the even seats

filter’s function should return true to keep the item.

JavaScript
const seats = [1, 2, 3, 4, 5, 6];
const even = seats.filter((n) => n % 2 === 0);
console.log(even);

Result · prints: [2, 4, 6]

3A running total

acc is “what I have so far.” Start at 0. Add each number.

JavaScript
const nums = [5, 5, 10];
const total = nums.reduce((acc, n) => acc + n, 0);
console.log(total);

Result · prints: 20

4Sort numbers, keep the original

Spread first so sort cannot chew the source list.

JavaScript
const nums = [10, 2, 1];
const ordered = [...nums].sort((a, b) => a - b);
console.log(ordered);
console.log(nums);

Result · prints: [1, 2, 10] [10, 2, 1]

5find one user

undefined if missing — then fail the test.

JavaScript
const users = [{ email: "a@b.c" }];
const u = users.find((x) => x.email === "a@b.c");
console.log(u?.email);

Result · prints: a@b.c

Watch out for

  • Forgetting the initial value of reduce on an empty array — it throws.
  • Using map when you meant forEach (map’s return value ignored).

Keep reading

Next page: Closures & higher-order functions

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 23 of 52 · Functions in depth · 19 min

Closures & higher-order functions

A closure is an inner function that still sees variables from the outer function after the outer call has finished. The engine keeps those variables alive because the inner function still needs them. That is not a leak. That is the feature.

You use closures already: a Playwright test closes over page from the fixtures argument. A helper makeLogin(user) that returns async (page) => { ... } closes over user. Private counters, debounce timers, and configuration wrappers are the same idea. If this page clicks, higher-order functions and the this page become much easier.

1Functions as values

makeMultiplier(x) returns a function that still sees x. Each call to makeMultiplier makes a new inner function with its own x. times3 and times10 do not share a counter unless you put the counter in a scope they both close over.

This is how you configure without a global: makeClient(baseURL) returns (path) => fetch(baseURL + path). Tests inject baseURL. Production injects production. The inner function closed over the configured value.

JavaScript
function makeMultiplier(x) {
  return (n) => n * x;
}
const times3 = makeMultiplier(3);
times3(4); // 12

2Why closures matter

Event handlers close over the element or the user id they need. Timers close over the latest flag. Test fixtures close over the browser context. The classic bug is closing over a var loop index so every handler sees the last i — use let, or bind the value in the function parameter.

Closures keep memory alive. A huge array captured by a long-lived handler will not be garbage-collected until the handler is gone. Remove listeners in teardown. In page objects, that is usually “the test ended and the context closed.”

3The loop trap

var is one variable for the whole function. Three timeouts scheduled in a var loop all see i === 3. let creates a fresh i per round, so you get 0, 1, 2. This is why the hoisting/scope pages begged you to drop var.

JavaScript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var", i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let", j), 0);
}
// var 3, var 3, var 3 then let 0, let 1, let 2

4Factory functions

A function that returns an object of functions, all closing over private lets, is how modules hid data before class private fields. Each call to makeBank() gets its own balance.

JavaScript
function makeBank(start) {
  let balance = start;
  return {
    deposit(n) { balance += n; },
    total() { return balance; },
  };
}
const a = makeBank(10);
a.deposit(5);
a.total(); // 15

5Fixtures that close over a base URL

makeClient(base) returns { get, post } that all use base. That is a closure plus a factory — the shape of a small API client and of Playwright fixtures that capture a request context.

JavaScript
function makeClient(base) {
  return {
    url(path) {
      return base + path;
    },
  };
}
const api = makeClient("https://api.test");
api.url("/health");

Result · https://api.test/health

Read the story, then the code, then the result. You do not need to type yet.

1A custom multiplier

makeMultiplier(3) returns a function that still knows x is 3.

JavaScript
function makeMultiplier(x) {
  return (n) => n * x;
}
const times3 = makeMultiplier(3);
console.log(times3(4));
console.log(times3(10));

Result · prints: 12 30

2A private counter

Nobody outside can touch count except through next(). That is data hiding.

JavaScript
function makeCounter() {
  let count = 0;
  return function next() {
    count += 1;
    return count;
  };
}
const ticket = makeCounter();
console.log(ticket());
console.log(ticket());

Result · prints: 1 2

3let in a loop

Each timeout closes over its own j. var would print 3, 3, 3.

JavaScript
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}

Result · prints: 0 1 2

4Client closed over base

url() still sees base after makeClient returned.

JavaScript
function makeClient(base) {
  return { url: (p) => base + p };
}
console.log(makeClient("http://x").url("/y"));

Result · prints: http://x/y

Watch out for

  • Creating functions inside a hot loop when a single function would do.
  • Expecting a loop variable from var to be unique per iteration — use let.

Keep reading

Next page: this, call, apply, bind

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 24 of 52 · Functions in depth · 21 min

this, call, apply, bind

this is not the function’s enclosing scope. For ordinary functions, this is decided by how the function is called. Arrow functions are the exception: they close over this from the place they were written.

1How it is called

obj.method() binds this to obj. fn() binds this to undefined in strict mode. new Fn() binds this to the new instance. That is the default story. Losing this is almost always “I pulled the method off the object and called it later.” The function does not remember the object unless you bind or wrap.

JavaScript
const user = {
  name: "Ada",
  hello() {
    return this.name;
  },
};
user.hello(); // "Ada"
const fn = user.hello;
fn(); // undefined (strict) — lost the object

2call, apply, bind

call sets this and passes arguments listed. apply sets this and takes an array of arguments. bind returns a new function with this glued (and optional partial arguments). Use bind for event handlers and for setTimeout(obj.method, 0). Prefer an arrow wrapper when it reads better: () => obj.method().

JavaScript
function greet(punct) {
  return `hi ${this.name}${punct}`;
}
greet.call({ name: "Sam" }, "!");
const hi = greet.bind({ name: "Sam" }, "!");
hi();

3Arrow functions

No own this. They close over the this of the enclosing function. Perfect inside a method that needs the instance in a callback. Wrong as the method itself if you wanted this to be the object. No arguments object, no new Arrow(). The timer example in the snippet is the pattern Playwright page objects use with promises too.

JavaScript
const timer = {
  n: 0,
  start() {
    setTimeout(() => {
      this.n += 1; // this is timer
    }, 0);
  },
};

4A tester’s checklist

Called as obj.method()? this is obj. Called as fn()? this is undefined (strict). Passed to setTimeout(obj.method, 0)? same as fn() — lost. Fix: setTimeout(() => obj.method(), 0) or obj.method.bind(obj). Arrow callbacks inside a method keep the method’s this. Arrow as the method itself does not get obj as this.

class methods have the same rules. Playwright page objects: this.page is correct inside methods when you call loginPage.submit(). If you pass loginPage.submit to something that calls it, bind it. This is the same bug as DOM handlers.

5new and precedence

new Fn() makes an object and sets this to it (the prototypes page). Precedence, from winning to losing: bind (already locked) > call/apply > obj.method() > new > bare fn() (undefined in strict). Arrows skip all of this and close over outer this.

JavaScript
function Box(n) {
  this.n = n;
}
const b = new Box(3);
b.n; // 3

6Page objects and this

class LoginPage { constructor(page) { this.page = page; } async submit() { await this.page.click("button"); } } — methods need this.page. If you pass this.submit to a callback, bind it. Playwright examples often use arrows in constructors or just call this.submit() from another method so this stays the instance.

Read the story, then the code, then the result. You do not need to type yet.

1The owner calls the method

user.hello() — the thing before the dot is this.

JavaScript
const user = {
  name: "Ada",
  hello() {
    return "Hi, " + this.name;
  },
};
console.log(user.hello());

Result · prints: Hi, Ada

2Lost this, then bind it back

Passing the method as a bare function drops the owner. bind sews it back on.

JavaScript
const user = {
  name: "Ada",
  hello() {
    return this.name;
  },
};
const loose = user.hello;
const bound = user.hello.bind(user);
console.log(bound());

Result · prints: Ada

3call with a stand-in owner

greet was not written on guest, but call pretends it was.

JavaScript
function greet() {
  return "Hi, " + this.name;
}
console.log(greet.call({ name: "Sam" }));

Result · prints: Hi, Sam

4Lost this

Pulling the method off the object loses the object.

JavaScript
const page = {
  url: "/app",
  href() { return this.url; },
};
const fn = page.href;
console.log(page.href());
try { console.log(fn()); } catch (e) { console.log("threw"); }

Result · prints /app then either undefined or threw (strict)

Watch out for

  • Taking a method off an object (const f = obj.inc) then calling f() and wondering why this died. Use obj.inc() or inc.bind(obj).
  • Writing an object method as an arrow and expecting this to be the object.

Keep reading

Next page: Prototypes & new

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 25 of 52 · Objects & classes · 21 min

Prototypes & new

Every object has an internal [[Prototype]] (read as obj.__proto__ or Object.getPrototypeOf(obj)). If a property is missing, the engine looks at the prototype, then that prototype’s prototype, until it hits null. class is sugar over this same chain.

1The chain

If a property is missing on the object, the engine looks at its prototype, then that prototype’s prototype, until null. Object.create(proto) sets that link. {}.toString works because Object.prototype sits on almost every chain. Shared methods belong on the prototype so instances do not each carry a copy of the same function.

JavaScript
const animal = { speak() { return "…"; } };
const dog = Object.create(animal);
dog.speak();

2Constructor functions and new

function User(name) { this.name = name; } plus new User("Ada") is what class desugars toward. new creates an object, sets its prototype to User.prototype, binds this, runs the function, and returns the object (unless you return a different object). Methods go on User.prototype, not inside the constructor, unless they must close over constructor-local secrets.

JavaScript
function User(name) {
  this.name = name;
}
User.prototype.hello = function () {
  return `Hi, ${this.name}`;
};
new User("Ada").hello();

3instanceof and constructor

instanceof walks the chain looking for Constructor.prototype. Across iframes, realms differ: Array.isArray is safer than instanceof Array. The constructor property is writable and not a security check. You almost never assign __proto__ in app code.

4Why this still matters

class is this model with nicer syntax. Array.prototype.map is why map exists on every array. When a method “cannot be found,” you are either on the wrong object or the prototype was not what you thought (a plain object pretending to be an array).

5Where methods really live

[].map works because Array.prototype.map exists on the chain. You did not copy map onto every array. class User { hello() {} } puts hello on User.prototype. Instances share it. That is why changing the prototype changes all instances — powerful and dangerous.

instanceof walks the chain. Across iframes, Array from another realm may fail instanceof Array; Array.isArray still works. In tests with multiple frames, prefer Array.isArray(body.items) before .map.

6own vs inherited

obj.hasOwnProperty("n") (or Object.hasOwn(obj, "n")) asks “is it on this object, not the family book?” Object.create(null) has no prototype — a clean dictionary with no inherited toString. class instances still use the chain; you just do not type .prototype every day.

JavaScript
const a = Object.create({ kind: "tea" });
a.name = "masala";
Object.hasOwn(a, "name"); // true
Object.hasOwn(a, "kind"); // false (inherited)

7Why Array.isArray exists

An iframe can have another realm’s Array. instanceof Array may be false across frames. Array.isArray still works. In tests, prefer Array.isArray(body.items) before .map.

Read the story, then the code, then the result. You do not need to type yet.

1Borrow a method

dog has no speak of its own. It finds speak on animal.

JavaScript
const animal = {
  speak() {
    return "…";
  },
};
const dog = Object.create(animal);
console.log(dog.speak());

Result · prints: …

2The old new pattern

This is what class User { ... } is doing under the hood.

JavaScript
function User(name) {
  this.name = name;
}
User.prototype.hello = function () {
  return "Hi, " + this.name;
};
console.log(new User("Ada").hello());

Result · prints: Hi, Ada

Watch out for

  • Copying methods onto every instance in the constructor instead of the prototype — it works, but wastes memory.
  • Forgetting new so this is undefined (strict) and the assignment throws.

Keep reading

Next page: Classes

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 26 of 52 · Objects & classes · 18 min

Classes

class is the modern spelling of “a constructor plus methods on a shared prototype.” new User("Ada") runs constructor, attaches fields to the instance, and returns the object. Methods like hello() live on User.prototype so every instance shares one function.

extends reuses another class. You must call super(...) before you touch this in a subclass constructor. Testers meet classes as page objects: class LoginPage { constructor(page) { this.page = page; } async submit() { ... } }. The this rules from the previous chapter still apply: extract the method and call it loose, and this dies.

1class fields and methods

constructor is the setup. this.page = page in a page object is the usual field. Methods are shared. Fields you assign in the constructor are per instance. static methods are called on the class: User.fromJson(body). They do not get an instance as this unless you pass one.

Private fields (#name) are a later syntax; many codebases still use the _name convention, which is only a hint. Getters look like properties: get area() { return this.w * this.h }. They can hide work; keep them cheap.

JavaScript
class User {
  constructor(name) {
    this.name = name;
  }
  hello() {
    return `Hi, ${this.name}`;
  }
}

2extends

class Admin extends User inherits methods. constructor must call super(...) before this. That call runs the parent constructor. If you omit constructor, the default constructor calls super with the same arguments.

instanceof Admin is true for an Admin instance, and instanceof User is usually true too because the prototype chain includes User.prototype. Playwright’s locators are not a class you extend in tests; page objects are the class you write.

3Getters, static, private

get area() lets you write box.area with no (). static belongs to the class, not an instance (Mathy.double(2)). #name is truly private — only the class body can touch it. Fields can have defaults: cups = 0.

JavaScript
class Box {
  #secret = 7;
  constructor(w, h) {
    this.w = w;
    this.h = h;
  }
  get area() {
    return this.w * this.h;
  }
  static square(n) {
    return new Box(n, n);
  }
}

4Page object, JS version

A page object is a class that wraps locators and actions. constructor(page) stores the Playwright page. Methods are async because clicks are. You do not need TypeScript to use this pattern.

JavaScript
class LoginPage {
  constructor(page) {
    this.page = page;
  }
  async goto() {
    await this.page.goto("/login");
  }
}

Read the story, then the code, then the result. You do not need to type yet.

1Stamp out two users

Each instance has its own name. hello is one shared method.

JavaScript
class User {
  constructor(name) {
    this.name = name;
  }
  hello() {
    return `Hi, ${this.name}`;
  }
}
console.log(new User("Ada").hello());
console.log(new User("Sam").hello());

Result · prints: Hi, Ada Hi, Sam

2A more specific stamp

Admin is a User plus a role. super(name) fills in the User part first.

JavaScript
class User {
  constructor(name) {
    this.name = name;
  }
}
class Admin extends User {
  constructor(name) {
    super(name);
    this.role = "admin";
  }
}
const a = new Admin("Riya");
console.log(a.name, a.role);

Result · prints: Riya admin

3A getter looks like a field

No () after area. static square builds an instance without new at the call site of square — new is inside the method.

JavaScript
class Box {
  constructor(w, h) {
    this.w = w;
    this.h = h;
  }
  get area() {
    return this.w * this.h;
  }
}
console.log(new Box(3, 4).area);

Result · prints: 12

4Instance stores page

this.page is set in the constructor.

JavaScript
class Box {
  constructor(n) { this.n = n; }
  twice() { return this.n * 2; }
}
console.log(new Box(4).twice());

Result · prints: 8

Watch out for

  • Extracting a method (const fn = obj.inc) and calling fn() — this becomes undefined in strict mode.
  • Forgetting super() in a subclass constructor.

Keep reading

Next page: Destructuring

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 27 of 52 · Modern JavaScript · 17 min

Destructuring

Destructuring unpacks arrays and objects into names in one statement. You will see it in function parameters, imports of a kind, and React props. It is the same idea as “pull these fields out.”

1Arrays

const [first, second] = row unpacks by position. Skip with commas. Rest at the end. Swap two lets without a temp: [a, b] = [b, a]. If the value is not iterable, it throws — guard API rows with Array.isArray first.

JavaScript
const colors = ["red", "green", "blue"];
const [primary, ...others] = colors;
let a = 1;
let b = 2;
[a, b] = [b, a];

2Objects

const { email, id } = user unpacks by name. Rename with email: userEmail. Defaults with =. Nested destructuring throws if the middle is null; use optional chaining when the contract is loose. Nested objects are still references, not clones.

JavaScript
const user = { name: "Riya", age: 24 };
const { name, age: years } = user;

3Parameters

function connect({ host, port = 443 }) is destructuring at the door. Playwright’s async ({ page }) is this. Missing argument undefined throws when you destructure; default the whole parameter: function connect({ host } = {}) if you must.

4Read a spec file with this lens

test("x", async ({ page, request }) => {}) unpacks fixtures. const { status } = await response.json() unpacks a body — and throws if json is not an object. Defaults: const { items = [] } = body. Nested: const { user: { email } } = body — fragile if user is missing; use user?.email instead when the contract is loose.

Swap: [a, b] = [b, a]. Skip: const [, second] = row. Rest: const { password, ...safe } = user before you log. Destructuring does not deep-clone. Nested objects are still references.

5Defaults, rest, nested

Missing keys take the default. ...rest collects leftover keys or leftover array items. Nest to reach city in one line — and default the parent so missing address does not throw.

JavaScript
const user = { name: "Ada", age: 36 };
const { name, city = "Pune", ...rest } = user;
const { address: { city: c } = {} } = user; // c is undefined, no throw

6Fixture argument lists

test("x", async ({ page, request }) => { ... }) is destructuring of a fixtures object Playwright passes in. Adding a custom fixture is adding a key to that object. The same syntax as const { page } = fixtures.

Read the story, then the code, then the result. You do not need to type yet.

1Unpack a pair

First item becomes x, second becomes y. Swap uses the same idea.

JavaScript
const point = [3, 4];
const [x, y] = point;
console.log(x, y);
let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a, b);

Result · prints: 3 4 2 1

2Unpack a person

The names on the left must match the keys, unless you rename with a colon.

JavaScript
const user = { name: "Riya", age: 24 };
const { name, age: years } = user;
console.log(name, years);

Result · prints: Riya 24

3Rename while unpacking

email: userEmail avoids clashing with a local email.

JavaScript
const user = { email: "a@b.c" };
const { email: userEmail } = user;
console.log(userEmail);

Result · prints: a@b.c

Watch out for

  • Destructuring undefined (const { a } = nothing) throws. Default the whole value: const { a } = nothing ?? {}.
  • Confusing [a, b] = obj (arrays) with { a, b } = obj (objects).

Keep reading

Next page: Spread, JSON, optional chaining

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 28 of 52 · Modern JavaScript · 16 min

Spread, JSON, optional chaining

Spread, rest, optional chaining, and nullish coalescing are the spellings you will see in every 2020s codebase. They are not decorations. Spread copies (shallowly). Rest gathers leftovers. ?. reads nested fields without throwing. ?? defaults only on null or undefined.

JSON has its own chapter later because testers live in payloads. This page is the language sugar around objects and arrays so you can read a spec file without translating every line into a for loop in your head.

1Spread and rest

Spread in arrays copies items into a new array: [...base, 4]. Spread in objects copies enumerable own keys: { ...defaults, ...overrides } — later keys win. Both are shallow. Nested objects are still shared. That is the same shallow-copy story as slice.

Rest in parameters gathers leftovers: function log(level, ...msg). Rest in destructuring: const [head, ...tail] = rows. Rest must be last. Interviewers like “clone and override” in one line; testers like { ...user, role: "admin" } for a one-off fixture.

2JSON

JSON is a string format. JSON.stringify walks a value and builds text. JSON.parse builds plain objects and arrays. Functions disappear. undefined keys disappear. Dates become ISO strings and come back as strings. There is a full JSON chapter later; here you only need: stringify before you send, parse before you assert, try/catch because HTML error pages are not JSON.

JavaScript
const payload = JSON.stringify({ ok: true });
JSON.parse(payload).ok; // true

3Optional chaining and ??

?. stops a read when the left is null or undefined. ?? fills in a default only for those two. Together they read nested config without a pile of ifs.

JavaScript
const cfg = {};
cfg.theme?.dark ?? "light"; // "light"
cfg.volume ?? 0;            // 0 (0 is kept)

4Destructure the JSON once

const { token, user: { email } } = await res.json() is easier to assert than a long path. Combine with defaults: const { items = [] } = body so a missing list does not throw when you .map.

JavaScript
const body = { user: { email: "a@b.c" } };
const { user: { email } } = body;
console.log(email);

Result · prints: a@b.c

Read the story, then the code, then the result. You do not need to type yet.

1Pour two lists into one

The new array does not share identity with either input.

JavaScript
const pantry = ["tea", "milk"];
const extra = ["sugar"];
const all = [...pantry, ...extra];
console.log(all);

Result · prints: ["tea", "milk", "sugar"]

2Safe nested read

address is missing, so ?. stops. You get undefined, not a crash.

JavaScript
const user = { name: "Ada" };
console.log(user.address?.city);
console.log(user.address?.city ?? "unknown");

Result · prints: undefined unknown

3Round trip through JSON

stringify to send. parse to use again.

JavaScript
const text = JSON.stringify({ ok: true });
console.log(text);
console.log(JSON.parse(text).ok);

Result · prints: {"ok":true} true

4Default empty list

Missing items becomes [] so map is safe.

JavaScript
const body = {};
const { items = [] } = body;
console.log(items.map((x) => x));

Result · prints: []

Watch out for

  • Spreading a nested object and thinking the inner object was cloned (it is still shared).
  • Parsing JSON without try/catch when the string might be invalid.

Keep reading

Next page: JSON: the language of APIs

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 29 of 52 · Modern JavaScript · 23 min

JSON: the language of APIs

JSON is a text format, not a JavaScript value. APIs send strings that look like objects. Your job as a tester is to parse them, assert fields, and send bodies back. Almost every Playwright APIRequestContext call and every fetch in this tutorial is JSON in disguise.

JavaScript objects can hold functions, undefined, Dates, Maps, and circular links. JSON cannot. Knowing the gap stops false bugs in test reports.

1What JSON allows

Objects, arrays, strings, numbers, true, false, and null. Keys must be double-quoted strings. No comments, no trailing commas, no undefined, no functions, no NaN or Infinity (they become null if you stringify carelessly). Dates become ISO strings if you stringify a Date, and come back as strings — not Date objects — after parse.

JavaScript
const text = '{"id":7,"ok":true,"tags":["smoke"],"user":null}';
const data = JSON.parse(text);
console.log(data.id, data.tags[0], data.user);

Result · prints: 7 smoke null

2stringify and parse

JSON.stringify(value) walks the value and builds text. JSON.parse(text) builds a plain object/array. Pretty print with JSON.stringify(value, null, 2) — the 2 is spaces of indent. A replacer function (second argument) can drop keys. A reviver on parse can turn date strings back into Date if you choose.

stringify skips undefined object values (the key disappears). Arrays keep a hole as null. Throws on circular objects.

JavaScript
const user = { name: "Ada", password: undefined, created: new Date("2024-01-15T00:00:00Z") };
const text = JSON.stringify(user);
console.log(text);
const back = JSON.parse(text);
console.log(typeof back.created);

Result · prints a string without password; created comes back as string, not Date

3Asserting an API body like a tester

After parse, use === for primitives, Array.isArray for lists, and optional chaining for nested fields that might be absent in error payloads. Check response.ok / status first — a 500 often has a different shape than 200.

Do not compare whole objects with === (that is identity). Compare the fields you care about, or JSON.stringify both sides only if key order is stable and you accept that Dates are strings.

JavaScript
function assertUser(body) {
  if (typeof body?.id !== "number") throw new Error("id");
  if (typeof body?.email !== "string") throw new Error("email");
  if (!Array.isArray(body.roles)) throw new Error("roles");
}
assertUser({ id: 1, email: "a@b.c", roles: ["qa"] });

4Building a request body

The network needs a string. JSON.stringify({ title: "Bug" }) then set Content-Type: application/json. If you pass a JS object to fetch’s body without stringify, you will not send JSON. Playwright’s request.post({ data: obj }) stringify for you — know which layer does it so you do not double-encode.

JavaScript
const body = JSON.stringify({ title: "Login broken", severity: "high" });
// fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body })

5Safe parse

JSON.parse throws SyntaxError on HTML error pages, empty strings, and truncated intercepts. In tests, wrap parse and attach the raw text to the failure so the report shows what the server actually sent.

JavaScript
function parseBody(text) {
  try {
    return JSON.parse(text);
  } catch {
    throw new Error("Not JSON: " + String(text).slice(0, 120));
  }
}

6Contract thinking

Write down the 200 shape and the 4xx shape. They are often different. Assert status first, then fields. typeof body.id === "number" (or string, if that is the contract). Array.isArray(body.items). Optional chaining for error payloads. Do not === two objects. Do not expect JSON.parse to revive Date or Map.

Pretty-print failures: JSON.stringify(body, null, 2) in the thrown Error. Safe parse wraps SyntaxError with a slice of the raw text so a 502 HTML page is obvious. Double-encoded JSON is a string that looks like an object — parse twice only if you have evidence, not as a habit.

Read the story, then the code, then the result. You do not need to type yet.

1Parse a list payload

After parse, it is a normal array.

JavaScript
const text = '{"items":[1,2]}';
const data = JSON.parse(text);
console.log(data.items.length, data.items[1]);

Result · prints: 2 2

2undefined keys vanish

stringify drops undefined fields. null stays.

JavaScript
console.log(JSON.stringify({ a: 1, b: undefined, c: null }));

Result · prints: {"a":1,"c":null}

3Pretty print

The 2 is indent spaces.

JavaScript
console.log(JSON.stringify({ ok: true }, null, 2));

Result · prints a two-line object with ok

Watch out for

  • Treating JSON.parse as always successful — login pages and 502 HTML will throw.
  • Expecting JSON.parse to revive Date, Map, or undefined.
  • Comparing two API objects with ===.

Keep reading

Next page: Modules (import / export)

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 30 of 52 · Modern JavaScript · 17 min

Modules (import / export)

A module is a file with a border. Only what you export is visible outside. That is how Playwright specs import { test, expect } from "@playwright/test" and import { LoginPage } from "./login-page.js" without everything becoming a global soup.

Named exports (export function add) must be imported with the same name in braces. Default export (export default function) is imported without braces. Mixing them is allowed and confusing. Prefer named exports when a file has several tools. Browser ESM almost always needs the .js extension in the import path.

1Named exports

export function area(r) { ... } publishes the name area. The importer writes import { area } from "./math.js". The names must match. Rename with import { area as circleArea }. You can export at the declaration or at the bottom with export { area, PI }. Cycles (A imports B imports A) are possible and painful; keep helpers one-directional.

Playwright’s import { test, expect } from "@playwright/test" is named exports from a package. Your import { LoginPage } from "./login-page.js" is the same idea with a relative path. In the browser the path usually needs .js even if you wrote .ts that compiled to .js.

JavaScript
// math.js
export const PI = 3.14;
export function area(r) {
  return PI * r * r;
}

// app.js
import { PI, area } from "./math.js";
import { area as circleArea } from "./math.js";

2Default export

export default function area() {} allows import area from "./math.js" — the local name is yours. Default is “the main thing in this file.” Two defaults in one file are illegal. Named plus default in one file is legal and a frequent mix-up. Teams that got bitten prefer named only.

JavaScript
// math.js
export default function area(r) {
  return 3.14 * r * r;
}

// app.js
import area from "./math.js";

3Where it runs

<script type="module"> is deferred and strict and has its own scope (no accidental globals). Node needs "type": "module" in package.json or a .mjs suffix, otherwise .js is CommonJS and import is a syntax error. The next chapter is import versus require in slow motion, because Playwright configs still come in both flavors.

4What a test file imports

import { test, expect } from "@playwright/test" is named imports from a package. import { LoginPage } from "./login-page.js" is your module. Default import is import LoginPage from "./login-page.js" only if that file used export default. Match the export style.

Read the story, then the code, then the result. You do not need to type yet.

1Named export — several tools in one drawer

math.js offers two names. app.js picks the ones it needs. The names in the braces must match.

JavaScript
// math.js
export const PI = 3.14;
export function area(r) {
  return PI * r * r;
}

// app.js
import { area } from "./math.js";
console.log(area(2));

Result · prints: 12.56

2Default export — one main dish

No braces on the import. You may even rename it: import circleArea from "./math.js".

JavaScript
// math.js
export default function area(r) {
  return 3.14 * r * r;
}

// app.js
import area from "./math.js";
console.log(area(2));

Result · prints: 12.56

3Rename on the way in

as lets the importer use a name that fits this file.

JavaScript
import { area as circleArea } from "./math.js";
console.log(circleArea(1));

Result · prints: 3.14

4Named import names must match

export function add then import { add }.

JavaScript
export function add(a, b) { return a + b; }
// import { add } from "./math.js";

Result · add is the exported name

Watch out for

  • Forgetting the .js extension in browser imports.
  • Writing import area from "./math.js" when the file used export function area (named). Use import { area } instead.
  • Putting import in the middle of a function. Ordinary import must sit at the top of the file. (Dynamic import() is the exception — next page.)

Keep reading

Next page: import/export vs require

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 31 of 52 · Modern JavaScript · 22 min

import/export vs require

JavaScript grew two module systems. import / export is the language standard (ES modules, ESM). require / module.exports is CommonJS (CJS), invented by Node.js years earlier. You will meet both in real projects. They are not the same syntax with different spelling — they load files differently.

1Why two ways exist

Node.js shipped in 2009. The official JavaScript language had no import yet, so Node invented require: a function that loads a file, runs it, and gives you module.exports. npm filled up with that style.

ES modules (import / export) became part of the language in 2015. Browsers implemented that, not require. Node later added ESM too. So today: browsers speak import. Node speaks both. Old tutorials and old packages still say require. New front-end and new Node apps prefer import.

2The same idea, two spellings

Imagine math.js shares a function called add. In ESM you mark it with export and pull it with import. In CommonJS you hang it on module.exports and pull it with require. The kitchen is the same; the labels on the jars are from two different decades.

JavaScript
// --- ES modules (modern) ---
// math.js
export function add(a, b) {
  return a + b;
}
// app.js
import { add } from "./math.js";
console.log(add(2, 3));

// --- CommonJS (older Node) ---
// math.js
function add(a, b) {
  return a + b;
}
module.exports = { add };
// app.js
const { add } = require("./math.js");
console.log(add(2, 3));

3How CommonJS writes exports

module.exports is the box Node hands to whoever required the file. You can replace the whole box (module.exports = function () {}) or fill properties (exports.add = add). exports is a shortcut to the same box — until you assign module.exports = somethingElse, which replaces the box. require("./math") returns whatever that box is. You can require JSON: const data = require("./config.json").

JavaScript
// helpers.js
exports.double = (n) => n * 2;
exports.triple = (n) => n * 3;

// or one main value:
// module.exports = function greet(name) {
//   return "Hi, " + name;
// };

const { double } = require("./helpers.js");
console.log(double(4));

4How ES modules write exports

export is a keyword, not a function. It must sit at the top level of the file — not inside if. import { double } from "./helpers.js" is also top-level. There is a later exception: const mod = await import("./helpers.js") which returns a promise and can live inside a function. That is dynamic import, useful for “load this only if the user opens the tab.”

JavaScript
export function double(n) {
  return n * 2;
}

import { double } from "./helpers.js";

async function loadTools() {
  const math = await import("./math.js");
  return math.add(1, 2);
}

5The real differences

require is a function: you can call it inside if, after a condition, with a variable path. It loads synchronously (the next line waits). import is static: the engine lists every import before running your file, which lets bundlers tree-shake unused exports. ESM bindings are live — if the exporter later changes a let, the importer sees the new value. require usually gives you a snapshot of module.exports at load time.

ESM is always strict mode. CommonJS is not, unless you add "use strict". In ESM, this at the top level is undefined. In CJS, this is module.exports. ESM has no __dirname or __filename; you build a path from import.meta.url. CJS has both built in. Browsers have import only — there is no require in a web page unless a bundler fakes it.

6How Node decides which one you are using

A file named .cjs is always CommonJS. A file named .mjs is always ESM. A .js file follows package.json: if you write "type": "module", .js is ESM (and you would use .cjs for the old style). If you omit type, or set "type": "commonjs", .js is require. That is why copying an import into a default Node folder throws: Node still thinks the file is CommonJS.

JavaScript
{
  "name": "tea-shop",
  "type": "module"
}

7Can you mix them?

An ESM file can load some CommonJS libraries with import — Node wraps them. The default export of a CJS module is often the whole module.exports object: import pkg from "old-lib". Going the other way is bumpier: require() cannot load ESM in older Node. Newer Node can import() an ESM file from CJS (async). Do not write import and require as if they were interchangeable in one file. Pick one system for your own code; let dependencies be whatever they are.

If you must call require from ESM, Node offers createRequire(import.meta.url). If you must load ESM from CJS, use import().

JavaScript
// ESM file that still needs require:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const old = require("./legacy.cjs");

// CJS file that loads ESM:
async function load() {
  const { add } = await import("./math.mjs");
  return add(2, 3);
}

8What to use in 2026

New browser code: import / export only. New Node apps: "type": "module" and import / export. You will still read require in older Node scripts, Electron apps, and many npm packages. When a README says const x = require("x"), that package is CommonJS. When it says import x from "x", it is ESM (or a bundler is translating for you).

9Diagnose a config file in thirty seconds

If the file says import and Node throws, you are in CommonJS context: either rename to .mjs, set "type": "module", or rewrite as require. If the file says require and you are in ESM, use createRequire or convert. Playwright’s config might be TS compiled either way. Match the package.json "type" and the examples from the docs version you installed (npx playwright test, not a random global).

Default export vs module.exports = vs exports.foo are three spellings of “what the importer receives.” Mixing import foo from in ESM with module.exports = { foo } in CJS is the usual interop mess. Prefer one system per package.

10Playwright config is usually ESM or CJS

playwright.config.ts is compiled. A raw playwright.config.js might be CJS (module.exports = {}). If you copy import into a CJS config, Node throws. Match the file to package.json "type" or the .mjs/.cjs suffix.

Read the story, then the code, then the result. You do not need to type yet.

1Same add(), two passports

Left is how a 2026 app writes it. Right is how a 2014 Node tutorial writes it. Both print 5.

JavaScript
// ESM
export function add(a, b) {
  return a + b;
}
import { add } from "./math.js";
add(2, 3);

// CommonJS
function add(a, b) {
  return a + b;
}
module.exports = { add };
const { add } = require("./math.js");
add(2, 3);

Result · both print / return: 5

2require can wait until an if

This is legal in CommonJS because require is a function. An ordinary import cannot sit inside the if — the engine wants every import listed up front.

JavaScript
function loadPlugin(enabled) {
  if (!enabled) return null;
  return require("./plugin.js");
}

Result · plugin.js runs only when enabled is true

3ESM’s version of “load later”

import() returns a promise. Use it when the file is optional or heavy. This works in browsers and in Node ESM.

JavaScript
async function loadPlugin(enabled) {
  if (!enabled) return null;
  const plugin = await import("./plugin.js");
  return plugin;
}

Result · plugin.js is fetched only when enabled is true

4Tell Node “my .js files are import”

Without this line, Node treats app.js as require and will reject import.

JavaScript
{
  "name": "tea-shop",
  "type": "module"
}

Result · then import { add } from "./math.js" works in Node

5The exports shortcut trap

exports.add = … is fine. exports = { add } is not — you replaced a local name, not the box Node will return.

JavaScript
exports.add = (a, b) => a + b;

// wrong: does not change what require() receives
// exports = { add: (a, b) => a + b };

// right: replace the whole box
module.exports = { add: (a, b) => a + b };

Result · require("./math") gets { add: [Function] }

Watch out for

  • Pasting import into a Node file that is still CommonJS (no "type": "module") — SyntaxError.
  • Writing require in a <script type="module"> in the browser — require is not defined.
  • Assigning exports = { add } in CJS. That replaces the shortcut, not the real box. Use module.exports = { add } or exports.add = add.
  • Expecting import { add } from a CJS file that did module.exports = add (a function). You often need import add from "./math.cjs" instead.

Keep reading

Next page: Node, npm, and package.json

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 32 of 52 · Modern JavaScript · 21 min

Node, npm, and package.json

Playwright tests run in Node.js, not in the page. The page has document; Node has process, fs, and fetch (modern Node). Mixing the two is the classic “document is not defined” in a spec file — you imported a browser helper into Node.

npm is the installer. package.json is the manifest: scripts, dependencies, and whether files are ESM. A QA who cannot read package.json will stall on “how do I run the tests.”

1Node is a host, like the browser

The language is the same. Global objects differ. In Node you do not have window or document unless you are inside page.evaluate(() => ...), which serializes a function into the browser. process.env.CI is how CI tells your tests they are not on a laptop. process.argv is command-line args.

console.log still works. Paths use path.join so Windows does not break. __dirname exists in CommonJS; in ESM use import.meta.url with fileURLToPath.

JavaScript
console.log(typeof document); // "undefined" in a Node spec
console.log(process.env.NODE_ENV ?? "unset");

2package.json you will actually touch

"scripts": { "test": "playwright test" } means npm test runs that command. "type": "module" makes .js files ESM (import). Without it, .js is CommonJS (require) unless the file is .mjs. dependencies are production; devDependencies are for tests and tools — Playwright belongs in devDependencies for an app repo.

npx playwright test runs the local binary from node_modules/.bin. Always prefer the local version so CI matches your laptop.

JavaScript
{
  "name": "shop-tests",
  "type": "module",
  "scripts": { "test": "playwright test", "test:ui": "playwright test --ui" },
  "devDependencies": { "@playwright/test": "^1.50.0" }
}

3Install, lockfile, node_modules

npm install reads package.json and writes node_modules plus package-lock.json. Commit the lockfile so CI installs the same tree. Never commit node_modules. If a test imports a package you forgot to add, CI fails with Cannot find module — add it and reinstall.

npm ci is for CI: it installs exactly the lockfile and fails if package.json drifted. Use it in pipelines.

4page.evaluate vs Node

Need a DOM API? Run it in the browser: const title = await page.evaluate(() => document.title). The function you pass cannot close over Node variables unless you pass them as arguments: page.evaluate((id) => document.getElementById(id), "main"). JSON-serializable only — no functions, no DOM nodes coming back unless you return plain data.

JavaScript
const href = await page.evaluate(() => location.href);
// href is a string in Node now

5A spec file is Node

Top-level document, window, localStorage: wrong host. page.evaluate(() => document.title) is the bridge; arguments must be serializable. process.env.CI, process.env.BASE_URL are how the same spec runs on a laptop and in GitHub Actions.

npm ci in CI, npm install on a laptop. Commit the lockfile. Playwright in devDependencies. Scripts in package.json so nobody has to remember flags. If you edit node_modules, the next install wipes your “fix.”

Read the story, then the code, then the result. You do not need to type yet.

1document is missing in Node

Specs run in Node unless you evaluate inside the page.

JavaScript
console.log(typeof document);
console.log(typeof process);

Result · prints: undefined object (in Node)

2env with a fallback

CI sets variables; laptops often do not.

JavaScript
const base = process.env.API_URL ?? "http://localhost:3000";
console.log(base.startsWith("http"));

Result · prints: true

Watch out for

  • Using document or localStorage at the top of a spec file (Node).
  • Running a global playwright instead of npx / npm script — version drift.
  • Editing node_modules to “fix” a test. Reinstall wipes it; fix your code or pin a version.

Keep reading

Next page: TypeScript in one sitting (for JS readers)

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 33 of 52 · Modern JavaScript · 23 min

TypeScript in one sitting (for JS readers)

TypeScript is JavaScript plus types that are erased before the code runs. Playwright’s docs prefer it because the editor catches typos in locators and options before CI. You do not need to master TS to write JS — but a QA who can read a .ts spec will not freeze in 2026 interviews.

This page is a map, not a course. After it you should recognize :, interface, and why tsc failed your build.

1A type is a promise to the compiler

let n: number = 1; n = "no" is a compile error. At runtime there is no : number — it is still JavaScript. Types are checked by tsc or by your editor. If you lie with as any, you turn the checker off. Prefer honest types.

JavaScript
function add(a: number, b: number): number {
  return a + b;
}
add(1, 2);
// add(1, "2"); // error before the file runs

2Objects, optional fields, arrays

interface User { id: number; email: string; roles?: string[] } — ? means the key may be missing (undefined). User[] is an array of User. type Status = "open" | "done" is a union of string literals — perfect for API enums. unknown is “we must check before use”; any is “do not check.” Prefer unknown.

JavaScript
interface User {
  id: number;
  email: string;
  roles?: string[];
}
function emailOf(u: User): string {
  return u.email;
}

3What you will see in Playwright

import { test, expect } from "@playwright/test" — test is typed so test("name", async ({ page }) => { ... }) already knows page.click exists. Locator, Page, APIResponse are types. You rarely write them until you extract a helper: async function login(page: Page) { ... }.

4Gradual typing

A .js file can become .ts. // @ts-check plus JSDoc @param {number} n is a lighter step. If a library has no types, you can still import it; tsc may say any. Do not block learning JavaScript on finishing TypeScript — learn JS first, then annotate the edges (function inputs, API bodies).

5How to read a red tsc line

TypeScript is erased. The browser never sees : number. tsc is a spell-check. Error “Type string is not assignable to type number” means you passed the wrong thing, or your types are a lie (any, as). Prefer unknown and a typeof check over any.

Playwright’s page is already typed. You add types when you extract helpers: async function login(page: Page, user: { email: string; password: string }). Optional roles?: string[] means the key may be missing. User | null means it is present and maybe null. Those two are different bugs.

Read the story, then the code, then the result. You do not need to type yet.

1A typed add (as it compiles)

After tsc, this is ordinary JS.

JavaScript
function add(a, b) {
  return a + b;
}
console.log(add(2, 3));

Result · prints: 5

2Optional means maybe missing

roles? can be undefined. Guard before .map.

JavaScript
function countRoles(user) {
  return user.roles?.length ?? 0;
}
console.log(countRoles({ email: "a@b.c" }));
console.log(countRoles({ email: "a@b.c", roles: ["qa"] }));

Result · prints: 0 1

Watch out for

  • Thinking TypeScript runs in the browser. It compiles to JS first.
  • Using any everywhere “so it compiles” — that deletes the value of TS.
  • Confusing optional user?: User (the argument may be missing) with user: User | null (it is present but maybe null).

Keep reading

Next page: Iterators & generators

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 34 of 52 · Modern JavaScript · 17 min

Iterators & generators

for...of works on anything iterable: arrays, strings, maps, sets, and custom objects that implement [Symbol.iterator]. A generator (function*) is the easiest way to write a custom iterable that can pause.

1The iterator protocol

An iterable has [Symbol.iterator]() that returns an iterator. An iterator has next() returning { value, done }. You rarely call next yourself — for...of, spread, and Array.from do.

JavaScript
for (const ch of "hi") {
  // "h" then "i"
}
const unique = [...new Set([1, 1, 2])];

2Generators

function* range(n) { for (let i = 0; i < n; i++) yield i; } pauses at each yield and resumes on the next next() / loop step. Generators can yield* another iterable. They are useful for lazy sequences and for walking trees without building a giant array.

JavaScript
function* range(n) {
  for (let i = 0; i < n; i++) yield i;
}
[...range(3)]; // [0, 1, 2]

3What for...of is actually doing

An iterable has Symbol.iterator. Arrays, strings, maps, sets do. Plain objects do not unless you add one. for...of asks for the iterator and keeps calling next() until done. spread [...iterable] does the same into an array.

function* is a generator: yield pauses and returns a value. Rare in tests. You will see them in some Redux-old and in custom iterables. Know next/value/done so the protocol is not magic. You do not need to write generators to be interview-ready; you need to read for...of.

4What for...of actually does

It calls [Symbol.iterator](), then next() until done is true. Spread and Array.from do the same. A generator’s yield is a friendly next(). You can break a for...of early; that calls return() on the iterator if it exists.

JavaScript
const it = "ab"[Symbol.iterator]();
it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }

Read the story, then the code, then the result. You do not need to type yet.

1Walk a string

Strings are iterable. Each step is one character.

JavaScript
let out = "";
for (const ch of "hi") {
  out += ch + "-";
}
console.log(out);

Result · prints: h-i-

2A lazy counter

range(3) does not build [0,1,2] up front. The loop asks for the next yield each time.

JavaScript
function* range(n) {
  for (let i = 0; i < n; i++) yield i;
}
console.log([...range(3)]);

Result · prints: [0, 1, 2]

Watch out for

  • for...in on an array (keys as strings, plus inherited). Use for...of for values.
  • Forgetting that a generator function returns a generator object — you must iterate it, not expect the array immediately.

Keep reading

Next page: Regular expressions

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 35 of 52 · Modern JavaScript · 16 min

Regular expressions

A regular expression is a pattern, not a string. /\d+/ means “one or more digits,” not the characters backslash-d. Use regex when the shape varies (an order id, a date, a CSS-ish token). Use includes, startsWith, and endsWith when the text is fixed — they are easier to read in a failure.

Flags change the engine: i ignore case, g every match, m ^ and $ per line, s dot matches newline. replace without g (or without replaceAll) only hits the first match. Building a regex from user input without escaping can freeze a process; prefer literal patterns you wrote.

1test, match, replace

/\d/.test("ab3") is true. test returns a boolean — good in if. match returns an array or null — good when you need the text. exec is the older stepper. replace with a string replacement and /g hits every match. Parentheses capture: /id:(\d+)/, then match[1] is the digits. Named groups (?<id>\d+) help readers.

Dots match any character except newline (unless the s flag). Escape a real dot as \. Unescaped dots in an email regex are a common false green. For a fixed substring, includes is clearer and faster to read in a failure.

JavaScript
const hasDigit = /\d/.test("ab3");
"A12".replace(/\d/g, "#"); // "A##"

2Flags and when not to use regex

i makes the pattern case-insensitive. g finds all matches (and makes lastIndex state on the regex object — reuse of a /g regex in a loop can skip matches; prefer a fresh regex or matchAll). m makes ^ and $ line-based.

If the question is “does this locator text contain Save,” use a Playwright substring or exact matcher, not /Save/. Regex in locators is for genuine variation. Catastrophic backtracking (nested quantifiers on untrusted text) can hang a test process. Keep patterns simple.

3A pocket grammar

\d digit, \w word character, \s space, . any character except newline. + one or more, * zero or more, ? optional. ^ start, $ end. [aeiou] a class. [^0-9] not a digit. Use a raw string of examples before you write the pattern.

JavaScript
/^\d{4}$/.test("2024"); // true  (exactly four digits)
/tea/i.test("TEA");     // true  (i = ignore case)
"id:42".match(/id:(\d+)/)[1]; // "42"

4getByRole name as /regex/i

Playwright accepts a regex for accessible name: getByRole("button", { name: /save/i }). In JS that is a real RegExp. ^ and $ if you want the whole string. Do not over-regex a stable label — a string is clearer.

JavaScript
const name = "Save changes";
/save/i.test(name); // true
/^save$/i.test(name); // false

Read the story, then the code, then the result. You do not need to type yet.

1Is there a digit?

\d means a digit. test answers yes/no.

JavaScript
console.log(/\d/.test("ab3"));
console.log(/\d/.test("abc"));

Result · prints: true false

2Hide every digit

The g flag means “all of them, not just the first.”

JavaScript
console.log("A12B9".replace(/\d/g, "#"));

Result · prints: A##B#

3Capture a year

Parentheses remember a piece. [1] is the first group.

JavaScript
const m = "year=2024".match(/year=(\d+)/);
console.log(m[1]);

Result · prints: 2024

4Case-insensitive button

i flag. Full-string would use ^ $.

JavaScript
console.log(/save/i.test("Save changes"));

Result · prints: true

Watch out for

  • Forgetting the g flag and only replacing once.
  • Building regex from user input without escaping — that can explode into catastrophic patterns.

Keep reading

Next page: Dates

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 36 of 52 · Modern JavaScript · 16 min

Dates

Date is an instant: milliseconds since 1 Jan 1970 UTC. Displaying it in the user’s timezone is a separate step. Tests that create new Date() and assert on the local calendar will fail in CI in another timezone. Prefer UTC getters, ISO strings ending in Z, or a fixed clock.

getUTCMonth is 0–11. That is not a trick question; it is how you get an off-by-one in a report. toISOString().slice(0, 10) is a stable YYYY-MM-DD. For “add three days,” copy the date first, then setUTCDate. Mutating a date you still need is the same class of bug as mutating a fixture object.

1Read parts

new Date(Date.UTC(2024, 0, 15)) is 15 Jan 2024 UTC — month 0 is January. getUTCFullYear, getUTCMonth, getUTCDate, getUTCHours are the stable getters for tests. Local getters (getHours) follow the machine timezone. CI is often UTC; your laptop may not be.

toISOString() is always UTC and always ends in Z. slice(0, 10) is the calendar date in UTC, which may be “yesterday” locally late at night. Be explicit in the assertion message about which calendar you mean.

JavaScript
const d = new Date(Date.UTC(2024, 0, 15));
d.getUTCDate(); // 15

2Compare and shift

getTime() (or unary +date) is milliseconds, comparable with > and -. Subtract two instants, divide by 86400000 for whole days (careful with DST if you used local dates). To add days, copy: const next = new Date(d.getTime()); next.setUTCDate(next.getUTCDate() + n).

Do not parse "15/01/2024" with Date.parse — it is implementation-defined. Prefer ISO 8601 with a timezone. Fake clocks in unit tests exist so a “now + 30s” debounce does not take 30s; Playwright has clock APIs when you must. Prefer waiting on UI state when you can.

3Timestamps and invalid dates

Date.now() and date.getTime() are milliseconds. Subtract to get a duration. Invalid strings become an Invalid Date — Number(d) is NaN, so check Number.isNaN(date.getTime()) before using it. Prefer Date.UTC(y, monthIndex, day) over Date.parse of a short string.

JavaScript
const a = new Date(Date.UTC(2024, 0, 1));
const b = new Date(Date.UTC(2024, 0, 2));
(b - a) / 86400000; // 1 day
Number.isNaN(new Date("nope").getTime()); // true

4ISO strings from APIs

APIs often send "2024-01-15T10:00:00.000Z". new Date(iso) then getTime() to compare. Do not parse "15/01/2024" with Date.parse — it is implementation-defined. Prefer a library or split the parts. For “date only” in tests, compare slice(0, 10) in UTC.

JavaScript
const iso = "2024-01-15T10:00:00.000Z";
const d = new Date(iso);
console.log(d.toISOString().slice(0, 10));

Result · prints: 2024-01-15

Read the story, then the code, then the result. You do not need to type yet.

1A fixed calendar day

Month 0 is January. getUTCDate is the day of the month.

JavaScript
const d = new Date(Date.UTC(2024, 0, 15));
console.log(d.getUTCFullYear());
console.log(d.getUTCMonth() + 1);
console.log(d.getUTCDate());

Result · prints: 2024 1 15

2Stamp for a form

ISO strings start with YYYY-MM-DD. slice keeps that date part.

JavaScript
const d = new Date(Date.UTC(2024, 0, 15));
console.log(d.toISOString().slice(0, 10));

Result · prints: 2024-01-15

3One day later (UTC)

Copy, then setUTCDate. Mutating the original would surprise the caller.

JavaScript
const a = new Date(Date.UTC(2024, 0, 15));
const b = new Date(a.getTime());
b.setUTCDate(b.getUTCDate() + 1);
console.log(b.toISOString().slice(0, 10));

Result · prints: 2024-01-16

4ISO date prefix

Compare the calendar day in UTC.

JavaScript
console.log("2024-01-15T10:00:00.000Z".slice(0, 10));

Result · prints: 2024-01-15

Watch out for

  • new Date("2024-01-15") parsing as UTC or local depending on the engine — prefer Date.UTC or a full ISO string with Z.
  • Mutating a Date you still need; always copy first.

Keep reading

Next page: Sets & Maps

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 37 of 52 · Modern JavaScript · 17 min

Sets & Maps

Set is a bag of unique values. Map is a dictionary whose keys can be any type, not only strings. Objects-as-maps collide with prototype keys and stringify everything to string keys. When you mean “have I seen this id?” use Set. When you mean “id → row,” use Map.

Neither survives JSON.stringify as a Set or Map — you get {}. Convert with [...set] or Object.fromEntries(map) before you log or send. WeakMap exists for garbage-collected object keys; you almost never need it in tests.

1Set

new Set(array) drops duplicates with === uniqueness (NaN is treated as the same NaN). .add, .has, .delete, .size. Iteration order is insertion order. [...set] is an array again. Unique emails: new Set(rows.map((r) => r.email)).size === rows.length.

Union: new Set([...a, ...b]). Intersection: [...a].filter((x) => b.has(x)). Difference: [...a].filter((x) => !b.has(x)). These beat nested loops in interview settings and in “dedupe this fixture” helpers.

2Map

map.set(key, value), .get, .has, .delete, .size. Keys can be objects (identity), numbers, strings. Getting a missing key returns undefined — same as objects, so you cannot store undefined and tell it from missing unless you .has first.

Use Map for frequency counts (word → n), for “id → node,” and when keys are not valid identifiers. Object.create(null) is the old “dict without prototype”; Map is clearer. Convert to JSON with Object.fromEntries(map).

JavaScript
const ages = new Map();
ages.set("Ada", 36);
ages.get("Ada"); // 36

3Set recipes

Union: new Set([...a, ...b]). Intersection: [...a].filter((x) => b.has(x)). Difference: [...a].filter((x) => !b.has(x)). These beat nested loops for “have I seen this?”

JavaScript
const a = new Set([1, 2, 3]);
const b = new Set([3, 4]);
new Set([...a, ...b]); // {1,2,3,4}
[...a].filter((x) => b.has(x)); // [3]

4Unique emails in a table

new Set(rows.map((r) => r.email)).size === rows.length means no duplicate emails. Map is the right bag for “email → last login.” JSON.stringify(set) is {} — convert with [...set] first in logs.

JavaScript
const emails = ["a@b.c", "a@b.c", "d@e.f"];
const unique = [...new Set(emails)];
console.log(unique.length);

Result · prints: 2

5WeakMap footnote

WeakMap keys are objects and do not prevent garbage collection. You will rarely need it in tests. Map is the default dictionary.

Read the story, then the code, then the result. You do not need to type yet.

1Unique guests

The second "Ada" disappears. size is 2.

JavaScript
const guests = new Set(["Ada", "Bea", "Ada"]);
console.log(guests.size);
console.log(guests.has("Ada"));
console.log([...guests]);

Result · prints: 2 true ["Ada", "Bea"]

2A name-to-age book

set writes. get reads. keys can be strings here — they can also be objects.

JavaScript
const ages = new Map();
ages.set("Ada", 36);
ages.set("Sam", 41);
console.log(ages.get("Ada"));

Result · prints: 36

3Duplicate emails

Set size vs array length.

JavaScript
const e = ["a", "a", "b"];
console.log(new Set(e).size === e.length);

Result · prints: false

Watch out for

  • JSON.stringify on a Set or Map — you get {} unless you convert first.
  • Using an object as a Set by setting flags to true; Set is clearer.

Keep reading

Next page: Callbacks

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 38 of 52 · Asynchrony · 24 min

Callbacks

A callback is a function you hand to someone else so they can call it when they are ready. That “someone” might run it immediately (array map) or later (a timer, a click, a file finishing). Once you see that pattern, half of JavaScript’s “magic” is just: pass a function, get called back.

Promises (next page) were invented because stacking callbacks inside callbacks becomes a pyramid that is hard to read and hard to handle errors in. Learn callbacks first — you still meet them every day.

1You already write them

A callback is a function you pass so someone else can call it later. test("name", fn), addEventListener("click", fn), setTimeout(fn, 0), arr.map(fn). The word is not special syntax. It is a role. Name the function when the body is more than a few lines so a stack trace shows a name.

JavaScript
function doTwice(fn, value) {
  fn(value);
  fn(value);
}
function shout(word) {
  console.log(word + "!");
}
doTwice(shout, "tea");

2Synchronous: now, before we continue

map’s callback runs now, for each item, before map returns. That is still a callback. People say “async callbacks” to mean the later kind. Both exist. The bug is assuming a callback ran when you only scheduled it.

JavaScript
console.log("start");
const doubled = [1, 2, 3].map((n) => {
  console.log("doubling", n);
  return n * 2;
});
console.log("done", doubled);

3Asynchronous: later, when the engine is free

setTimeout, fetch’s then, click handlers, Playwright’s test function after awaits — these run later. The current function continues. If you needed the result on the next line, you needed a promise and await, not a fire-and-forget callback.

JavaScript
console.log("1 kettle on");
setTimeout(() => {
  console.log("3 tea is ready");
}, 0);
console.log("2 waiting");

4Two styles of success or fail

DOM events do not pass err as the first argument. Old Node APIs do: (err, data) => {}. Promises replaced that with reject/fulfill. If you wrap a callback API, convert err to reject and data to resolve. Do not mix both styles in one helper.

JavaScript
function loadName(onOk, onFail) {
  const ok = true;
  if (ok) onOk("Ada");
  else onFail("offline");
}
loadName(
  (name) => console.log("hi", name),
  (err) => console.log("failed", err),
);

function readFake(path, callback) {
  if (path !== "notes.txt") callback(new Error("missing"));
  else callback(null, "buy milk");
}
readFake("notes.txt", (error, data) => {
  if (error) {
    console.log(error.message);
    return;
  }
  console.log(data);
});

5Events are callbacks too

The browser calls your listener with an event object. Playwright’s page.once("dialog", handler) is the same idea. Register before the action that opens the dialog or you miss it. That “register first” rule is a callback timing rule, not a Playwright quirk.

JavaScript
function onClick() {
  console.log("clicked");
}
button.addEventListener("click", onClick);
// later: button.removeEventListener("click", onClick);

6Callback hell — the pyramid

Nested callbacks indent until the page is unreadable and errors disappear into inner functions. Flatten with named functions or, better, promises and await. If you still see a pyramid in a spec, it is technical debt.

JavaScript
loadUser(id, (error, user) => {
  if (error) {
    console.log(error);
    return;
  }
  loadPosts(user.id, (error, posts) => {
    if (error) {
      console.log(error);
      return;
    }
    loadComments(posts[0].id, (error, comments) => {
      if (error) {
        console.log(error);
        return;
      }
      console.log(comments[0]);
    });
  });
});

7Passing a method as a callback

The this chapter: obj.method as a value is unbound. Wrap or bind. This heading exists because testers copy a page-object method into addEventListener and lose the page.

JavaScript
const kettle = {
  cups: 0,
  pour() {
    this.cups += 1;
    console.log(this.cups);
  },
};
setTimeout(() => kettle.pour(), 0);
// setTimeout(kettle.pour, 0) would lose this

8The extra parentheses

setTimeout(fn, 100) schedules fn. setTimeout(fn(), 100) runs fn now and schedules its return value (often undefined). The same bug: addEventListener("click", handle) vs handle(). Playwright: test("name", run) not run(). You already live in callbacks; name them when the arrow grows past a few lines.

Error-first Node callbacks (err, data) => {} still exist in old fs APIs. Promises wrap them. New code awaits fs.promises. The pyramid of doom (callback inside callback) is why promises were added. If you see it in a test, flatten with async/await.

9Playwright uses callbacks too

page.once("dialog", (dialog) => dialog.accept()) is a callback. route handlers are callbacks. The test function itself is an async callback given to test(). You already live in this world — name the function if the arrow grows past three lines.

Read the story, then the code, then the result. You do not need to type yet.

1Hand a function to a helper

doTwice does not know about tea. It only knows: call fn twice. shout is the callback.

JavaScript
function doTwice(fn, value) {
  fn(value);
  fn(value);
}
function shout(word) {
  console.log(word + "!");
}
doTwice(shout, "tea");

Result · prints: tea! tea!

2Now — before the next line

map calls the callback three times immediately. “done” cannot print until doubling is finished.

JavaScript
console.log("start");
const doubled = [1, 2, 3].map((n) => n * 2);
console.log("done", doubled);

Result · prints: start done [2, 4, 6]

3Later — after “waiting”

0 ms still means “next turn,” not “this line.” So 1 and 2 print first, then 3.

JavaScript
console.log("1 kettle on");
setTimeout(() => {
  console.log("3 tea is ready");
}, 0);
console.log("2 waiting");

Result · prints: 1 kettle on 2 waiting 3 tea is ready

4The () bug

ready() runs now. setTimeout receives undefined, not the function. Use setTimeout(ready, 100) with no ().

JavaScript
function ready() {
  console.log("tea");
}
setTimeout(ready(), 100);

Result · prints tea immediately; timer does nothing useful

5Error-first (Node style)

First argument is the error or null. Always check it before using data.

JavaScript
function readFake(path, callback) {
  if (path !== "notes.txt") callback(new Error("missing"));
  else callback(null, "buy milk");
}
readFake("notes.txt", (error, data) => {
  if (error) {
    console.log(error.message);
    return;
  }
  console.log(data);
});

Result · prints: buy milk

6The pyramid to avoid

Each step waits inside the previous callback. One miss on error and you never hear about it. Promises flatten this — next page.

JavaScript
loadUser(id, (error, user) => {
  if (error) return;
  loadPosts(user.id, (error, posts) => {
    if (error) return;
    console.log(posts[0]);
  });
});

Result · works, but every extra step adds a nest and another error check

7Keep this on a method

An arrow closes over kettle. Passing kettle.pour raw would make this undefined.

JavaScript
const kettle = {
  cups: 0,
  pour() {
    this.cups += 1;
    return this.cups;
  },
};
setTimeout(() => console.log(kettle.pour()), 0);

Result · prints: 1

8No extra ()

Pass the function, do not call it.

JavaScript
function ping() { return "pong"; }
console.log(typeof ping);
console.log(typeof ping());

Result · prints: function string

Watch out for

  • Writing setTimeout(fn(), 100) — the () calls it now. Pass the function: setTimeout(fn, 100).
  • Forgetting to handle the error argument in an error-first callback.
  • Nesting four levels of callbacks instead of flattening with named functions or promises.
  • Passing a method without bind/arrow so this becomes undefined.

Keep reading

Next page: Timers: setTimeout, setInterval, delay in tests

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 39 of 52 · Asynchrony · 21 min

Timers: setTimeout, setInterval, delay in tests

Timers are how JavaScript says “later.” QA automation lives and dies here: a test that uses a fixed sleep(5000) is slow and still flakes; a test that forgets await on a wait is fast and red. This page is the language underneath Playwright’s waitFor and Node’s setTimeout.

setTimeout does not pause the whole program. It schedules a callback. The rest of your function keeps running. That is why you need promises or await if the *next* line must happen after the delay.

1setTimeout is a minimum, not a guarantee

setTimeout(fn, 100) means “run fn after at least 100 ms, when the engine is idle.” If the call stack is busy, it waits longer. Delay 0 still runs after the current stack and after microtasks (see the event loop page). It returns a numeric id (or an object in Node). clearTimeout(id) cancels if it has not run yet.

JavaScript
const id = setTimeout(() => console.log("later"), 100);
clearTimeout(id);
console.log("never later");

Result · prints: never later

2Wrapping a delay as a promise

Tests and async functions want await delay(ms), not nested callbacks. This helper is the standard pattern. Do not use it as a substitute for waiting for a condition (an element, a network response) — wait for the thing itself.

JavaScript
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function demo() {
  console.log("a");
  await delay(0);
  console.log("b");
}

3setInterval and why tests hate it

setInterval(fn, ms) repeats until clearInterval. If fn takes longer than ms, browsers may skip or stack calls depending on the spec version — messy. In pages, polling the DOM with setInterval is how flaky tests are born. Prefer MutationObserver, Playwright locators that wait, or a loop with await delay and a timeout budget.

JavaScript
let n = 0;
const id = setInterval(() => {
  n += 1;
  if (n >= 3) clearInterval(id);
}, 10);

4The extra () bug and this

setTimeout(fn(), 100) runs fn immediately. setTimeout(fn, 100) schedules it. Passing obj.method loses this unless you bind or wrap in an arrow. Playwright’s page.waitForTimeout(ms) is a delay; prefer expect(locator).toBeVisible() which waits for a condition with a timeout.

JavaScript
const box = {
  n: 0,
  tick() { this.n += 1; },
};
setTimeout(() => box.tick(), 0);

5Fake timers in unit tests

Jest and Vitest can mock timers so a 30s debounce is not a 30s test. That is a test-runner feature, not a JavaScript feature — but you should know the language still uses real timers in the browser. In Playwright, use clock APIs when you must; otherwise wait for UI state.

6What to do instead of sleep

delay(ms) as a promise is fine for a demo. In a test, wait for the thing: a response, a locator, a URL. Fake timers in Jest/Vitest make a 30s debounce instant. Playwright clock APIs exist; default to web-first expects. clearTimeout and clearInterval in teardown so Node processes exit.

setTimeout 0 is not “now.” It is “after this stack and after microtasks.” The event loop chapter’s A/D/B/C example is the proof. The extra () bug is still the extra () bug.

Read the story, then the code, then the result. You do not need to type yet.

1Cancel before it fires

clearTimeout stops the note.

JavaScript
const id = setTimeout(() => console.log("boom"), 50);
clearTimeout(id);
console.log("safe");

Result · prints: safe

2Delay as a promise

await delay(0) yields, then continues.

JavaScript
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function run() {
  console.log("a");
  await delay(0);
  console.log("b");
}
run();

Result · prints: a b

Watch out for

  • sleep(5000) in every test “to be safe” — slow CI and still racy.
  • setTimeout(fetchData(), 0) — fetchData runs now.
  • Forgetting clearInterval on teardown so Node processes hang.

Keep reading

Next page: Promises

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 40 of 52 · Asynchrony · 26 min

Promises

A Promise is an object that stands in for a value that is not ready yet. Think of a cloakroom ticket: you hold the ticket now; the coat (the value) arrives later. The ticket is pending, then either fulfilled (here is your coat) or rejected (we lost it). You attach .then to unwrap a success, .catch to handle a failure, and .finally to run cleanup either way.

fetch, databases, and wait() helpers all return promises. This page is the long version. async/await on the next-but-one page is the same machinery with nicer spelling.

1Three states, then done

Pending, fulfilled, rejected. Terminal states stick. You do not “reset” a promise; you make a new one. That is why a test that starts one login promise and awaits it twice still has one result.

JavaScript
const pending = new Promise(() => {});
const done = Promise.resolve("ready");
const failed = Promise.reject(new Error("nope"));
done.then(console.log);
failed.catch((err) => console.log(err.message));

2new Promise — wrap a callback

new Promise((resolve, reject) => { ... }) is for wrapping APIs that still use callbacks: fs, old SDKs, a custom event. resolve(value) fulfills. reject(error) rejects. Do not wrap fetch — it already returns a promise. Do not resolve and reject both.

JavaScript
function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
function waitOrFail(ok) {
  return new Promise((resolve, reject) => {
    if (ok) resolve("tea");
    else reject(new Error("kettle off"));
  });
}
wait(0).then(() => console.log("later"));

3then, catch, finally

then maps fulfillment. catch maps rejection. finally does cleanup. Each returns a new promise. Thrown errors inside then reject the next promise. That is how a chain stays linear instead of nested.

JavaScript
Promise.resolve(21)
  .then((n) => n * 2)
  .then((n) => {
    console.log(n);
    return n;
  })
  .catch((err) => {
    console.log("failed", err);
    return 0;
  })
  .finally(() => {
    console.log("always");
  });

4Chaining — flatten the pyramid

return the next promise inside then so the following then waits. Forgetting return is the silent undefined bug. async/await is the same chain with less punctuation. Prefer await in new tests.

JavaScript
function loadUser() {
  return Promise.resolve({ id: 1, name: "Ada" });
}
function loadPosts(userId) {
  return Promise.resolve(["post-a", "post-b"]);
}
loadUser()
  .then((user) => loadPosts(user.id))
  .then((posts) => {
    console.log(posts[0]);
    return posts;
  })
  .catch((err) => {
    console.log("could not load", err);
  });

5The forgotten return

then(() => { fetch(...) }) without return does not wait for fetch. then(() => fetch(...)) does. await fetch is the spelling that makes the wait visible. Same bug, two dialects.

JavaScript
Promise.resolve(1)
  .then((n) => {
    Promise.resolve(n + 1); // missing return
  })
  .then((n) => console.log(n));
// prints undefined

Promise.resolve(1)
  .then((n) => {
    return Promise.resolve(n + 1);
  })
  .then((n) => console.log(n));
// prints 2

6Promise.all, allSettled, race, any

all: wait for every fulfill, fail if any reject. allSettled: always wait, inspect status per item. race: first to settle (a reject can win). any: first to fulfill. Testers like allSettled for “hit these ten endpoints and report.”

JavaScript
Promise.all([Promise.resolve(2), Promise.resolve(3)]).then(console.log);
// [2, 3]

Promise.allSettled([
  Promise.resolve("ok"),
  Promise.reject("nope"),
]).then(console.log);
// [{ status: "fulfilled", value: "ok" }, { status: "rejected", reason: "nope" }]

function wait(ms, label) {
  return new Promise((resolve) => setTimeout(() => resolve(label), ms));
}
Promise.race([wait(80, "slow"), wait(10, "fast")]).then(console.log);
// "fast"

7Same job: callbacks vs promises

Both schedule later work. Promises give you a value you can await, compose, and catch in one place. Callbacks give you a pyramid unless you are very tidy. New public APIs return promises. That is the direction of the language.

JavaScript
// callbacks (last page)
loadUser(id, (error, user) => {
  if (error) return console.log(error);
  loadPosts(user.id, (error, posts) => {
    if (error) return console.log(error);
    console.log(posts[0]);
  });
});

// promises
loadUser(id)
  .then((user) => loadPosts(user.id))
  .then((posts) => console.log(posts[0]))
  .catch((error) => console.log(error));

8States and what testers assert

Pending, fulfilled, rejected. You cannot unset a fulfill. .then returns a new promise, which is how chaining flattens pyramids. Forgotten return inside then: the next then gets undefined. async/await makes that return visible again.

Promise.all fails fast. allSettled waits for every outcome — useful when you want a report of which of ten URLs failed. race is first to settle (including reject). any is first to fulfill. fetch does not reject on 404; the promise fulfills with a Response whose ok is false. Assert status yourself.

9all vs allSettled in a suite setup

Seeding three users: Promise.all fails the batch on the first 500. Promise.allSettled lets you report which ids failed and still keep the successes. In a test you usually want all (fail fast). In a cleanup job you often want allSettled.

JavaScript
const results = await Promise.allSettled([ok(), boom()]);
const failed = results.filter((r) => r.status === "rejected");

Read the story, then the code, then the result. You do not need to type yet.

1Already delivered

Promise.resolve is a ticket whose coat is already on the counter. then receives 21, doubles it, logs 42.

JavaScript
Promise.resolve(21)
  .then((n) => n * 2)
  .then((n) => console.log(n));

Result · prints: 42

2Wrap a timer

resolve() is called when the timeout fires. The rest of your program is not frozen during the wait.

JavaScript
function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
console.log("start");
wait(0).then(() => console.log("later"));
console.log("middle");

Result · prints: start middle later

3Recover from a failure

reject jumps to catch. Returning a value from catch continues the chain as a success.

JavaScript
Promise.reject("nope")
  .catch(() => "toast and tea")
  .then((meal) => console.log(meal));

Result · prints: toast and tea

4A small story in a chain

Each then hands a value to the next. Return a promise (loadPosts) and the chain waits.

JavaScript
Promise.resolve({ id: 1, name: "Ada" })
  .then((user) => user.name)
  .then((name) => "Hi, " + name)
  .then((line) => console.log(line));

Result · prints: Hi, Ada

5Forgot return

The inner Promise.resolve runs, but the chain does not wait for it. Next then gets undefined.

JavaScript
Promise.resolve(1)
  .then((n) => {
    Promise.resolve(n + 1);
  })
  .then((n) => console.log(n));

Result · prints: undefined

6Wait for two kitchens

Promise.all finishes when every promise finishes. Order in the array matches the input, not who finished first.

JavaScript
Promise.all([Promise.resolve("rice"), Promise.resolve("dal")])
  .then((parts) => console.log(parts.join(" + ")));

Result · prints: rice + dal

7all vs allSettled

all fails the whole batch if one fails. allSettled always gives you one record per input.

JavaScript
Promise.allSettled([
  Promise.resolve("ok"),
  Promise.reject("nope"),
]).then((rows) => {
  console.log(rows[0].status, rows[1].status);
});

Result · prints: fulfilled rejected

8Same job, flatter code

One catch covers every step. Compare this to the nested callbacks on the previous page.

JavaScript
loadUser(id)
  .then((user) => loadPosts(user.id))
  .then((posts) => console.log(posts[0]))
  .catch((error) => console.log(error));

Result · logs the first post, or one error from any step

9allSettled keeps going

One reject does not drop the fulfilled sibling from the report.

JavaScript
Promise.allSettled([Promise.resolve(1), Promise.reject("x")]).then((r) => {
  console.log(r.map((x) => x.status).join(","));
});

Result · prints: fulfilled,rejected

Watch out for

  • Forgetting to return the inner promise inside then, so the chain does not wait.
  • new Promise(async () => { ... }) — don’t put async in the executor. Use an async function, or call resolve/reject yourself.
  • A promise with no catch becomes an unhandled rejection.
  • Nesting .then inside .then when you could return and keep the chain flat.

Keep reading

Next page: async/await

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 41 of 52 · Asynchrony · 19 min

async/await

async/await is not a new kind of concurrency. It is nicer spelling for promises. An async function always returns a promise. await pauses that function — not the whole program — until the promise settles, then continues with the value. The rest of the app can still run: that is the event loop chapter.

Forgetting await is the number one test bug. You log Promise { <pending> }, or you assert on a promise object, or the test finishes before the click’s navigation. If the next line needs the result, await. If two independent calls can run together, start both, then Promise.all.

1Promise basics

A promise is an object for a value that may arrive later. It is pending, fulfilled, or rejected — and then it stays that way. Promise.resolve(7) is already fulfilled. .then maps a fulfilled value. .catch maps a rejection. .finally runs for cleanup either way.

await Promise.resolve(7) yields 7. await a rejected promise throws, which is why try/catch around await is the async version of try/catch around JSON.parse. You do not need new Promise for values you already have; you need it to wrap a callback API (the promises chapter).

JavaScript
async function load() {
  const n = await Promise.resolve(7);
  return n;
}

2async functions

async function load() { return 1; } returns a promise that fulfills with 1. If the body throws, the promise rejects. await only works inside async functions or at module top level. A missing async is a SyntaxError. A missing await is a logic error: the next line runs too soon.

Playwright’s test("name", async ({ page }) => { await page.goto(...) }) is this pattern. The runner awaits your function. If you forget await on an expect that returns a promise, the test can pass before the assertion finishes — a false green.

3Promise.all

Start independent work together, then wait once. const [user, posts] = await Promise.all([loadUser(id), loadPosts(id)]). Results stay in order. If either rejects, all rejects and you may not see the other error — Promise.allSettled if you need both outcomes.

Awaiting inside a for loop runs one after another. That is correct when each step needs the previous id. It is slow when you are fetching 50 independent URLs. Map to promises, then all. Cap concurrency if the server rate-limits — that is a later, real-world concern; interviews usually want all.

JavaScript
const [user, posts] = await Promise.all([
  loadUser(id),
  loadPosts(id),
]);

4One after another vs all at once

await in a for loop waits each round — use it when step 2 needs step 1. When the work is independent, map to promises and await Promise.all so they overlap. Forgetting await in the loop still fires the work but you will not wait for it.

JavaScript
async function sequential(ids) {
  const out = [];
  for (const id of ids) out.push(await load(id));
  return out;
}
async function parallel(ids) {
  return Promise.all(ids.map((id) => load(id)));
}

5try/catch and return value

An async function always returns a promise. return 5 is a promise of 5. throw inside async rejects that promise. try/catch around await is the readable version of .catch.

JavaScript
async function safeJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}

6The forgotten await in Playwright

await page.click("button") vs page.click("button"). Without await, the test continues while the click is in flight — the next assert races. This is the #1 JS-in-tests bug. eslint can warn no-floating-promises. Treat every Playwright call that returns a Promise as must-await unless the docs say fire-and-forget.

JavaScript
async function save(page) {
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByText("Saved")).toBeVisible();
}

Read the story, then the code, then the result. You do not need to type yet.

1Wait, then use the value

Without await you would get a Promise object. With await you get 7.

JavaScript
async function load() {
  const n = await Promise.resolve(7);
  return n + 1;
}
load().then(console.log);

Result · prints: 8

2Catch a failed parcel

try/catch around await is the same idea as .catch.

JavaScript
async function safe() {
  try {
    await Promise.reject("nope");
  } catch (error) {
    return "fallback";
  }
}
safe().then(console.log);

Result · prints: fallback

3Overlap independent work

Both loads start before we wait. Total time is about the slower one, not the sum.

JavaScript
async function both() {
  const a = loadUser();
  const b = loadPosts();
  return Promise.all([a, b]);
}

Result · returns [user, posts] when both finish

4Must await

Without await you log a Promise.

JavaScript
async function load() { return 7; }
async function main() {
  const a = load();
  const b = await load();
  console.log(a instanceof Promise, b);
}
main();

Result · prints: true 7

Watch out for

  • Forgetting await — you then have a Promise object, not the value.
  • Awaiting in a loop when Promise.all could run the work in parallel.

Keep reading

Next page: The event loop

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 42 of 52 · Asynchrony · 19 min

The event loop

The event loop is how a single thread stays busy without freezing. Run the call stack to empty. Then run every microtask (promise reactions). Then take one macrotask (a timer, a click, I/O). Repeat. That order is why Promise.then runs before setTimeout(…, 0).

1Three buckets

1) The call stack (now). 2) Microtasks (promise then, queueMicrotask) drained after the stack is empty. 3) Macrotasks (timers, I/O, UI events) one at a time, with microtasks between them. Rendering in browsers sits with this schedule. You do not control it with a sleep as well as you control it by awaiting the right signal.

JavaScript
console.log("a");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("then"));
console.log("b");
// a, b, then, timeout

2Why UI stays alive

JavaScript is still one thread. The page paints because you returned control between chunks of work. A tight while (true) {} freezes clicks. await fetch yields. Testers who busy-wait in a loop without a delay starve the same thread their locators need.

3Rendering

Browsers want to render between macrotasks. A test that mutates the DOM and immediately asserts layout may still be a frame early. Playwright’s visibility checks wait for a stable-ish state. That is why they beat a raw querySelector in a Node evaluate without a wait.

4The order you will be asked to predict

console.log("A"); Promise.resolve().then(() => console.log("B")); setTimeout(() => console.log("C"), 0); console.log("D"); prints A, D, B, C. Synchronous first, then microtasks (then), then timers. That is the whole party trick. UI stays responsive because long work should be chunked or awaited, not because two JS stacks run at once.

Playwright auto-wait is not the event loop, but it lives with it: it keeps checking until the loop has painted a visible button or the timeout hits. sleep(5000) ignores the loop’s real signals. Prefer expect(locator).toBeVisible().

5A longer queue story

Read this log from top to bottom as “now,” then drain every then, then take one timeout. Nested then callbacks also count as microtasks and run before that timeout.

JavaScript
console.log("s1");
setTimeout(() => console.log("macrotask"), 0);
Promise.resolve()
  .then(() => {
    console.log("micro 1");
    return Promise.resolve();
  })
  .then(() => console.log("micro 2"));
console.log("s2");
// s1, s2, micro 1, micro 2, macrotask

6Why a test logs ‘clicked’ after ‘asserted’

If you forget await, the click is scheduled and your expect runs now. The event loop will run the click later — too late. The order Promise.then before setTimeout(0) also explains odd log order in unit tests. Draw the three buckets when a flake looks “impossible.”

Read the story, then the code, then the result. You do not need to type yet.

1Why “then” beats timeout 0

a and b are the stack. then is a microtask. timeout is a macrotask. Microtasks drain first.

JavaScript
console.log("a");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("then"));
console.log("b");

Result · prints: a b then timeout

2A long loop blocks the doorbell

While this runs, clicks and paints wait. Don’t do huge work on the main stack.

JavaScript
let n = 0;
for (let i = 0; i < 3; i++) n += 1;
console.log(n);

Result · prints: 3 (tiny on purpose — a huge loop would freeze the page)

3then before timeout

Microtask wins against 0 ms timer.

JavaScript
setTimeout(() => console.log("T"), 0);
Promise.resolve().then(() => console.log("P"));

Result · prints: P T

Watch out for

  • Assuming setTimeout(fn, 0) runs before a resolved promise’s then. It runs after.
  • Blocking the stack with a huge for-loop and blaming the event loop.

Keep reading

Next page: fetch & JSON over the network

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 43 of 52 · Asynchrony · 19 min

fetch & JSON over the network

fetch(url) returns a promise for a Response. The body is not the parsed data yet — you call response.json() or response.text(), which are also promises. This is how browsers talk to APIs.

1Read JSON

const res = await fetch(url); if (!res.ok) throw ...; const data = await res.json(); Two awaits. json() consumes the body; you cannot json() then text() on the same response. Clone if you must read twice. Headers tell you whether json is legal; a content-type of text/html is your 502 page.

JavaScript
const response = await fetch("/api/user");
if (!response.ok) throw new Error("HTTP " + response.status);
const user = await response.json();

2POST and headers

method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload). Authorization belongs in headers, not in the JSON, unless the API is unusual. CORS only applies in the browser page, not always in Node tests.

JavaScript
await fetch("/api/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Ink" }),
});

3Abort

AbortController lets you cancel a fetch when a test times out or a user navigates. Playwright has its own timeouts. In app code, abort on unmount so a late response does not setState on a dead component. You will recognize signal: controller.signal in reviews.

4A complete request in your head

fetch(url, { method, headers, body }) returns a promise of Response. await it. Check res.ok or res.status. Then res.json() or res.text() — those are another await. If you json() a non-JSON body, you throw. If you skip ok-check, you parse an error HTML page and wonder why id is undefined.

POST JSON: headers Content-Type application/json, body JSON.stringify(obj). Do not stringify twice. CORS is a browser rule; Node fetch in tests may not show the same CORS failures as a page. APIRequestContext in Playwright is the tester’s fetch with cookie jars if you configure them.

5Query strings and JSON mistakes

Build URLs with URLSearchParams so spaces encode correctly. response.json() throws if the body is not JSON — wrap it. Time out a hung request with AbortSignal.timeout(ms) where it exists, or AbortController plus setTimeout.

JavaScript
const params = new URLSearchParams({ q: "green tea" });
const url = `/search?${params}`;
const res = await fetch(url);
const data = await res.json();

6Playwright request vs fetch

request.get(url) in Playwright returns an APIResponse: await res.json(), res.status(), res.ok(). Same ideas as fetch: check ok, then parse. Cookie jars can ride along with the browser context. Node 18+ has global fetch too — same API as the browser.

Read the story, then the code, then the result. You do not need to type yet.

1The two awaits

First await the envelope. Then await the parsed body.

JavaScript
async function loadUser() {
  const response = await fetch("/api/user");
  if (!response.ok) throw new Error("HTTP " + response.status);
  return response.json();
}

Result · returns a user object, or throws if the status is not OK

2Send a small package

POST with a JSON body. stringify on the way out.

JavaScript
await fetch("/api/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Ink" }),
});

Result · server receives { title: "Ink" }

3Encode a search

Spaces become + or %20. Do not glue strings with "?q=" + userText.

JavaScript
const params = new URLSearchParams({ q: "green tea" });
console.log(params.toString());

Result · prints: q=green+tea

4404 does not throw

ok is false. You must check.

JavaScript
const fake = { ok: false, status: 404 };
console.log(fake.ok, fake.status);

Result · prints: false 404

Watch out for

  • Treating fetch() as the JSON. You still need .json().
  • Ignoring HTTP errors because the promise resolved.

Keep reading

Next page: URLs, query strings, and HTTP status

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 44 of 52 · Asynchrony · 21 min

URLs, query strings, and HTTP status

Testers live in URLs: environment hosts, query parameters, redirects, and status codes. JavaScript’s URL and URLSearchParams classes beat string glue. Combined with fetch (previous page), this is enough to read and write REST calls without a library.

HTTP status is not a JavaScript type — it is a number on the Response. Your assertions should name it.

1URL parts

new URL(string) parses. .origin is scheme + host + port. .pathname is the path. .search is the query including ?. .hash is the fragment. You can pass a base: new URL("/users", "https://api.example.com") for relative paths — the same idea as a browser resolving links.

JavaScript
const u = new URL("https://api.example.com:443/v1/users?role=qa#top");
console.log(u.hostname, u.pathname, u.searchParams.get("role"));

Result · prints: api.example.com /v1/users qa

2URLSearchParams

Build or read query strings without worrying about spaces and &. .set, .get, .getAll (repeated keys), .append, .toString(). Fetch GET with a query: ${url}?${params}. Never concatenate user text into a URL with +.

JavaScript
const q = new URLSearchParams();
q.set("q", "green tea");
q.set("page", "2");
console.log(q.toString());

Result · prints: q=green+tea&page=2

3Status codes you will assert

2xx success (200 OK, 201 created, 204 no body). 3xx redirect (follow or assert Location). 4xx client (400 bad request, 401 unauthenticated, 403 forbidden, 404 missing, 409 conflict, 422 validation). 5xx server. fetch does not throw on 404 — response.ok is false. Playwright’s APIResponse has .ok() and .status() the same way.

JavaScript
async function expectOk(res) {
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
  }
}

4Headers and cookies on the wire

response.headers.get("content-type") tells you if json() is legal. Authorization: Bearer … is a header you set on the request, not a JSON field (unless the API is unusual). Cookies for the browser are a different store (next cookies page). For API tests, you often pass a header cookie or a token explicitly.

JavaScript
const res = await fetch("https://example.com", {
  headers: { Accept: "application/json", Authorization: "Bearer test-token" },
});

5Environment and assertions

new URL(path, baseURL) is how you stop concatenating slashes. URLSearchParams encodes spaces. Status: 2xx success, 4xx your request, 5xx their server. 401 vs 403 is an interview and a real bug triage. fetch + 404 ≠ throw. Attach res.status and a body snippet to the error when !ok.

Never hard-code production hosts in a spec. baseURL from config or process.env. Query params for filters belong in URLSearchParams, not in a handwritten string with &.

Read the story, then the code, then the result. You do not need to type yet.

1Read a query param

searchParams.get is safer than split.

JavaScript
const u = new URL("https://shop.test/search?q=tea&page=2");
console.log(u.pathname, u.searchParams.get("q"), u.searchParams.get("page"));

Result · prints: /search tea 2

2Build a query

Spaces become + or %20 depending on the method; both are valid.

JavaScript
const q = new URLSearchParams({ q: "green tea", sort: "new" });
console.log(q.toString());

Result · prints: q=green+tea&sort=new

3ok vs status

404 is not a throw.

JavaScript
const res = { ok: false, status: 404 };
console.log(res.ok, res.status);

Result · prints: false 404

Watch out for

  • String-splitting "?" and "&" instead of URLSearchParams — breaks on encoded values.
  • Assuming fetch rejects on HTTP 500.
  • Hard-coding environments in tests instead of a base URL from config.

Keep reading

Next page: Forms, FormData, and file uploads

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 45 of 52 · Asynchrony · 19 min

Forms, FormData, and file uploads

HTML forms send either application/x-www-form-urlencoded, multipart/form-data (files), or JSON from JavaScript. Testers who only POST JSON are surprised when a real browser submit looks different. This page is the JavaScript side of that gap.

Playwright can fill fields and setInputFiles without you building FormData by hand — but API tests and fetch helpers still need it.

1FormData is a list of fields

const fd = new FormData(); fd.append("email", "a@b.c"); fd.append("file", blob, "shot.png"). fetch(url, { method: "POST", body: fd }) lets the browser set Content-Type with the multipart boundary. Do not set that header yourself or the boundary will be wrong.

JSON APIs want stringify + application/json. Form posts want FormData or URLSearchParams. Mixing them is a 415 or a silent empty body.

JavaScript
const fd = new FormData();
fd.append("title", "Bug");
fd.append("severity", "high");
// await fetch("/tickets", { method: "POST", body: fd });

2URLSearchParams for classic forms

application/x-www-form-urlencoded is name=Ada&role=qa. new URLSearchParams({ name: "Ada", role: "qa" }).toString() builds it. fetch with headers Content-Type: application/x-www-form-urlencoded and that string as body. This is what many login forms still send.

JavaScript
const body = new URLSearchParams({ user: "ada", pass: "secret" }).toString();
console.log(body);

Result · prints: user=ada&pass=secret

3Files in the browser vs in tests

An <input type="file"> stores a FileList. JavaScript in the page cannot set it for security — the user (or Playwright) must. In Playwright: await page.getByLabel("Resume").setInputFiles("tests/fixtures/cv.pdf"). For API upload tests, read a buffer in Node and attach it with multipart in request.post({ multipart: { file: { name, mimeType, buffer } } }).

Blob and File exist in modern Node too. A screenshot is often a buffer you write to disk, not a DOM File.

4preventDefault on submit

A SPA calls event.preventDefault() on submit then fetch. If your test clicks Submit and expects a full navigation, it will hang. If the app does a full form POST, wait for navigation. Read the handler — that is a JS skill, not a Playwright trick.

JavaScript
form.addEventListener("submit", (event) => {
  event.preventDefault();
  const data = new FormData(form);
  // fetch(..., { body: data })
});

5Match the content type the server wrote the controller for

JSON APIs want stringify and application/json. Classic forms want URLSearchParams or a real navigation submit. File uploads want multipart; let FormData or Playwright set the boundary. Setting Content-Type: multipart/form-data by hand omits the boundary and the server sees garbage.

The page cannot assign input.files for security. Tests use setInputFiles. If the app preventDefault on submit and fetch, your test should not wait for a full navigation. Read the handler — that is JavaScript literacy, not a Playwright trick.

Read the story, then the code, then the result. You do not need to type yet.

1URL encoded body

Classic login forms look like this.

JavaScript
const body = new URLSearchParams({ user: "ada", remember: "1" });
console.log(body.toString());

Result · prints: user=ada&remember=1

2FormData append

Multiple appends with the same name are allowed.

JavaScript
const fd = new FormData();
fd.append("tag", "smoke");
fd.append("tag", "login");
console.log([...fd.keys()].join(","));

Result · prints: tag,tag

Watch out for

  • Setting Content-Type: multipart/form-data by hand and losing the boundary.
  • Expecting to assign input.files = ... from page script — browsers block it.
  • Treating a JSON POST and a form POST as interchangeable in contract tests.

Keep reading

Next page: Error handling

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 46 of 52 · Asynchrony · 17 min

Error handling

When the program cannot continue honestly, throw. throw new Error("expected 201, got " + status) stops the current function and looks for try/catch. If nobody catches, that turn of the event loop fails — in Node, often the process reports it; in a test runner, the test is red.

Catch to recover or to add context, not to hide. empty catch {} is how false greens happen. Async code needs try/catch around await, or .catch on the promise. Unhandled rejections are still failures even if your test function returned.

1try / catch / throw

try runs the risky work. If throw happens, control jumps to catch (error). error.message is the string you put in new Error(...). error.stack is the trace. finally runs whether you threw or not — close a file, clear a timeout. If catch rethrows, finally still runs first.

Throw Error objects, not strings, so the stack is preserved. Include actual values in the message. That is the whole of a good assertion helper. Custom error classes (class HttpError extends Error) help you catch only the failures you can handle.

JavaScript
function parseAge(raw) {
  const n = Number(raw);
  if (!Number.isFinite(n)) throw new Error("Not a number");
  return n;
}

try {
  parseAge("hi");
} catch (error) {
  console.error(error.message);
}

2Async errors

await fetch(...) can throw on network failure. HTTP 404 does not throw — you check res.ok. Rejected promises from your own helpers should throw Error with the body snippet. In Playwright, failed expects throw; do not catch them unless you are writing a custom matcher.

Promise chains need .catch or the rejection becomes unhandled. Mixing then and await is legal and messy; pick await in new code. The event loop chapter explains when the catch runs relative to the next line of the caller.

3finally and rethrow

finally runs on success and on failure — close files, stop spinners. If you catch and cannot fix the problem, throw error again (rethrow) so a caller can decide. Empty catch {} hides bugs.

JavaScript
function read() {
  try {
    return JSON.parse(text);
  } catch (error) {
    console.log("bad json");
    throw error;
  } finally {
    console.log("attempt finished");
  }
}

4Your own Error

throw new Error("Need a number") is enough at first. A custom class extends Error if callers must if (e instanceof NotFound). Always Error objects, not bare strings — you keep the stack.

JavaScript
class NotFound extends Error {
  constructor(id) {
    super(`Missing ${id}`);
    this.name = "NotFound";
  }
}
throw new NotFound(9);

5expect().rejects / failed responses

When you *want* a throw: await expect(fn()).rejects.toThrow(/missing/). When the HTTP layer does not throw, assert status yourself. Never empty-catch a helper used in tests.

JavaScript
async function mustFail() {
  const res = await fetch("/missing");
  if (res.status !== 404) throw new Error("should 404");
}

Read the story, then the code, then the result. You do not need to type yet.

1Refuse bad input

"hi" is not a number. throw jumps to catch. The function never returns a fake age.

JavaScript
function parseAge(raw) {
  const n = Number(raw);
  if (!Number.isFinite(n)) throw new Error("Not a number");
  return n;
}
try {
  console.log(parseAge("hi"));
} catch (error) {
  console.log("could not parse");
}

Result · prints: could not parse

2Happy path

"21" converts cleanly. catch never runs.

JavaScript
function parseAge(raw) {
  const n = Number(raw);
  if (!Number.isFinite(n)) throw new Error("Not a number");
  return n;
}
console.log(parseAge("21"));

Result · prints: 21

3finally always runs

parse throws. catch logs. finally still logs finished.

JavaScript
try {
  JSON.parse("{");
} catch {
  console.log("bad");
} finally {
  console.log("finished");
}

Result · prints: bad finished

4Message includes actual

Future you will thank present you.

JavaScript
const status = 500;
try {
  if (status !== 200) throw new Error(`status ${status}`);
} catch (e) {
  console.log(e.message);
}

Result · prints: status 500

Watch out for

  • Catching an error and ignoring it — log or rethrow if you cannot fix it.
  • Throwing a string instead of an Error — you lose the stack trace.

Keep reading

Next page: The DOM

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 47 of 52 · Browser & problem solving · 18 min

The DOM

The DOM is the browser’s live tree of the page. HTML is the source text. The DOM is the object model you query after parse. querySelector finds one node. textContent changes text safely. innerHTML parses markup and is a cross-site scripting footgun if the string is untrusted.

Automation (Playwright) talks to this tree from the outside with locators. You still need the model: a button that is in the DOM but not visible, a field inside an iframe, a list that renders after fetch. Later chapters on events and storage sit on top of this tree.

1Select and change

document.querySelector("h1") returns the first match or null. Always check null in production code; in tests, a missing node is usually a failed expect. querySelectorAll returns a NodeList; spread it to an array if you need map. getElementById is the fast path for ids.

textContent sets plain text. classList.add("hidden") is safer than rewriting className. setAttribute / getAttribute for href and data-*. createElement("li") plus appendChild grows the tree. Prefer accessible roles in tests (getByRole) over copying CSS from DevTools.

JavaScript
const heading = document.querySelector("h1");
heading.textContent = "Ready";

2Events

addEventListener("click", handler) registers a callback. The browser calls it with an event object. event.target is the exact node; event.currentTarget is the node you bound. preventDefault() stops a form navigation or a link. stopPropagation() stops bubbling — use sparingly; tests that need it are often testing an accident.

The next chapter (DOM events) is capture, bubble, and delegation. Here: if your script runs in <head> without defer, the nodes may not exist yet. Put the script at the end of body, use type="module" (deferred), or wait for DOMContentLoaded.

3Lists of nodes and safe text

querySelectorAll returns a NodeList — use for...of or [...list]. textContent is safe for untrusted strings. innerHTML parses HTML and is an XSS hole if you dump user text. classList.toggle("open") is the usual way to flip a class.

JavaScript
const items = document.querySelectorAll("li");
for (const li of items) {
  li.textContent = li.textContent.trim();
}
box.classList.toggle("open");

4You rarely poke the DOM in Playwright

Prefer getByRole, getByLabel, getByTestId over page.$ and raw CSS. The DOM page still matters: you must understand why textContent differs from innerText, why a hidden attribute hides a locator, and why innerHTML is an XSS smell if the app dumps user HTML.

Read the story, then the code, then the result. You do not need to type yet.

1Change the heading

querySelector finds the first h1. textContent is the words inside.

JavaScript
const heading = document.querySelector("h1");
if (heading) heading.textContent = "Ready";

Result · the visible title becomes "Ready"

2Grow a list item

createElement makes a node that is not on the page yet. appendChild hangs it up.

JavaScript
const li = document.createElement("li");
li.textContent = "Milk";
document.querySelector("ul")?.appendChild(li);

Result · a new bullet “Milk” appears in the list

Watch out for

  • Running DOM code before the HTML exists — wait for DOMContentLoaded or put the script at the end of body.
  • Using innerHTML with untrusted strings (XSS). Prefer textContent.

Keep reading

Next page: Events: bubble, capture, delegate

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 48 of 52 · Browser & problem solving · 19 min

Events: bubble, capture, delegate

The DOM does not only give you querySelector. Events travel the tree. Understanding capture, target, and bubble lets you listen once on a parent instead of on every button.

1The three phases

A click goes capture (window down to the target), then the target, then bubble (back up). addEventListener(type, fn) listens on bubble by default. Pass { capture: true } for the way down. event.stopPropagation() cuts the rest of the trip. event.preventDefault() stops the browser’s built-in action (a form submit, a link navigation).

2Delegation

Put one listener on a list. In the handler, use event.target (what was clicked) or event.currentTarget (the node you bound). If the click landed on a child of a button, closest("button") finds the button. New items added later still work — you did not bind per item.

JavaScript
list.addEventListener("click", (event) => {
  const button = event.target.closest("button");
  if (!button || !list.contains(button)) return;
  // handle button
});

3Remove listeners

addEventListener needs the same function reference to removeEventListener. Anonymous arrows cannot be removed. { once: true } fires then unbinds. AbortController can cancel a group of listeners via { signal }.

4Delegation and why lists do not die

A listener on the parent with event.target.closest("button") handles buttons that were added later. That is cheaper than binding every row. Capture phase runs down, target, then bubble up. Most code uses bubble (the default). preventDefault on submit is the SPA pattern from the forms chapter.

Remove listeners on teardown if you added them on document in a test helper. Playwright locators click for you; you still need this model when the app’s own handlers race your click — usually a wait-for-visible problem, not a capture-phase problem.

5Forms without a reload

Listen to submit on the form, not click on the button (Enter still submits). preventDefault() stops the browser posting the page. Read fields with FormData or input.value.

JavaScript
form.addEventListener("submit", (event) => {
  event.preventDefault();
  const data = new FormData(form);
  console.log(data.get("email"));
});

6force click vs waiting

A handler that calls preventDefault explains a link that does not navigate. Tests should wait for the handler’s result (URL, toast), not click with force unless you are testing the disabled overlay itself.

Read the story, then the code, then the result. You do not need to type yet.

1Listen for a click

The handler receives an event. preventDefault stops a form from reloading the page.

JavaScript
button.addEventListener("click", (event) => {
  console.log("clicked");
});

Result · prints "clicked" each time the button is pressed

2One listener for many buttons

closest walks up from the exact pixel to the nearest button.

JavaScript
list.addEventListener("click", (event) => {
  const button = event.target.closest("button");
  if (!button) return;
  console.log(button.textContent);
});

Result · prints that button’s label, even for buttons added later

Watch out for

  • Binding a click on every row inside a loop instead of one parent listener.
  • Forgetting preventDefault on submit and getting a full page reload.

Keep reading

Next page: localStorage & sessionStorage

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 49 of 52 · Browser & problem solving · 15 min

localStorage & sessionStorage

Web storage saves strings in the browser. localStorage lasts across visits. sessionStorage lasts until the tab closes. Both are origin-scoped (scheme + host + port). They are synchronous and small (a few MB) — not a database.

1Set, get, JSON

setItem(key, value) and getItem(key) (null if missing). You can only store strings, so JSON.stringify objects and JSON.parse them back. removeItem and clear clean up.

JavaScript
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme");
localStorage.setItem("user", JSON.stringify({ name: "Ada" }));

2Limits

Do not store secrets (the user can read storage). Do not store huge JSON on every keystroke. Storage events fire in other tabs of the same origin when localStorage changes — useful for syncing theme, not for a live database.

3Strings only, origin only

setItem always stringifies to string. Objects need JSON.stringify in, JSON.parse out, and a null guard if getItem missed. sessionStorage dies with the tab. localStorage persists. Neither is sent to the server. Quota is small; do not store files. Not for passwords.

Private mode, iframe rules, and user settings can throw QuotaExceededError or SecurityError. Tests should not assume storage survived a context recreation unless you used storageState.

4session vs local, and quota

sessionStorage dies with the tab — good for a wizard’s draft. localStorage survives — good for theme. Both throw QuotaExceededError if you stuff megabytes. Wrap writes in try/catch. Never store tokens you would not write on a postcard.

JavaScript
try {
  sessionStorage.setItem("step", "2");
} catch {
  // private mode or full disk
}

5storageState dumps both

Playwright’s storageState JSON includes cookies and origins[].localStorage. Seeding only localStorage will not log you into an HttpOnly-cookie app. Open the saved JSON once so you know which jar the app uses.

Read the story, then the code, then the result. You do not need to type yet.

1Remember a theme

Next visit, getItem still returns "dark".

JavaScript
localStorage.setItem("theme", "dark");
console.log(localStorage.getItem("theme"));

Result · prints: dark

2Remember an object

stringify to store. parse to use. Guard against null.

JavaScript
localStorage.setItem("user", JSON.stringify({ name: "Ada" }));
const raw = localStorage.getItem("user");
const user = raw ? JSON.parse(raw) : null;
console.log(user?.name);

Result · prints: Ada

Watch out for

  • JSON.parse(localStorage.getItem("x")) when getItem returned null.
  • Assuming storage is available (it can throw in private modes). Wrap in try/catch.

Keep reading

Next page: Cookies vs storage

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 50 of 52 · Browser & problem solving · 19 min

Cookies vs storage

Cookies are small strings the browser stores per origin and *sends automatically* on matching HTTP requests. localStorage is not sent to the server. Mixing them up is a common QA confusion when a “token in storage” never reaches the API.

Automation cares about cookies for login state (Playwright storageState is often cookies + localStorage together). This page is the JavaScript view.

1document.cookie is a weird string

Reading document.cookie gives name=value pairs joined by "; ". You cannot see HttpOnly cookies from JavaScript (the server set them as hidden from XSS). Writing document.cookie = "theme=dark; path=/" *adds or updates one cookie*; it does not replace the whole jar. Attributes (Secure, SameSite, Max-Age) are set on write, not visible on read.

JavaScript
document.cookie = "theme=dark; path=/; SameSite=Lax";
console.log(document.cookie.includes("theme=dark"));

2What testers actually use

In Playwright, context.cookies() lists cookies including HttpOnly. context.addCookies([...]) seeds a session without clicking login. storageState({ path }) dumps cookies plus localStorage for reuse. Prefer that over document.cookie in tests.

SameSite=Lax/Strict/None affects whether a cookie rides on cross-site requests. Secure means HTTPS only. Domain and Path shrink where the cookie is sent.

3Storage is not a cookie

localStorage.setItem("token", jwt) stays in the tab’s origin storage. fetch to the API will *not* attach it unless your app reads it and sets Authorization. If production puts the session in an HttpOnly cookie, your test that only sets localStorage will still be logged out. Always ask: how does the real app authenticate?

JavaScript
// App pattern A — cookie (browser sends it)
// document.cookie is incomplete; use Playwright cookies APIs.

// App pattern B — bearer in memory/storage
localStorage.setItem("token", "abc");
await fetch("/api/me", {
  headers: { Authorization: "Bearer " + localStorage.getItem("token") },
});

4Ask how the real app authenticates

HttpOnly cookies are invisible to document.cookie and visible to Playwright’s context.cookies(). Bearer tokens in localStorage are invisible to the Cookie header unless the app copies them to Authorization. Seeding the wrong jar looks like “login test is flaky.” Open storageState JSON once.

SameSite, Secure, Path, Domain decide whether the cookie rides. A cookie set on /login without Path=/ may not apply to /dashboard. Tests that only set localStorage against an HttpOnly app will stay logged out.

Read the story, then the code, then the result. You do not need to type yet.

1Storage is not sent

Setting a token in localStorage does not add a Cookie header by itself.

JavaScript
localStorage.setItem("token", "abc");
console.log(localStorage.getItem("token"));
// fetch("/api") will not magically attach abc

Result · prints: abc

2document.cookie is incomplete

You only see non-HttpOnly cookies, joined as a string.

JavaScript
document.cookie = "theme=dark; path=/";
console.log(typeof document.cookie);

Result · prints: string

Watch out for

  • Setting localStorage in a test when the app uses HttpOnly cookies.
  • Expecting document.cookie to show session cookies marked HttpOnly.
  • Forgetting Path=/ so a cookie set on /login does not apply to /dashboard.

Keep reading

Next page: Problem-solving patterns

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 51 of 52 · Browser & problem solving · 17 min

Problem-solving patterns

Interview puzzles look unique. Most of them are a handful of habits: walk a string from both ends, count with a Map, remember the best so far, look up a complement. Fluency with arrays, objects, and loops beats memorizing 200 solutions.

Talk before you type: restate input and output, give an example, name the structure, then code. Testers still get these questions. Frequency maps and “seen” sets cover an embarrassing amount of them. Practice on this site exists so the syntax does not eat the thinking time.

1Strings and arrays

Reverse a string: [...s].reverse().join(""). Palindrome: normalize case, compare to reverse. Anagram: sort characters, compare. Chunk: slice in a loop. Unique: Set. Flatten: flat(Infinity) or recursion. FizzBuzz: % 15 before % 3 and % 5 so the overlap is right.

These are not “algorithm research.” They are fluency. Write them once by hand without looking. Then use the built-ins at work. Interviews still want the hand version so they can hear you talk.

2Lookups

twoSum: one pass, Map from value to index, look up complement. maxProfit: track min price so far, max difference. “Have I seen this?” is Set. “How many times?” is Map. Nested loops are the brute force you mention, then improve.

Name the pattern out loud before you type. Testers who automate still get asked. The practice track exists so the syntax is not the hard part.

3Two pointers and a map

Palindrome: one index at each end, walk in. twoSum: walk once, store seen numbers in a Map from value → index, look up target - n. If you need “have I seen this character?”, Set. If you need a count, Map.

JavaScript
function twoSum(nums, target) {
  const seen = new Map();
  for (let i = 0; i < nums.length; i++) {
    const want = target - nums[i];
    if (seen.has(want)) return [seen.get(want), i];
    seen.set(nums[i], i);
  }
}

4Talk before you type (interview)

Restate input/output. Give an example. Name the structure (Map, two pointers). Then code. Testers who automate still get DS questions — frequency Map and “seen” Set cover most of them.

Read the story, then the code, then the result. You do not need to type yet.

1A palindrome

Ignore case. Compare the string to its reverse.

JavaScript
function isPalindrome(text) {
  const s = text.toLowerCase();
  return s === [...s].reverse().join("");
}
console.log(isPalindrome("Level"));
console.log(isPalindrome("tea"));

Result · prints: true false

2FizzBuzz for 1..6

Multiples of both 3 and 5 are “FizzBuzz”. Else Fizz, Buzz, or the number.

JavaScript
function fizzBuzz(n) {
  const out = [];
  for (let i = 1; i <= n; i++) {
    if (i % 15 === 0) out.push("FizzBuzz");
    else if (i % 3 === 0) out.push("Fizz");
    else if (i % 5 === 0) out.push("Buzz");
    else out.push(String(i));
  }
  return out;
}
console.log(fizzBuzz(6).join(", "));

Result · prints: 1, 2, Fizz, 4, Buzz, Fizz

3Count letters

A Map from character → count. This is the ‘frequency’ habit.

JavaScript
function counts(text) {
  const map = new Map();
  for (const ch of text) {
    map.set(ch, (map.get(ch) ?? 0) + 1);
  }
  return map;
}
console.log(counts("aba").get("a"));

Result · prints: 2

Watch out for

  • Jumping to code before restating the examples in your own words.
  • Mutating the input when the prompt asked for a new array.

Keep reading

Next page: Assertions: how testers say “this must be true”

Scroll down, or use the Next button. Arrow keys also turn the page.

Page 52 of 52 · Browser & problem solving · 21 min

Assertions: how testers say “this must be true”

An assertion is a check that throws when the world is wrong. JavaScript has no built-in expect(). Test runners add it. You can still write the idea with if + throw — and you should, so Playwright/Jest messages make sense.

Good assertions are specific: they name the field, the expected value, and they fail with the actual value in the message. Vague if (!data) throw new Error("bad") wastes hours.

1Throw with a useful message

Put actual and expected in the string. JSON.stringify objects. This is what expect(x).toBe(y) does under the hood.

JavaScript
function assertEqual(actual, expected, label) {
  if (actual !== expected) {
    throw new Error(`${label}: expected ${expected}, got ${actual}`);
  }
}
assertEqual("open", "open", "status");

2Truth, matchers, and soft vs hard

Hard fail: one assertion throws and the test stops. Soft fail (some runners): collect many mismatches then fail once. Playwright’s expect(locator) is async — it *retries* until timeout. expect(value) on a plain number is sync. Mixing them up is a common interview answer: UI expects wait; API body expects usually do not retry unless you wrap them.

toEqual (Jest) checks deep value equality. toBe checks identity (===). toContain checks a substring or array item. Learn the runner you use; the JS underneath is still ===, loops, and throws.

JavaScript
function assertContains(list, item) {
  if (!list.includes(item)) {
    throw new Error(`missing ${item} in ${JSON.stringify(list)}`);
  }
}

3Async assertions

If the check needs the network, the function must be async and you must await the fetch. Forgetting await makes the test pass before the request finishes. Playwright: await expect(page.getByRole("button", { name: "Save" })).toBeVisible().

JavaScript
async function assertStatus(url, code) {
  const res = await fetch(url);
  if (res.status !== code) {
    throw new Error(`expected ${code}, got ${res.status}`);
  }
}

4Do not swallow failures

try { assert() } catch { } is how false greens happen. If you catch, rethrow or fail the test runner. empty catch in production code is the same smell.

5Make the failure do the debugging

assertEqual(actual, expected, "status") should throw status: expected 201, got 404. JSON.stringify objects. Hard fails stop the test. Playwright locator expects retry; that is not a license to catch and ignore. Never empty-catch an assertion. Never assert page.content().includes if a locator wait would be stable.

Async assertions must be awaited. A test that returns before fetch finishes is a coin flip. If you write a helper, return the promise and let the test await it. Matchers (toBe, toEqual, toContain) are still ===, deep compare, and includes underneath. Learn one runner well; the JavaScript is portable.

Read the story, then the code, then the result. You do not need to type yet.

1Name the field

The error should mention status and both values.

JavaScript
function assertEqual(actual, expected, label) {
  if (actual !== expected) {
    throw new Error(`${label}: expected ${expected}, got ${actual}`);
  }
}
assertEqual(201, 201, "status");
console.log("ok");

Result · prints: ok

2Lists

includes is the JS behind many toContain matchers.

JavaScript
const roles = ["qa", "user"];
console.log(roles.includes("qa"));

Result · prints: true

3Async check needs await

Without await, you assert on a Promise.

JavaScript
async function statusOf() { return 200; }
async function test() {
  const p = statusOf();
  const n = await statusOf();
  console.log(p instanceof Promise, n);
}
test();

Result · prints: true 200

Watch out for

  • Assertions without the actual value in the message.
  • Not awaiting an async expect / fetch.
  • Checking page.content().includes(text) when a locator wait would be stable.

You finished the tutorial

Now lock it in with practice

Open practice

33 / 52 · TypeScript in one sitting (for JS readers)

Teams and tool vendors can sponsor this page.