TechByteByByte

Part 2: JavaScript Fundamentals for QA

Learn the JavaScript concepts that make Playwright test code readable and reliable.

JavaScript lets us give Playwright exact instructions. Variables hold values, functions group instructions, and await pauses until browser work finishes.

Think of code as a precise recipe that the computer follows in order.

JavaScript test → Playwright instructions → browser actions → result

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

A fair question to ask right now: why does a tester need to learn a programming language at all? The honest answer is that Playwright isn’t a separate “testing tool” with its own made-up syntax you memorize in isolation — it’s a JavaScript (and TypeScript) library. Every single Playwright command you’ll ever write is, underneath, ordinary JavaScript calling functions, working with objects, and handling asynchronous operations. If you don’t understand those three things, Playwright code will always feel like memorized incantations instead of something you can actually read, reason about, and eventually write from scratch.

This part will not turn you into a general-purpose JavaScript developer — that’s not the goal, and a huge amount of what a professional JS developer needs is irrelevant to writing good tests. We’re going to cover exactly what a QA automation engineer actually uses, and at every step, tie it directly back to why Playwright needs it.


Programming Basics, Quickly

A programming language is simply a way of writing instructions a computer can actually execute — a formal, precise alternative to natural language, because computers can’t handle ambiguity the way humans can. Syntax is the set of grammar rules a language demands you follow exactly — miss a comma or a bracket, and unlike a human reading a slightly broken sentence, a computer usually won’t guess what you meant; it’ll just fail.

A statement is one complete instruction — “do this one thing.” A variable is a named container that holds a value you want to use or reuse later, instead of retyping it everywhere.

// This is a statement: it creates a variable and stores a value in it
let username = "standard_user";

That’s genuinely the whole idea underneath everything more complex you’re about to learn: name things, store things, do things with them, in a precise order.


Variables — let, const, var

JavaScript gives you three ways to declare a variable, and knowing which to reach for is a small habit that instantly signals experience:

let count = 1; // can be reassigned later
const username = "standard_user"; // cannot be reassigned after this line
var oldStyle = "avoid this"; // legacy, has quirky behavior — avoid in modern code
  • const means “this name will always point to this same value, for the rest of this scope.” Use this by default — it’s the safest choice, and it makes your intent explicit: this value isn’t supposed to change.
  • let means “this value is allowed to change later.” Use it specifically when you genuinely need to reassign something, like a counter that increases in a loop.
  • var is the old way JavaScript used to declare variables, before let and const existed. It has looser, more error-prone behavior around where a variable is “visible” in your code (its scope). You’ll still see it in older code and tutorials, but modern Playwright code — and modern JavaScript in general — avoids it.

Here’s why this matters in real test code, not just as trivia: imagine a test where you store an expected product price in a variable to compare later. If you use const, and somewhere later in the test you accidentally try to reassign it, JavaScript will immediately throw an error and stop you — catching a mistake on the spot, before it silently gives you a wrong, misleading test result. That protection is exactly why defaulting to const is considered good practice, not just a style preference.


Data Types

Every value in JavaScript has a type — what kind of thing it is. This matters because different types behave differently, and Playwright’s own functions expect specific types in specific places.

let username = "standard_user"; // String — text, always in quotes
let price = 29.99; // Number — no quotes, used for arithmetic
let isLoggedIn = true; // Boolean — only ever true or false
let cartItems = ["Backpack", "T-Shirt"]; // Array — an ordered list of values
let user = { name: "Amar", age: 28 }; // Object — named properties holding values
let error = null; // Null — deliberately "nothing" / "empty"
let notYetSet; // Undefined — a variable that has no value at all yet

Two of these deserve extra attention because they show up constantly in Playwright test code specifically:

Arrays

hold an ordered list of values, and you access items by position, starting from zero:

let cartItems = ["Backpack", "T-Shirt", "Bike Light"];
console.log(cartItems[0]); // "Backpack" — the first item, at position 0
console.log(cartItems.length); // 3 — how many items are in the array

That “counting starts at zero” rule trips up nearly every beginner at least once — cartItems[1] is the second item (“T-Shirt”), not the first. You’ll rely on arrays constantly in real tests: verifying the number of products shown, checking every row of a table, looping through multiple test users.

Objects

hold named properties, each with its own value — genuinely the most important data type for you specifically, because Playwright’s own APIs are built almost entirely around objects:

let loginData = {
  username: "standard_user",
  password: "secret_sauce",
};

console.log(loginData.username); // "standard_user"

Here’s the payoff for learning this now, made concrete: when you eventually write something like

await page.getByRole("button", { name: "Login" }).click();

that { name: 'Login' } is not special Playwright syntax you have to memorize as a magic phrase — it’s just an ordinary JavaScript object, with one property called name, set to the value 'Login', being passed in to tell getByRole which button, specifically, you mean. Once objects genuinely click for you, a huge fraction of Playwright’s API stops looking like arbitrary syntax and starts looking like exactly what it is: ordinary function calls with configuration objects.


Operators, Conditions, and Loops

Operators

let you do things with values — compare them, combine them, check them:

let total = 10 + 5; // arithmetic: 15
let isEqual = total === 15; // comparison: true
let isNotEqual = total !== 20; // true

Notice === (three equals signs) for comparison, not = (one equals sign, which is assignment — storing a value). Mixing these up is one of the most common early JavaScript mistakes, and it’s worth deliberately drilling into muscle memory now, because it resurfaces constantly, including inside test assertions.

Conditions

let code make decisions — do one thing if something is true, another if it’s false:

let stock = 0;

if (stock > 0) {
  console.log("In stock");
} else {
  console.log("Out of stock");
}
// Output: Out of stock

Loops

let code repeat an action, instead of writing the same instruction over and over by hand:

let products = ["Backpack", "T-Shirt", "Bike Light"];

for (let i = 0; i < products.length; i++) {
  console.log(products[i]);
}
// Output:
// Backpack
// T-Shirt
// Bike Light

Think about why this matters for testing specifically: imagine SauceDemo’s inventory page has six products, and you want to verify that every single one has a visible “Add to cart” button. Without a loop, you’d write six nearly identical lines of test code by hand.

With a loop, you write the check once, and it runs against however many products actually exist — six today, sixty tomorrow, without you touching the test at all. This single idea is the seed of writing maintainable, scalable tests instead of long, repetitive, copy-pasted ones — a theme that will come back explicitly once we reach data-driven testing later in the series.


Functions and Arrow Functions

A function is a named, reusable block of instructions — write the logic once, then call (run) it as many times as you need, wherever you need it:

function greet(name) {
  return "Hello, " + name;
}

console.log(greet("Amar")); // Output: Hello, Amar
console.log(greet("Priya")); // Output: Hello, Priya

name here is a parameter — a placeholder for whatever value gets passed in when the function is actually called. return sends a value back out of the function to wherever it was called from.

Arrow functions

are a shorter, more modern way to write the same idea, and they’re the style you will see in essentially every piece of real Playwright code you encounter, including every example in this series from here on:

// Traditional function
function greet(name) {
  return "Hello, " + name;
}

// The exact same thing, as an arrow function
const greet = (name) => {
  return "Hello, " + name;
};

// Arrow functions can be even shorter for a single-line return
const greet = (name) => "Hello, " + name;

There’s no deep behavioral mystery here for your purposes right now — treat arrow functions as simply “the modern, shorter way to write a function,” because that’s overwhelmingly how you’ll encounter and write them in Playwright.

Here’s why this specific syntax matters immediately and directly: every single Playwright test you write is itself wrapped in a function.

test("user can login successfully", async ({ page }) => {
  // test steps go here
});

Strip away everything Playwright-specific from that line, and what’s left is a function call — test(...) — being given two things: a string describing the test, and an arrow function containing the actual steps to run. Once “a function is just a named, reusable block of instructions, and this arrow syntax is just a shorter way to write one” genuinely sits with you, that entire line stops being mysterious boilerplate and becomes something you can actually read.


Destructuring, and Spread/Rest

Destructuring

lets you pull specific properties out of an object (or items out of an array) directly into their own named variables, in one step, instead of accessing them one at a time with dot notation:

let loginData = { username: "standard_user", password: "secret_sauce" };

// Without destructuring
let username = loginData.username;
let password = loginData.password;

// With destructuring — same result, one line
let { username, password } = loginData;

This isn’t just a shortcut for convenience — it’s the exact mechanism behind one of the very first pieces of “real” Playwright syntax you’ll write. Look again at:

test("user can login successfully", async ({ page }) => {
  // ...
});

That { page } is destructuring in action. Playwright automatically hands the test function an object containing several useful tools (a page, and others you’ll meet properly once we reach fixtures in Part 15), and { page } simply says “pull the page property out of whatever object you gave me, and let me refer to it directly as page from here on.” Once destructuring makes sense, { page } stops looking like strange required boilerplate and becomes something you understand completely.

Spread

(...) lets you expand an array or object out into individual pieces — commonly used to copy or merge data:

let baseUser = { username: "standard_user", password: "secret_sauce" };
let adminUser = { ...baseUser, role: "admin" };
// adminUser is now: { username: "standard_user", password: "secret_sauce", role: "admin" }

You’ll meet this again in Part 21 (Test Data Management), where it becomes a genuinely convenient way to build variations of test data — a base user object, with just one property overridden, instead of retyping the whole object each time.


Modules — import / export

As your test code grows past a handful of files, you’ll want to split logic across multiple files instead of one enormous one — a page object in one file, test data in another, utility functions in a third. Modules are how JavaScript lets separate files share code with each other.

// file: loginData.js
export const validUser = {
  username: "standard_user",
  password: "secret_sauce",
};
// file: login.spec.js
import { validUser } from "./loginData.js";

console.log(validUser.username); // Output: standard_user

export marks something in one file as available to be used elsewhere. import pulls that exported thing into a different file. This is the exact mechanism behind Playwright’s Page Object Model, which you’ll build properly in Part 20 — a LoginPage class exported from its own file, then imported into every test file that needs to use it, instead of duplicating the same login logic across dozens of test files.


Error Handling — try / catch / finally / throw

Sometimes code doesn’t run the way you expect — a network request fails, a value is missing, something genuinely goes wrong mid-execution. JavaScript gives you a structured way to anticipate and handle that, instead of letting the whole program crash unhelpfully:

try {
  // code that might fail
  let result = riskyOperation();
  console.log(result);
} catch (error) {
  // runs only if something inside "try" throws an error
  console.log("Something went wrong:", error.message);
} finally {
  // runs no matter what — whether it succeeded or failed
  console.log("This always runs");
}

throw is how code deliberately signals “something is wrong here,” creating an error on purpose so it can be caught and handled:

function login(username, password) {
  if (!username) {
    throw new Error("Username is required");
  }
  // ... proceed with login
}

For QA work specifically, this becomes genuinely important once you start writing more advanced tests and helper functions — for instance, wrapping an API call in a try/catch so that if the API fails unexpectedly, your test can log a clear, useful message about what failed, instead of the whole test suite dying with a vague, confusing crash.


Asynchronous JavaScript — the Most Important Concept in This Part

Everything up to this point has been useful groundwork. This next idea is the one thing in all of JavaScript that, if it doesn’t genuinely click, will make Playwright feel confusing forever — and if it does click, will make almost every Playwright command make immediate, obvious sense.

Ordinary JavaScript code runs synchronously — one line at a time, in order, each line waiting for the previous one to fully finish before starting:

console.log("Step 1");
console.log("Step 2");
console.log("Step 3");
// Output, always in this exact order:
// Step 1
// Step 2
// Step 3

This works perfectly for fast, instant operations. But think back to Part 0’s request-response loop: when a browser navigates to a page, or clicks a button that triggers a network request, that operation doesn’t complete instantly. It might take 200 milliseconds. It might take three seconds on a slow connection. During that time, the computer genuinely doesn’t yet have the answer — the page hasn’t loaded, the response hasn’t arrived.

If JavaScript simply froze completely and did nothing at all while waiting for every single one of these operations, the entire browser tab would lock up and become unusable for however long each operation took. This is precisely the problem asynchronous programming exists to solve — a way to say “start this operation, and let me know when it’s actually done, without freezing everything else in the meantime.”

Analogy: Supermarket Cashier vs. Food Court Buzzer Imagine two different ways of ordering things:

  • Synchronous (Supermarket Cashier): You stand in a single checkout queue. The cashier must scan every item, process payment, and bag goods for Customer 1 before they can even look at Customer 2. If Customer 1 has a price dispute (a slow operation), the entire line freezes and everyone waits.
  • Asynchronous (Food Court Buzzer): You order a gourmet burger at a food court. It takes 10 minutes to cook. Instead of forcing you to stand at the counter and blocking the next customer, the cashier hands you a buzzer pager (a Promise) and says: “Go find a seat, read a book, or check your phone. We will buzz you when your food is ready.” Your buzzer pager is Pending while the food cooks, turns Fulfilled (flashes) when the burger is ready, or Rejected (beep error) if the kitchen runs out of meat.

📊 Visual Flowchart: Promise Lifecycle & The Node.js Event Loop

Here is how an asynchronous task moves from its initial request to final completion under the hood:

stateDiagram-v2
    [*] --> Pending : Async Action Initiated (e.g. page.goto)
    Pending --> Fulfilled : Action Completed Successfully (resolve)
    Pending --> Rejected : Action Failed / Timeout (reject)

Fulfilled --> CallbackQueue : .then() or await returns value
    Rejected --> ErrorHandler : .catch() or try-catch blocks

CallbackQueue --> EventLoop : Process next step in line
    ErrorHandler --> EventLoop
    EventLoop --> [*]

Early JavaScript solved this with callbacks — passing a function to be run later, once an operation finished. Callbacks work, but nested several layers deep (an operation depending on another operation depending on another), they become genuinely hard to read — famously nicknamed “callback hell” in the JavaScript world. This is worth knowing exists, mostly so that if you ever encounter it in older code, you recognize what you’re looking at.

Promises

were introduced as a cleaner solution. A Promise is an object representing a value that doesn’t exist yet, but will — eventually — either successfully resolve with a result, or fail with an error. A Promise is always in one of three states:

  • Pending — the operation hasn’t finished yet.
  • Fulfilled — the operation succeeded, and a result is now available.
  • Rejected — the operation failed, and an error is now available.
function fetchProductPrice() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(29.99); // simulates a slow operation that eventually succeeds
    }, 2000);
  });
}

fetchProductPrice()
  .then((price) => console.log("Price is:", price))
  .catch((error) => console.log("Failed:", error));
// After roughly 2 seconds:
// Price is: 29.99

.then() runs once the Promise successfully resolves; .catch() runs if it fails instead. This works, and it’s a genuine improvement over deeply nested callbacks — but chaining many .then() calls together for a multi-step process can still get visually messy.

async/await

— the syntax you will actually use, in essentially every single line of real Playwright test code you write from here on — is a cleaner way to work with Promises, letting asynchronous code be written and read almost as if it were ordinary, top-to-bottom synchronous code:

async function fetchProductPrice() {
  return new Promise((resolve) => {
    setTimeout(() => resolve(29.99), 2000);
  });
}

async function showPrice() {
  console.log("Fetching price...");
  const price = await fetchProductPrice(); // pauses here until the Promise resolves
  console.log("Price is:", price);
}

showPrice();
// Output:
// Fetching price...
// (pause of about 2 seconds)
// Price is: 29.99

Two rules to hold onto: await can only be used inside a function marked async, and await tells JavaScript “pause this specific function right here, and don’t move to the next line until this Promise has actually resolved.” Critically — and this is the part that removes any remaining mystery — this pause does not freeze the entire browser or program. Other unrelated code can still run during that wait.

It only pauses the specific sequence of steps that genuinely needs the result before it can sensibly continue, which is exactly the behavior test automation needs: don’t try to click a button on a page that hasn’t finished loading yet, but also don’t lock up the whole system while waiting for it.

Now, finally, look back at the exact test example from Part 0’s spec, and read it properly, piece by piece, for the first time:

test("user can login successfully", async ({ page }) => {
  // Open the login page
  await page.goto("/login");

  // Find the Email input and enter the user's email
  await page.getByLabel("Email").fill("amar@example.com");

  // Find the Login button and click it
  await page.getByRole("button", { name: "Login" }).click();

  // Verify that the user has successfully logged in
  await expect(page.getByText("Welcome")).toBeVisible();
});

You now have every single piece of vocabulary required to genuinely understand this, not just recognize it:

  • test(...) is a function call, given a string and an arrow function — exactly as covered above.
  • async ({ page }) => { ... } is an arrow function, marked async because it needs to await things inside it, with { page } destructuring the page tool out of the object Playwright provides.
  • page.goto('/login') navigates the browser — an operation that takes real time, which is exactly why it’s await-ed: don’t run the next line until this page has actually started loading.
  • page.getByLabel('Email') is a locator (Part 7 will cover this properly), .fill(...) is an action, and the whole thing is await-ed because typing into a field on a page still involves the browser actually locating and interacting with a real element, which takes a small but real amount of time.
  • { name: 'Login' } is an ordinary object, exactly like loginData earlier in this part — just a way of passing configuration into getByRole.
  • expect(...) is an assertion (Part 9 covers this in full) — and it’s await-ed because Playwright’s assertions actually retry automatically for a period of time, waiting for the condition to become true, rather than checking exactly once and immediately giving up.

Every single await in this test exists for the same underlying reason: something on the other side of it takes real time to complete, and the test needs to genuinely wait for that before moving on — never guessing, never freezing everything else, and never proceeding based on a state that hasn’t actually happened yet.

It’s genuinely common, especially very early on, to sprinkle await in front of things almost superstitiously, without a clear reason — or, just as often, to forget it somewhere it was actually needed.

Forgetting an await in front of an asynchronous Playwright action is one of the single most common sources of confusing, seemingly random test failures beginners run into, because the test moves on to the next line before the browser has actually finished the previous action — the test isn’t wrong about what to check, it’s just checking too early.

If you ever see a test behave inconsistently, checking for a missing await should be one of the very first things you look for, well before assuming the application itself has a bug.


How It Works in a Real Test Run

A Playwright test is JavaScript or TypeScript executed by Node.js. Node starts the test, an awaited Playwright call begins asynchronous browser work, and the function pauses until the promise settles; meanwhile, JavaScript’s event loop can process other completed work.

Missing await does not make the browser faster. It lets later test code run before the earlier operation has finished, creating races that may pass locally and fail in CI.

Interview Questions

Q: Why does a QA automation engineer need to learn JavaScript at all?

Ans: Because Playwright is a JavaScript (and TypeScript) library — every Playwright command is, underneath, ordinary JavaScript calling functions and handling objects and asynchronous operations. Without understanding those basics, Playwright code is just memorized syntax rather than something you can genuinely read, adapt, and debug.

Q: What’s the difference between let, const, and var, and which should you default to?

Ans: const means a variable’s value cannot be reassigned after it’s declared, let allows reassignment, and var is the older, legacy way of declaring variables with looser, more error-prone scoping rules. The general default is const, switching to let only when a value genuinely needs to change later, and avoiding var in modern code.

Q: What is the difference between an array and an object, and can you give an example of when you’d use each in a test?

Ans: An array is an ordered list of values, accessed by numeric position — useful for something like a list of product names on an inventory page. An object holds named properties, each with its own value, accessed by name rather than position — useful for something like a set of login credentials, where each value has a clear, distinct meaning (username, password) rather than just a position in a list.

Q: What is a Promise, and what are its three possible states?

Ans: A Promise is an object representing a value that doesn’t exist yet but will, eventually, either succeed or fail. Its three states are pending (still in progress), fulfilled (completed successfully, with a result available), and rejected (failed, with an error available).

Q: Explain what async and await actually do, in your own words.

Ans: async marks a function as one that’s allowed to use await inside it. await pauses execution of that specific function at that exact line until the Promise it’s waiting on has resolved, before moving on to the next line — without freezing the rest of the program while it waits. It’s a way of writing code that deals with operations taking real time, like a network request, while still reading almost like ordinary step-by-step code.

Q: In the test test('user can login', async ({ page }) => { ... }), what is { page } actually doing?

Ans: It’s destructuring — Playwright passes the test function an object containing several useful tools, and { page } pulls the page property out of that object so it can be referred to directly as page for the rest of the function, instead of needing to be accessed through a longer path.

Q: A test clicks a button and then immediately checks for a result, but the test fails inconsistently — sometimes it passes, sometimes it fails. Before assuming the application has a bug, what’s one of the first things you’d check?

Ans: I’d check whether the asynchronous steps involved are properly await-ed. A very common cause of inconsistent, seemingly random failures is a missing await in front of an action or assertion — the test moves on to the next line before the browser has actually finished the previous step, so it ends up checking a state that hasn’t happened yet rather than genuinely failing because of an application bug.

Q: What problem do Promises and async/await solve that plain synchronous code cannot handle well?

Ans: Plain synchronous JavaScript executes one line at a time, each waiting for the previous line to finish. Operations like network requests or page navigations take real, unpredictable time to complete, and if JavaScript simply froze entirely while waiting for each one, the whole browser tab would lock up. Promises, and the cleaner async/await syntax built on top of them, let code wait specifically for the result it needs without freezing everything else in the meantime.


Exercises — Part 2

Understand: Without running any code, read this snippet and write down, in your own words, what will print and in what order:

console.log("A");
async function wait() {
  console.log("B");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log("C");
}
wait();
console.log("D");

(Hint: think carefully about what pauses, and what doesn’t.)

Simple Practice: Write a small object called loginData with username and password properties, using SauceDemo’s real login values (standard_user / secret_sauce). Then use destructuring to pull both values out into their own variables in a single line.

Real-World Scenario: You have an array of five SauceDemo product names. Write a for loop that prints each one to the console, and explain, in a sentence, why using a loop here is better than writing five separate console.log lines — connect your answer back to what happens if a sixth product gets added later.

Challenge: Write an async function called fetchUser that returns a Promise resolving to a user object ({ username: "standard_user", password: "secret_sauce" }) after a short simulated delay using setTimeout. Then write a second async function that calls fetchUser, awaits its result, and logs the username to the console. Predict the output and its rough timing before you run it.


Next: Part 3 — TypeScript

— now that plain JavaScript makes sense, we’ll add a thin, genuinely useful layer on top of it that catches entire categories of mistakes before your tests even run.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed