Skip to content

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


You’ll see two variable keywords in every test file: const and let.

const baseURL = 'http://localhost:3000'; // won't be reassigned
let retryCount = 0; // will be reassigned later

The rule: use const by default. Switch to let only when you need to reassign the variable.

// ✅ Correct
const TIMEOUT = 30000;
let attempts = 0;
attempts = attempts + 1; // fine — let allows reassignment
// ❌ This throws an error
const MAX = 5;
MAX = 10; // TypeError: Assignment to constant variable

Important nuance: const does not mean immutable. You can still change object properties:

const config = { retries: 1 };
config.retries = 2; // ✅ this is allowed
config = { retries: 3 }; // ❌ this is not — you can't rebind the reference

As for var — it exists but causes confusing scope bugs. Every modern linter flags it. You will never need it in a Playwright project.

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 defined

Think 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.


How to Run Exercises Slides — opens in a new tab

All exercises run on plain Node.js — no browser needed.

  1. Open the Playwright project you set up in Module 0 in VS Code.
  2. Create an exercises/mod1/ folder in the project root (right-click in the Explorer → New Folder).
  3. Create one file per exercise: exercise-1.ts, exercise-2.tsexercise-6.ts.
  4. Open the integrated terminal (Ctrl + `) and confirm you are in the project root.
  5. 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 baseURL with const
  • Fix the TIMEOUT reassignment — use a new variable (e.g. TIMEOUT_EXTENDED)
  • Fix the user = {...} line — assigning to a const is an error; declare it with let instead
  • Fix the message scope issue — move console.log inside the block
  • Run the fixed file and verify no errors

Expected output:

Hello!
Exercise 1 done
Solution
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.


These three primitive types cover nearly everything you’ll touch in a test file.

const url = 'http://localhost:3000/login';
const selector = '#email-input';
// Template literals (backticks) — use these whenever you embed values
const 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 values

You 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')); // → true
console.log(path.endsWith('.com')); // → false
console.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).

const timeout = 5000; // milliseconds
const 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).

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/undefined
const 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().


const browsers = ['chromium', 'firefox', 'webkit'];
const prices = [9.99, 14.99, 29.99];
// Common operations
console.log(browsers.length); // 3
console.log(browsers[0]); // 'chromium'
browsers.push('electron'); // mutates the array: adds to the end
console.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 variable
const 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();.

const user = {
email: 'customer@test.io',
password: 'customer123',
role: 'admin',
};
// Access properties
console.log(user.email); // 'customer@test.io'
console.log(user['role']); // 'admin' — bracket notation for dynamic keys

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';

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.


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 admin
Solution
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 ....


// No parameters — just returns a value
function getTimestamp(): number {
return Date.now();
}
// With a parameter
function 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 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 return
const 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 use this)

Single-expression arrow functions return their value implicitly — no return keyword needed:

const double = x => x * 2; // implicit return — returns x * 2
const triple = x => { x * 3 }; // ❌ no return — returns undefined
const triple = x => { return x * 3 }; // ✅ explicit return

Returning 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 undefined
const makeUser = role => ({ email: role }); // ✅ parentheses → returns the object

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 matches

Then call them so the output matches:

console.log(formatPrice(19.99)); // $19.99
console.log(isEmail('customer@test.io')); // true
console.log(isEmail('not-an-email')); // false
console.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 => ({ ... }).


Playwright uses ES modules (ESM) — the import / export syntax. Every .ts file you write will start with at least one import.

// Named imports use { } — the names must match the file's named exports
import { 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.

// 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 with import { 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.

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.


async/await — Why It Matters Slides — opens in a new tab

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.

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 Promise
const promise = page.goto('/login'); // starts the navigation but doesn't wait
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 immediately
page.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 awaited

Any function that uses await must be declared with async:

// ✅ Correct
async function runTest() {
await page.goto('/');
}
// Also works as arrow function
const 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.

When you need to wait for multiple things at once:

// Wait for navigation AND something else to happen simultaneously
const [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.


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.


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.

Types catch bugs before you run the code:

// JavaScript — no error until runtime
function formatPrice(price) {
return `$${price.toFixed(2)}`;
}
formatPrice('not-a-number'); // crashes at runtime
// TypeScript — error caught immediately in your editor
function 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 tab

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',
};

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.


When a test fails, the error message tells you exactly what went wrong — if you know how to read it.

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:12

Read it top to bottom:

  1. Test header[chromium] › login.spec.ts:10:3 › admin can login to dashboard shows the browser project, the test file with the line where the test starts, and the test name.
  2. Error type and messageTypeError: Cannot read properties of undefined (reading 'click'). Something is undefined and you tried to call .click() on it.
  3. 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.loginButton is undefined.
  4. 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.

ErrorMeaningFix
Cannot read properties of null (reading 'click')A variable or handle is null — an expected object was not foundCheck the variable is properly initialised, check your selector
Cannot read properties of undefinedVariable hasn’t been assignedCheck imports, check variable scope
ReferenceError: X is not definedVariable not declared in scopeCheck spelling, check where it’s declared
TypeError: X is not a functionImported wrong thing or forgot exportCheck console.log(typeof X)
Wrong output orderMissing awaitAdd await before async calls

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:12

Questions:

  1. Where did the error actually occur? (file, line, column)
  2. What is the name of the test that failed?
  3. What does “Cannot read properties of undefined” mean — what is undefined here?
  4. What would you check first to fix this?

Write your answers as comments in exercise-5.ts.

Solution
  1. Where: tests/pages/LoginPage.ts, line 18, column 28 — the > 18 | line, with the ^ caret under this.loginButton.
  2. Failed test: “admin can login to dashboard” (from login.spec.ts:10:3).
  3. What’s undefined: this.loginButton — you called .click() on it, but it was never assigned, so it’s undefined.
  4. Check first: how loginButton is set up in the LoginPage class — 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).


Never hardcode URLs, passwords, or tokens in test files. Use environment variables instead.

const baseURL = process.env.BASE_URL;
const adminEmail = process.env.ADMIN_EMAIL;
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 };
}

Make a .env file at the project root:

BASE_URL=http://localhost:3000
ADMIN_EMAIL=admin@test.io
ADMIN_PASSWORD=admin123
CI=false

Then load it at the top of your config:

import 'dotenv/config';
// Now process.env.BASE_URL, process.env.ADMIN_EMAIL etc. are available

Always add .env to .gitignore — never commit secrets to version control.

You’ll see this pattern in playwright.config.ts:

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
});

Implement getConfig() that reads from process.env:

// Simulate dotenv
process.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.


Four rules to write on a sticky note:

  1. const by default, let to rebind, never var
  2. await every Playwright action — missing await = flaky test
  3. Read stack traces top to bottom — test header, error message, code frame, then the stack pointing into your files
  4. Never hardcode secrets — use process.env and .env files

In Module 2, every single concept from this module appears in real Page Object classes.