Module 1: JavaScript & TypeScript for QA
This module gives you the TypeScript and JavaScript skills you need to write Playwright tests — nothing more, nothing less. If you’ve never written code before, that’s fine. If you have, this will be a quick consolidation of the patterns you’ll use every day.
Throughout the course we use TypeScript — that’s what npm init playwright@latest set up for you in Module 0. TypeScript is JavaScript with optional types, so every JavaScript example below is also valid TypeScript.
🎬 Video coming soon
const and let — never use var
Section titled “const and let — never use var”You’ll see two variable keywords in every test file: const and let.
const baseURL = 'http://localhost:3000'; // won't be reassignedlet retryCount = 0; // will be reassigned laterThe rule: use const by default. Switch to let only when you need to reassign the variable.
// ✅ Correctconst TIMEOUT = 30000;let attempts = 0;attempts = attempts + 1; // fine — let allows reassignment
// ❌ This throws an errorconst MAX = 5;MAX = 10; // TypeError: Assignment to constant variableImportant nuance: const does not mean immutable. You can still change object properties:
const config = { retries: 1 };config.retries = 2; // ✅ this is allowedconfig = { retries: 3 }; // ❌ this is not — you can't rebind the referenceAs for var — it exists but causes confusing scope bugs. Every modern linter flags it. You will never need it in a Playwright project.
Block scope
Section titled “Block scope”const and let are block-scoped — they only exist inside the { } they are declared in. Here message is only accessible inside the if block; referencing it outside throws a ReferenceError:
if (true) { let message = 'Hello!';}console.log(message); // ❌ ReferenceError: message is not definedThink of a let inside { } like a note you can only read while you’re in that room — step outside the braces and it’s gone.
You will fix exactly this in Exercise 1.
Exercise setup
Section titled “Exercise setup”All exercises run on plain Node.js — no browser needed.
- Open the Playwright project you set up in Module 0 in VS Code.
- Create an
exercises/mod1/folder in the project root (right-click in the Explorer → New Folder). - Create one file per exercise:
exercise-1.ts,exercise-2.ts…exercise-6.ts. - Open the integrated terminal (Ctrl + `) and confirm you are in the project root.
- Run a file:
npx tsx exercises/mod1/exercise-1.ts
If tsx is not found: npm install -D tsx
Exercise 1: Variables — fix the mistakes
Section titled “Exercise 1: Variables — fix the mistakes”The code below has several problems. Fix them and run the file.
// ❌ This code has problems. Fix them.var baseURL = 'http://localhost:3000';const TIMEOUT = 30000;TIMEOUT = 50000; // Should this be allowed?
const user = { email: 'test@test.io', role: 'admin' };user = { email: 'admin@test.io', role: 'admin' }; // Should this work?
if (true) { let message = 'Hello!';}console.log(message); // Why does this throw?
console.log('Exercise 1 done');Tasks:
- Replace
var baseURLwithconst - Fix the
TIMEOUTreassignment — use a new variable (e.g.TIMEOUT_EXTENDED) - Fix the
user = {...}line — assigning to aconstis an error; declare it withletinstead - Fix the
messagescope issue — moveconsole.loginside the block - Run the fixed file and verify no errors
Expected output:
Hello!Exercise 1 doneSolution
const baseURL = 'http://localhost:3000';
const TIMEOUT = 30000;const TIMEOUT_EXTENDED = 50000; // a new variable instead of reassigning TIMEOUT
let user = { email: 'test@test.io', role: 'admin' };user = { email: 'admin@test.io', role: 'admin' }; // `let` allows rebinding
if (true) { let message = 'Hello!'; console.log(message); // moved inside the block so it's in scope}
console.log('Exercise 1 done');Remember: const by default, let only when you must reassign, never var.
Strings, numbers, and booleans
Section titled “Strings, numbers, and booleans”These three primitive types cover nearly everything you’ll touch in a test file.
Strings
Section titled “Strings”const url = 'http://localhost:3000/login';const selector = '#email-input';
// Template literals (backticks) — use these whenever you embed valuesconst message = `Navigated to ${url}`;const fullSelector = `[data-testid="${selector}"]`;Template literals (the backtick strings) are used constantly in Playwright — in config files, page objects, and assertions.
What does “embedding a value” mean?
“Embedding a value” means dropping the contents of a variable straight into a string. Inside a template literal, ${ ... } is a placeholder: JavaScript evaluates whatever is between the braces and inserts the result into the text.
const url = 'http://localhost:3000';const message = `Navigated to ${url}`;// message is now: 'Navigated to http://localhost:3000'Without template literals you’d have to join the pieces by hand with +:
const message = 'Navigated to ' + url; // same result, harder to read with many valuesYou can embed any expression, not just a variable — `Total: ${price * qty}` works too.
String methods you’ll reach for. Page text is messy — it often arrives with stray whitespace or inconsistent casing, so cleaning and checking strings is a daily QA task:
const path = 'http://localhost:3000/login';
console.log(path.includes('/login')); // → true (does it contain this text?)console.log(path.startsWith('http')); // → trueconsole.log(path.endsWith('.com')); // → falseconsole.log(' Submit '.trim()); // → 'Submit' (strip surrounding whitespace)console.log('LOGIN'.toLowerCase()); // → 'login' (normalize case before comparing)You’ll also reach for .replace() (e.g. strip $ and commas before Number()) and .split() (break delimited text into an array).
Numbers
Section titled “Numbers”const timeout = 5000; // millisecondsconst retries = 3;const price = 19.99;Watch out for type coercion (the examples below show plain JavaScript behaviour — no TypeScript types):
'5' - 3 // → 2 (JS converts the string to a number for subtraction)'5' + 3 // → '53' (JS converts 3 to a string for addition — a trap!)In practice, use explicit conversion: Number('5') or parseInt('5', 10).
What is “explicit conversion”?
JavaScript sometimes converts types for you automatically — that’s the trap above, where '5' + 3 silently becomes '53'. Explicit conversion means you do the conversion on purpose, so the result is never a surprise:
Number('5') // → 5 (string to number)parseInt('5', 10) // → 5 (string to whole number; the 10 means base-10)String(5) // → '5' (number to string)Boolean('') // → false (empty string to boolean)You’ll reach for this whenever a value arrives as a string but you need a real number or boolean — for example reading process.env variables or text pulled from the page (both covered later in this module).
Booleans
Section titled “Booleans”const isLoggedIn = true;const hasErrors = false;You combine booleans with && (AND) and || (OR) — used constantly in if statements and assertions (! flips one):
console.log(isLoggedIn && !hasErrors); // → true (both sides must be true)console.log(isLoggedIn || hasErrors); // → true (at least one side is true)Two more operators you’ll meet in config and env files:
const port = process.env.PORT ?? '3000'; // ?? — use the right side only when the left is null/undefinedconst isCI = !!process.env.CI; // !! — turn any value into a real true/false (! flips, !! flips twice)// Always use === (strict equality), never =='5' === 5 // → false (different types)'5' == 5 // → true (type coercion — avoid this)In Playwright, booleans appear in assertions (expect(isVisible).toBe(true)) and configuration (headless: true).
Remember: template literals embed values with ${ }; combine conditions with &&/||; clean messy page text with .trim() / .toLowerCase() / .includes().
Arrays and objects
Section titled “Arrays and objects”Arrays
Section titled “Arrays”const browsers = ['chromium', 'firefox', 'webkit'];const prices = [9.99, 14.99, 29.99];
// Common operationsconsole.log(browsers.length); // 3console.log(browsers[0]); // 'chromium'
browsers.push('electron'); // mutates the array: adds to the endconsole.log(browsers); // ['chromium', 'firefox', 'webkit', 'electron']
// filter and map do NOT change the original — they return a NEW array,// so capture the result in a new variableconst withoutWebkit = browsers.filter(b => b !== 'webkit');console.log(withoutWebkit); // ['chromium', 'firefox', 'electron']
const upperCased = browsers.map(b => b.toUpperCase());console.log(upperCased); // ['CHROMIUM', 'FIREFOX', 'ELECTRON']Think of it like a document: push/sort/splice edit the original in place, while filter/map photocopy it and hand you the copy — the original stays untouched.
More array methods you’ll occasionally use
These come up less often in Playwright tests than push, filter, and map, but they’re worth recognising. Watch which ones mutate the original array:
const nums = [3, 1, 2];
console.log(nums.pop()); // removes & returns the LAST item → 2; nums is now [3, 1]console.log(nums.shift()); // removes & returns the FIRST item → 3; nums is now [1]nums.unshift(9); // adds to the FRONT → nums is now [9, 1]nums.splice(1, 1); // removes 1 item starting at index 1 → nums is now [9]
const letters = ['c', 'a', 'b'];letters.sort(); // mutates → ['a', 'b', 'c']letters.reverse(); // mutates → ['c', 'b', 'a']pop, shift, unshift, splice, sort, and reverse all change the original array (unlike filter/map, which return a new one). To keep the original intact, copy it first with the spread operator: const sorted = [...letters].sort();.
Objects
Section titled “Objects”const user = { email: 'customer@test.io', password: 'customer123', role: 'admin',};
// Access propertiesconsole.log(user.email); // 'customer@test.io'console.log(user['role']); // 'admin' — bracket notation for dynamic keysDestructuring
Section titled “Destructuring”Destructuring lets you pull values out of objects and arrays cleanly:
const { email, password } = user;// same as: const email = user.email; const password = user.password;console.log(email, password); // 'customer@test.io' 'customer123'
const [first, second] = browsers;// same as: const first = browsers[0]; const second = browsers[1];console.log(first, second); // 'chromium' 'firefox'You’ll see this in Playwright constantly — for example, when pulling test and expect from the framework:
import { test, expect } from '@playwright/test';Spread operator
Section titled “Spread operator”The spread operator (...) copies or merges objects and arrays:
const defaultOptions = { headless: true, slowMo: 0 };const debugOptions = { ...defaultOptions, headless: false, slowMo: 500 };console.log(debugOptions); // { headless: false, slowMo: 500 }This is used heavily for merging test configs and producing modified test data objects.
Exercise 2: Build and use test data
Section titled “Exercise 2: Build and use test data”Build a small bundle of test data using objects, arrays, the spread operator, and destructuring — no functions yet (those come next).
const validCustomer = { email: 'customer@test.io', password: 'customer123', role: 'customer',};
const products = [ { name: 'Mouse', price: 9.99, category: 'accessories' }, { name: 'Keyboard', price: 29.99, category: 'accessories' }, { name: 'Monitor', price: 199.99, category: 'displays' },];
// 1. Build validAdmin from validCustomer using spread — override only email and role.// 2. accessoryNames — the names of all 'accessories' products (filter + map).// 3. Destructure email and role out of validAdmin.
console.log('Admin:', validAdmin);console.log('Accessories:', accessoryNames);console.log('Destructured:', email, role);Expected output:
Admin: { email: 'admin@test.io', password: 'customer123', role: 'admin' }Accessories: [ 'Mouse', 'Keyboard' ]Destructured: admin@test.io adminSolution
const validAdmin = { ...validCustomer, email: 'admin@test.io', role: 'admin' };const accessoryNames = products.filter(p => p.category === 'accessories').map(p => p.name);const { email, role } = validAdmin;Remember: filter/map return a new array; push/sort/splice mutate the original. Pull values out with destructuring, copy/merge with spread ....
Functions and arrow functions
Section titled “Functions and arrow functions”Regular functions
Section titled “Regular functions”// No parameters — just returns a valuefunction getTimestamp(): number { return Date.now();}
// With a parameterfunction formatPrice(price: number): string { return `$${price.toFixed(2)}`;}The : number annotation on price and the : string after the parens are TypeScript types — they tell the editor what’s allowed in and what comes out. The empty () on getTimestamp means it takes no arguments. You’ll see both everywhere.
Arrow functions
Section titled “Arrow functions”Arrow functions are the shorter, modern syntax — and they’re everywhere in Playwright:
const formatPrice = (price: number): string => `$${price.toFixed(2)}`;
// Multi-line arrow function needs { } and explicit returnconst generateUser = (role: string) => { const timestamp = Date.now(); return { email: `${role}_${timestamp}@test.io`, role };};Every Playwright test uses an arrow function:
test('user can log in', async ({ page }) => { await page.goto('/login'); // ...});When to use which:
- Arrow functions: callbacks, test bodies, short helpers
- Regular
function: class methods (like Page Object methods that usethis)
The implicit return trap
Section titled “The implicit return trap”Single-expression arrow functions return their value implicitly — no return keyword needed:
const double = x => x * 2; // implicit return — returns x * 2const triple = x => { x * 3 }; // ❌ no return — returns undefinedconst triple = x => { return x * 3 }; // ✅ explicit returnReturning an object from a single-expression arrow is the classic version of this trap — the { looks like a function body, so you must wrap the object in parentheses:
const makeUser = role => { email: role }; // ❌ `{ }` is read as a body — returns undefinedconst makeUser = role => ({ email: role }); // ✅ parentheses → returns the objectExercise 3: Write helper functions
Section titled “Exercise 3: Write helper functions”Write these five arrow functions in exercise-3.ts. You’ll use this catalog in task 5:
const catalog = [ { name: 'Mouse', price: 9.99, category: 'accessories' }, { name: 'Monitor', price: 199.99, category: 'displays' },];
// 1. formatPrice(price) — "$19.99" from 19.99 (single expression → implicit return)// 2. isEmail(email) — true if the string contains '@' and '.'// 3. makeUser(role) — { email: `${role}_${Date.now()}@test.io`, role } (multi-line → explicit return)// 4. makeProduct(name, price) — { name, price, category: 'accessories' } (two parameters)// 5. filterByCategory(products, category) — only the items whose category matchesThen call them so the output matches:
console.log(formatPrice(19.99)); // $19.99console.log(isEmail('customer@test.io')); // trueconsole.log(isEmail('not-an-email')); // falseconsole.log(makeUser('admin')); // { email: 'admin_1712345678901@test.io', role: 'admin' }console.log(makeProduct('Keyboard', 29.99)); // { name: 'Keyboard', price: 29.99, category: 'accessories' }console.log(filterByCategory(catalog, 'accessories')); // [ { name: 'Mouse', price: 9.99, category: 'accessories' } ]Hints: template literals, .toFixed(2), .includes(), Date.now(), .filter(), plus a type annotation on each parameter. Note #1 returns directly with no return; #3 and #4 are multi-line, so they need an explicit return.
Solution
const formatPrice = (price: number): string => `$${price.toFixed(2)}`;
const isEmail = (email: string): boolean => email.includes('@') && email.includes('.');
// Returning an object literal from a single-expression arrow needs parentheses:const makeUser = (role: string) => ({ email: `${role}_${Date.now()}@test.io`, role });
const makeProduct = (name: string, price: number) => ({ name, price, category: 'accessories' });
const filterByCategory = ( products: { name: string; price: number; category: string }[], category: string,) => products.filter(p => p.category === category);Remember: a single-expression arrow returns implicitly; multi-line needs { } and return; return an object with => ({ ... }).
Imports and exports (ESM)
Section titled “Imports and exports (ESM)”Playwright uses ES modules (ESM) — the import / export syntax. Every .ts file you write will start with at least one import.
Importing
Section titled “Importing”// Named imports use { } — the names must match the file's named exportsimport { test, expect } from '@playwright/test';import { chromium, firefox } from '@playwright/test'; // import several at once
// Importing your OWN LoginPage file — pick ONE, matching how it's exported:import LoginPage from './pages/LoginPage'; // if LoginPage.ts uses `export default`// import { LoginPage } from './pages/LoginPage'; // if it uses a named `export`Note: when you import your own file, you don’t add the .ts extension in the path.
Exporting
Section titled “Exporting”// LoginPage.ts — Option A: named export (a file can have several)import { Page } from '@playwright/test';
export class LoginPage { constructor(public page: Page) {}}// LoginPage.ts — Option B: default export (only one per file)import { Page } from '@playwright/test';
class LoginPage { constructor(public page: Page) {}}export default LoginPage;Named vs default export — which import goes with which?
- Named export (
export class LoginPage,export const x) → import it with braces:import { LoginPage }. A file can have many named exports, and the import name must match the export (rename withimport { LoginPage as Login }if you need to). - Default export (
export default LoginPage) → import it without braces:import LoginPage. A file can have only one default, and you may call it anything on import.
Mnemonic: braces ⇔ named, no braces ⇔ default. The Playwright framework gives you named exports (test, expect); your Page Object files are often default exports.
If you forget the export keyword, the import will resolve to undefined and you’ll get errors like TypeError: LoginPage is not a constructor.
You may still see CommonJS
Section titled “You may still see CommonJS”Older Node.js and Playwright examples use CommonJS syntax — const { test } = require('@playwright/test') and module.exports = LoginPage. Both styles work in Node.js, but the official Playwright generator and this course use ESM import / export throughout. If you see require() in a tutorial or older repo, treat it as the same idea with different syntax.
Remember: braces = named, no braces = default; one default per file.
Promises and async/await
Section titled “Promises and async/await”This is the most important section for avoiding flaky tests. Missing await is the #1 cause of test failures.
Think of an async operation like ordering coffee: you pay and get a receipt — that is your Promise. await is standing at the counter until the coffee is ready. You have the receipt (Promise) immediately; the result arrives later.
What is a Promise?
Section titled “What is a Promise?”A Promise represents a value that isn’t available yet — it’s the result of an asynchronous operation. Playwright returns Promises from nearly every action:
// page.goto() returns a Promiseconst promise = page.goto('/login'); // starts the navigation but doesn't waitawait pauses and waits
Section titled “await pauses and waits”async function login() { await page.goto('/login'); // waits for navigation to complete await page.fill('#email', '...'); // waits for fill to complete await page.click('button'); // waits for click to complete}Without await, the test moves to the next line before the action finishes:
// ❌ Missing await — test continues immediatelypage.goto('/login');page.fill('#email', 'user@test.io'); // page might not have loaded yet!expect(page).toHaveURL('/dashboard'); // runs before login happens!You can watch the order flip for yourself — this runs on plain Node, no browser:
const log = (label: string) => new Promise<void>(r => setTimeout(() => { console.log(label); r(); }, 50));
async function demo() { log('first'); // ❌ not awaited console.log('second'); // runs before "first" finishes! await log('third'); // ✅ awaited console.log('fourth'); // waits for "third"}demo();// Prints: second, first, third, fourth ← "second" beats "first" because "first" wasn't awaitedThe async keyword
Section titled “The async keyword”Any function that uses await must be declared with async:
// ✅ Correctasync function runTest() { await page.goto('/');}
// Also works as arrow functionconst runTest = async () => { await page.goto('/');};An async function always returns a Promise. If you return 'hello' from an async function, the caller receives Promise<string> — they need to await it too.
Promise.all — parallel actions
Section titled “Promise.all — parallel actions”When you need to wait for multiple things at once:
// Wait for navigation AND something else to happen simultaneouslyconst [response] = await Promise.all([ page.waitForResponse('/api/login'), page.click('button[type="submit"]'),]);This is used for navigation patterns where you click and wait for a network request at the same time.
Exercise 4: Fix broken async code
Section titled “Exercise 4: Fix broken async code”The code below has 6 missing await keywords. Find and fix them.
const fakePage = { goto: (url: string) => new Promise<void>(r => setTimeout(() => { console.log(`Navigated to ${url}`); r(); }, 50)), fill: (selector: string, value: string) => new Promise<void>(r => setTimeout(() => { console.log(`Filled ${selector} with ${value}`); r(); }, 50)), click: (selector: string) => new Promise<void>(r => setTimeout(() => { console.log(`Clicked ${selector}`); r(); }, 50)), waitForSelector: (selector: string) => new Promise<void>(r => setTimeout(() => { console.log(`Waited for ${selector}`); r(); }, 50)), screenshot: () => new Promise<void>(r => setTimeout(() => { console.log('Screenshot taken'); r(); }, 50)),};
async function runTest() { console.log('Starting test...');
fakePage.goto('/auth/login'); console.log('Navigated to login page');
fakePage.waitForSelector('#email'); fakePage.fill('#email', 'customer@test.io'); fakePage.fill('#password', 'customer123'); fakePage.click('button[type="submit"]');
console.log('Login form submitted'); fakePage.screenshot();
console.log('Test complete!');}
runTest();Before running it, predict the output order and jot it down. Then run the broken version — notice the order is wrong — add await, and run again to compare.
Solution
Add await before each of the six fakePage calls:
async function runTest() { console.log('Starting test...');
await fakePage.goto('/auth/login'); console.log('Navigated to login page');
await fakePage.waitForSelector('#email'); await fakePage.fill('#email', 'customer@test.io'); await fakePage.fill('#password', 'customer123'); await fakePage.click('button[type="submit"]');
console.log('Login form submitted'); await fakePage.screenshot();
console.log('Test complete!');}Remember: await every Playwright action — a missing await is the #1 cause of flaky tests.
TypeScript basics
Section titled “TypeScript basics”You’ve already been writing TypeScript throughout this module — every annotated parameter (price: number, role: string) is TypeScript at work. This section pulls together the parts that aren’t just “JS with type annotations”: interfaces, optional properties, and JSDoc.
Why types help
Section titled “Why types help”Types catch bugs before you run the code:
// JavaScript — no error until runtimefunction formatPrice(price) { return `$${price.toFixed(2)}`;}formatPrice('not-a-number'); // crashes at runtime
// TypeScript — error caught immediately in your editorfunction formatPrice(price: number): string { return `$${price.toFixed(2)}`;}formatPrice('not-a-number'); // ❌ TypeScript error: Argument of type 'string' is not assignable to parameter of type 'number'That red squiggle in VS Code is the lesson — you fix the bug before pressing run.
The red squiggle — how types catch the bug Slides — opens in a new tabInterfaces
Section titled “Interfaces”Interfaces describe the shape of an object. Use them for test data:
interface User { email: string; password: string; role: 'admin' | 'customer'; // union type — only these two values}
interface Product { name: string; price: number; category: string;}
const user: User = { email: 'customer@test.io', password: 'customer123', role: 'customer',};Optional properties
Section titled “Optional properties”Add ? to mark a property as optional:
interface ShippingDetails { name: string; address: string; city: string; zip: string; country?: string; // optional — may be undefined}JSDoc — TypeScript benefits without .ts files
Section titled “JSDoc — TypeScript benefits without .ts files”If you ever need type checking in a plain .js file, use JSDoc annotations. VS Code reads them and gives you auto-complete and type errors — same as TypeScript, with no build step.
/** * @param {number} price * @returns {string} */const formatPrice = (price) => `$${price.toFixed(2)}`;
class LoginPage { /** @param {import('@playwright/test').Page} page */ constructor(page) { this.page = page; }}You won’t need this often — the course uses .ts files — but you’ll see it in older codebases.
Remember: types catch bugs in the editor before you run; interfaces describe an object’s shape.
Reading errors and stack traces
Section titled “Reading errors and stack traces”When a test fails, the error message tells you exactly what went wrong — if you know how to read it.
Anatomy of a Playwright failure
Section titled “Anatomy of a Playwright failure” 1) [chromium] › login.spec.ts:10:3 › admin can login to dashboard ───────────
TypeError: Cannot read properties of undefined (reading 'click')
16 | async login(email: string, password: string) { 17 | await this.emailInput.fill(email); > 18 | await this.loginButton.click(); | ^ 19 | }
at LoginPage.login (tests/pages/LoginPage.ts:18:28) at tests/login.spec.ts:15:12Read it top to bottom:
- Test header —
[chromium] › login.spec.ts:10:3 › admin can login to dashboardshows the browser project, the test file with the line where the test starts, and the test name. - Error type and message —
TypeError: Cannot read properties of undefined (reading 'click'). Something isundefinedand you tried to call.click()on it. - Code frame — the lines around the failure. The
>arrow on line 18 marks the failing line; the^caret pinpoints the exact expression that broke — here,this.loginButtonisundefined. - Stack lines — where each call came from in your own files. The top of the stack is where the error fired; below that is what called it.
Ignore node_modules stack lines — those are Playwright’s internals. Focus on your own files.
Common errors and what they mean
Section titled “Common errors and what they mean”| Error | Meaning | Fix |
|---|---|---|
Cannot read properties of null (reading 'click') | A variable or handle is null — an expected object was not found | Check the variable is properly initialised, check your selector |
Cannot read properties of undefined | Variable hasn’t been assigned | Check imports, check variable scope |
ReferenceError: X is not defined | Variable not declared in scope | Check spelling, check where it’s declared |
TypeError: X is not a function | Imported wrong thing or forgot export | Check console.log(typeof X) |
| Wrong output order | Missing await | Add await before async calls |
Exercise 5: Read a stack trace
Section titled “Exercise 5: Read a stack trace”Read this error and answer the questions below:
1) [chromium] › login.spec.ts:10:3 › admin can login to dashboard ───────────
TypeError: Cannot read properties of undefined (reading 'click')
16 | async login(email: string, password: string) { 17 | await this.emailInput.fill(email); > 18 | await this.loginButton.click(); | ^ 19 | }
at LoginPage.login (tests/pages/LoginPage.ts:18:28) at tests/login.spec.ts:15:12Questions:
- Where did the error actually occur? (file, line, column)
- What is the name of the test that failed?
- What does “Cannot read properties of undefined” mean — what is undefined here?
- What would you check first to fix this?
Write your answers as comments in exercise-5.ts.
Solution
- Where:
tests/pages/LoginPage.ts, line 18, column 28 — the> 18 |line, with the^caret underthis.loginButton. - Failed test: “admin can login to dashboard” (from
login.spec.ts:10:3). - What’s undefined:
this.loginButton— you called.click()on it, but it was never assigned, so it’sundefined. - Check first: how
loginButtonis set up in theLoginPageclass — most likely a missing or misspelled locator assignment in the constructor (or its selector matched nothing).
Remember: read a trace top-to-bottom — error type → the > code frame → the stack into your files (ignore node_modules).
Environment variables
Section titled “Environment variables”Never hardcode URLs, passwords, or tokens in test files. Use environment variables instead.
Reading from process.env
Section titled “Reading from process.env”const baseURL = process.env.BASE_URL;const adminEmail = process.env.ADMIN_EMAIL;Defaults and required variables
Section titled “Defaults and required variables”function getConfig() { const baseUrl = process.env.BASE_URL ?? 'http://localhost:3000'; // default value
const adminEmail = process.env.ADMIN_EMAIL; if (!adminEmail) { throw new Error('ADMIN_EMAIL environment variable is required'); }
const isCI = process.env.CI === 'true'; // convert string to boolean
return { baseUrl, adminEmail, isCI };}.env files
Section titled “.env files”Make a .env file at the project root:
BASE_URL=http://localhost:3000ADMIN_EMAIL=admin@test.ioADMIN_PASSWORD=admin123CI=falseThen load it at the top of your config:
import 'dotenv/config';// Now process.env.BASE_URL, process.env.ADMIN_EMAIL etc. are availableAlways add .env to .gitignore — never commit secrets to version control.
You’ll see this pattern in playwright.config.ts:
import { defineConfig } from '@playwright/test';import 'dotenv/config';
export default defineConfig({ use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', }, forbidOnly: !!process.env.CI, // !! converts string to boolean});Exercise 6: Environment variables
Section titled “Exercise 6: Environment variables”Implement getConfig() that reads from process.env:
// Simulate dotenvprocess.env.BASE_URL = 'http://localhost:3000';process.env.ADMIN_EMAIL = 'admin@test.io';process.env.ADMIN_PASSWORD = 'admin123';process.env.CI = 'true';
function getConfig() { // Return: // - baseUrl (from BASE_URL, default 'http://localhost:3000') // - adminEmail (from ADMIN_EMAIL, throw Error if missing) // - adminPassword (from ADMIN_PASSWORD, throw Error if missing) // - isCI (from CI, convert string 'true' → boolean true)}
console.log(getConfig());Bonus: delete process.env.ADMIN_EMAIL and handle the error with a try/catch.
Solution
function getConfig() { const baseUrl = process.env.BASE_URL ?? 'http://localhost:3000';
const adminEmail = process.env.ADMIN_EMAIL; if (!adminEmail) throw new Error('ADMIN_EMAIL is required');
const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword) throw new Error('ADMIN_PASSWORD is required');
const isCI = process.env.CI === 'true';
return { baseUrl, adminEmail, adminPassword, isCI };}Remember: never hardcode secrets; read them from process.env (with a ?? default) and keep .env gitignored.
Key takeaways
Section titled “Key takeaways”Four rules to write on a sticky note:
constby default,letto rebind, nevervarawaitevery Playwright action — missingawait= flaky test- Read stack traces top to bottom — test header, error message, code frame, then the stack pointing into your files
- Never hardcode secrets — use
process.envand.envfiles
In Module 2, every single concept from this module appears in real Page Object classes.