Playwright with TypeScript

A two-part track: first build a solid JavaScript / TypeScript base, then learn Playwright end-to-end — from environment setup to data-driven tests in CI. Tick the box on each section to track how much you've covered.

Part A

JavaScript & TypeScript Basics

Playwright tests are just TypeScript code. Before you write a single test(...) block, you should be comfortable with variables, data types, control flow, functions, and modules. Tick each section as you cover it — your progress is saved locally in your browser.

Hello world

Open VS Code at an empty folder (e.g. D:\JS Fundamentals) and initialize a Node project:

D:\JS Fundamentals> npm init
# keep pressing enter, say yes at the end -- package.json is created

# create a folder for lessons, then a file
# lessons/lesson_1.js
console.log("Hello world !");

# run it
PS D:\JS Fundamentals\Lessons> node lesson_1.js
# output: Hello world !

Variables

A variable holds one value (or an object — see later). var is the old-style declaration, let is the modern, block-scoped form. Prefer let.

var firstName = "John";
let lastName = "Smith";
console.log(firstName);

// declare now, assign later
var age, dateOfBirth, sex;
age = 5;
sex = "Male";
console.log(age);  // 5
age = 6;
console.log(age);  // 6

Constants

Use const when the binding should not be reassigned. Reassigning throws TypeError: Assignment to constant variable.

const occupation = "engineer";
occupation = "driver";  // TypeError: Assignment to constant variable.
console.log(occupation);

Data types

JavaScript has dynamic types. TypeScript adds static typing on top.

var middleName    = "David";     // string
var yearInService = 5;            // number
var isHeMarried   = false;        // boolean
var yearInMarriage = null;        // null (intentionally empty)
var numOfCars     = undefined;    // undefined (not assigned)

TypeScript equivalent:

let middleName: string = "David";
let yearInService: number = 5;
let isHeMarried: boolean = false;
let yearInMarriage: null = null;
let numOfCars: undefined = undefined;

Concatenation

Glue strings together with +. Readable for two or three pieces; gets noisy beyond that — use interpolation below for anything bigger.

var item_name = "coffee";
var item_price = 50;
console.log("the price of your " + item_name + " is " + item_price + " dollars");
// the price of your coffee is 50 dollars

Interpolation (template literals)

Use backticks (`) — the key under Esc, left of 1. Embed expressions with ${ ... }.

var item_name = "tea";
var item_price = 5;
var msg = `my ${item_name} price is ${item_price} dollars`;
console.log(msg);
// my tea price is 5 dollars

Objects

An object holds multiple named values. Read / write fields with dot or bracket notation.

var customer = { firstName: "John", lastName: "Smith" };

// dot notation
console.log(customer.firstName);  // John
customer.firstName = "Mike";

// bracket notation
console.log(`${customer["firstName"]} ${customer["lastName"]}`);
customer["lastName"] = "Brown";

Arrays

Ordered, zero-indexed list.

var cars = ["Volvo", "Toyota", "Tesla"];
console.log(cars[1]);  // Toyota
console.log(cars.length);  // 3

Objects with arrays

Objects can hold arrays as values — the most common shape for real data.

var customer = {
    firstName: "John",
    lastName: "Smith",
    cars: ["Volvo", "Toyota", "Tesla"]
};
console.log(customer.cars[1]);  // Toyota

Relational operators

Compare two values. Result is always true or false.

console.log(10 < 75);   // true
console.log(10 > 75);   // false
console.log(10 <= 10);  // true
console.log(10 >= 20);  // false
  • < — less than
  • > — greater than
  • <= — less than or equal
  • >= — greater than or equal

Equality operators

Loose (==) compares value only, coercing types. Strict (===) compares value and type. Always prefer ===.

let x: any = 1;

console.log(x == "1");   // true  -- value match (loose)
console.log(x === "1");  // false -- type differs (strict)
console.log(x === 1);    // true

Logical operators

console.log(true && true);   // true   (AND)
console.log(true || false);   // true   (OR)
console.log(!true);           // false  (NOT)

Conditional statements

let hour = 6;

if (hour >= 6 && hour < 12) {
    console.log("Good morning");
} else if (hour >= 12 && hour < 18) {
    console.log("Good afternoon");
} else {
    console.log("Good evening");
}

for loop

for (let i = 0; i < 5; i++) {
    console.log(i);  // 0 1 2 3 4
}

for...of

Walk through every item of an iterable (array, string, set, etc.).

let cars = ["Volvo", "Toyota", "Tesla"];
for (let car of cars) {
    console.log(car);
}

forEach

Array method that runs a callback per element. car is the iterator.

cars.forEach(car => console.log(car));

Loop break

for (let car of cars) {
    if (car === "Toyota") {
        break;
    }
    console.log(car);  // prints only "Volvo"
}

Functions

function hello() {
    console.log("Hello");
}
hello();

Function with argument

function printName(name: string) {
    console.log(name);
}
printName("Wasim");

Return function

function multiplyBy2(num: number): number {
    return num * 2;
}
console.log(multiplyBy2(5));  // 10

Arrow function

Shorter syntax. No own this binding (matters in callbacks).

const hello = () => {
    console.log("Hello");
};

// concise body returns automatically
const square = (n: number) => n * n;
console.log(square(4));  // 16

Import / Export

Split code across files. Export from one module, import into another.

// helper.ts
export function printAge(age: number) {
    console.log(`Age is ${age}`);
}

// main.ts
import { printAge } from "./helper";
printAge(30);

TypeScript extras you'll meet in Playwright

  • Type annotationslet name: string, const count: number = 0.
  • Interfaces — describe object shapes: interface User { id: number; name: string; }
  • Async / await — almost every Playwright call returns a Promise: await page.goto(url).
  • Optional chaininguser?.address?.city returns undefined instead of throwing.
  • Nullish coalescingconst port = process.env.PORT ?? 3000;
Part B

Playwright with TypeScript

Playwright docs

Playwright is a Node.js end-to-end testing library by Microsoft. It drives Chromium, Firefox, and WebKit through a single API, ships with auto-waiting, network interception, video / trace recording, and parallel execution out of the box.

Setup · Node.js

Node.js is a runtime that runs JavaScript / TypeScript outside the browser. Playwright is published as a Node package, so Node is the first thing you install.

  1. Open nodejs.org/en/download.
  2. Click Windows and pick the x64 installer (Intel / AMD chips).
  3. Run the installer with default options. Verify in a new terminal:
node -v   # e.g. v20.11.1
npm -v    # e.g. 10.2.4

Setup · VS Code (+ Playwright extension)

Download Visual Studio Code (not Visual Studio — they are different products) from code.visualstudio.com.

Open VS Code → top menu Terminal → New Terminal and verify Node:

node -v
npm -v
# If npm fails on Windows with a script-execution policy error, open PowerShell as user and run:
# Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

Install the official Playwright extension: Playwright Test for VSCode (v1.1.17+). After installation a test-runner icon appears in the activity bar — from there you can run individual tests, see results, manage projects, and record new tests.

Setup · Git

Search Google for “Git SCM”Git for Windows → install with defaults. Verify:

git -v
# git version 2.45.1.windows.1

Clone the test application

We'll use a public practice app to learn against.

  1. Go to github.com, search pw-practice.
  2. Open the repo, click Code, and copy the HTTPS clone URL.
  3. Create a folder on your desktop (e.g. D:\pw-practice) and open a terminal in it.
D:\pw-practice> git clone https://github.com/bondar-artem/pw-practice-app.git
Cloning into 'pw-practice-app'...
Receiving objects: 100% (8166/8166), 14.70 MiB
Resolving deltas: 100% (5558/5558), done.

D:\pw-practice> cd pw-practice-app

Run the test app

Open the folder in VS Code (File → Open Folder → D:\pw-practice\pw-practice-app) and install dependencies.

cd D:\pw-practice\pw-practice-app

# 1. Clean npm cache (only if first install fails)
npm cache clean --force

# 2. Install dependencies (force flag handles peer-dep conflicts in this demo app)
npm install --force

# 3. Start the app
npm start
# -> http://localhost:4200/ — "compiled successfully"

# Stop the app: Ctrl + C in the terminal
# Restart: npm start (keep the terminal open while testing)

Troubleshooting: if npm install stalls, delete node_modules + the project folder, re-clone, and retry. A computer restart sometimes clears stuck file handles on Windows.

Init a Playwright project

In a separate folder (so the test code is its own project) initialize npm and add Playwright:

D:\pw-tests> npm init -y
D:\pw-tests> npm init playwright@latest
# answer the prompts:
#   - TypeScript
#   - tests folder name: tests
#   - add GitHub Actions workflow? no (for now)
#   - install Playwright browsers? yes

# Playwright also installs browser binaries (Chromium, Firefox, WebKit).

Resulting layout:

pw-tests/
├── tests/
│   └── example.spec.ts
├── tests-examples/
├── playwright.config.ts
├── package.json
└── tsconfig.json

playwright.config.ts

Central config — base URL, timeouts, retries, browser projects, reporters, trace / video / screenshot settings.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['list']],

  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 10_000,
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

First test

// tests/first.spec.ts
import { test, expect } from '@playwright/test';

test('home page shows welcome', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/Pw Practice App/i);

  await page.getByRole('textbox', { name: 'Email' }).fill('test@test.com');
  await page.getByRole('button', { name: 'Submit' }).click();

  await expect(page.getByText('Welcome')).toBeVisible();
});

Run it:

npx playwright test
npx playwright test --headed         # show the browser
npx playwright test --ui             # interactive UI mode
npx playwright show-report           # open last HTML report

Built-in locators

Prefer Playwright's user-facing locators — they auto-wait and survive small DOM changes.

page.getByRole('button', { name: 'Sign in' });
page.getByText('Welcome back');
page.getByLabel('Password');
page.getByPlaceholder('Email address');
page.getByTestId('submit-btn');
page.getByAltText('Profile picture');
page.getByTitle('Close');

Fall back to CSS or XPath only when the user-facing locator does not fit.

XPath & axes

Absolute XPath starts at /html; relative XPath starts with // and is much more stable.

page.locator("//button[text()='Submit']");
page.locator("//input[@name='email']");
page.locator("//ul/li[3]");                        // index
page.locator("//label[normalize-space()='Email']/following-sibling::input");
page.locator("//input[@id='email']/parent::div");

Useful axes: parent, ancestor, following-sibling, preceding-sibling, child, descendant, following, preceding.

CSS locators

page.locator('#login');                  // id
page.locator('.btn-primary');            // class
page.locator('input.form-control');      // tag + class
page.locator('input[name="email"]');     // attribute
page.locator('form > button');           // direct child
page.locator('label + input');           // adjacent sibling
page.locator('li:nth-child(3)');         // pseudo-class
page.locator('li:has(span.tag-new)');    // Playwright :has()
page.locator('.row').filter({ hasText: 'Toyota' });  // Playwright filter

Actions

await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button').dblclick();
await page.getByRole('button').click({ button: 'right' });
await page.getByRole('button').click({ modifiers: ['Shift'] });
await page.locator('#canvas').click({ position: { x: 10, y: 20 } });
await page.getByRole('button').click({ force: true });

await page.getByLabel('Email').fill('wasim@test.com');
await page.getByLabel('Search').type('laptop', { delay: 50 });
await page.getByLabel('Email').clear();
await page.keyboard.press('Enter');

await page.getByLabel('Subscribe').check();
await page.getByLabel('Subscribe').uncheck();
await page.getByLabel('Gender').first().check();   // radio
await page.getByRole('link', { name: 'Home' }).hover();
await page.getByRole('textbox').focus();

Dropdowns

// Native <select>
await page.getByLabel('Country').selectOption('IN');                 // by value
await page.getByLabel('Country').selectOption({ label: 'India' });   // by text
await page.getByLabel('Country').selectOption({ index: 2 });         // by index

// Custom JS dropdown
await page.locator('.dropdown-trigger').click();
await page.locator('.dropdown-item').filter({ hasText: 'India' }).click();

File upload

await page.getByLabel('Avatar').setInputFiles('fixtures/avatar.png');
// multiple
await page.getByLabel('Docs').setInputFiles(['a.pdf', 'b.pdf']);
// clear
await page.getByLabel('Avatar').setInputFiles([]);

Extract text & iterate tables

const title = await page.locator('h1').textContent();
const visible = await page.locator('h1').innerText();
const value = await page.getByLabel('Email').inputValue();
const all = await page.locator('li').allTextContents();
const href = await page.getByRole('link', { name: 'Docs' }).getAttribute('href');

// Iterate a table
const rows = page.locator('table tbody tr');
const count = await rows.count();
for (let i = 0; i < count; i++) {
    const cells = rows.nth(i).locator('td');
    console.log(await cells.nth(0).textContent(), await cells.nth(1).textContent());
}

Pagination

const next = page.getByRole('button', { name: 'Next' });
const collected: string[] = [];

while (await next.isEnabled()) {
    collected.push(...(await page.locator('.product-name').allTextContents()));
    await next.click();
    await page.waitForLoadState('networkidle');
}
collected.push(...(await page.locator('.product-name').allTextContents()));
console.log('Total:', collected.length);

Date pickers

// Native HTML5 date input
await page.getByLabel('Date').fill('2026-06-24');

// jQuery UI / Bootstrap calendar widget
await page.locator('#date').click();
await page.locator('.ui-datepicker-month').selectOption('5'); // June
await page.locator('.ui-datepicker-year').selectOption('2026');
await page.getByRole('link', { name: '24', exact: true }).click();

Dialogs & frames

// Alert / Confirm / Prompt
page.on('dialog', async dialog => {
    console.log(dialog.type(), dialog.message());
    await dialog.accept('optional prompt answer');
    // or: await dialog.dismiss();
});
await page.getByRole('button', { name: 'Show alert' }).click();

// Frames
const frame = page.frameLocator('iframe#payment');
await frame.getByLabel('Card number').fill('4242424242424242');

Browser context, tabs & popups

// New tab / popup opened by the app
const [popup] = await Promise.all([
    page.waitForEvent('popup'),
    page.getByRole('link', { name: 'Open profile' }).click(),
]);
await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Profile/);

// Two isolated users in one test
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const userA = await ctxA.newPage();
const userB = await ctxB.newPage();

// Save / reuse login state
await context.storageState({ path: 'auth.json' });

Auto-waiting, timeouts & assertions

Before interacting, Playwright waits for the element to be Attached → Visible → Stable → Enabled → Editable. You rarely need explicit sleeps.

await page.waitForURL('**/dashboard');
await page.waitForLoadState('networkidle');
await page.waitForResponse(r => r.url().includes('/api/me') && r.ok());

// Page-level
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle(/Home/);

// Element-level
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByLabel('Email')).toHaveValue('test@test.com');
await expect(page.locator('li')).toHaveCount(5);

// Soft (test continues after failure)
await expect.soft(page.locator('.banner')).toBeHidden();

Codegen — record your first test

npx playwright codegen http://localhost:4200
# A browser opens. Click around -- Playwright writes the spec for you in the Inspector.
# Copy the generated code into tests/, clean it up, add assertions.

Trace viewer, screenshots & flaky tests

await page.screenshot({ path: 'home.png', fullPage: true });
await page.locator('#summary').screenshot({ path: 'summary.png' });

// In config: trace: 'on-first-retry'  -- produces a .zip per failure
npx playwright show-trace trace.zip

// Mark known-bad tests so they don't fail the suite
test.fixme('legacy date picker breaks in webkit', async () => { /* ... */ });
test.slow();  // triples the timeout for the current test

Hooks, groups, tags & parallelism

import { test, expect } from '@playwright/test';

test.describe('Checkout', () => {
    test.beforeAll(async () => { /* seed once */ });
    test.beforeEach(async ({ page }) => { await page.goto('/cart'); });
    test.afterEach(async () => { /* cleanup per test */ });
    test.afterAll(async () => { /* teardown */ });

    test('happy path @smoke', async ({ page }) => {
        await expect(page.getByRole('heading')).toHaveText('Cart');
    });

    test.skip('legacy coupon @regression', async () => { /* ... */ });
});

// Filter by tag:  npx playwright test --grep @smoke
// Workers:        npx playwright test --workers 4
// CI sharding:    npx playwright test --shard=1/3

Data-driven testing

// Inline
for (const term of ['laptop', 'mobile', 'headphones']) {
    test(`search for ${term}`, async ({ page }) => {
        await page.goto('/');
        await page.getByPlaceholder('Search').fill(term);
        await page.keyboard.press('Enter');
        await expect(page.locator('.results')).toBeVisible();
    });
}

// JSON
import data from './fixtures/search.json';
for (const row of data) { /* ...same shape... */ }

// Excel (via xlsx)
import * as XLSX from 'xlsx';
const wb = XLSX.readFile('fixtures/search.xlsx');
const rows = XLSX.utils.sheet_to_json<{ keyword: string }>(wb.Sheets['Sheet1']);
for (const { keyword } of rows) { /* ... */ }

Cheat sheet

# Install
npm init playwright@latest

# Run
npx playwright test
npx playwright test --headed
npx playwright test --ui
npx playwright test --project=chromium
npx playwright test --grep @smoke
npx playwright test path/to/file.spec.ts:42

# Reports / traces
npx playwright show-report
npx playwright show-trace trace.zip

# Record
npx playwright codegen http://localhost:4200

# Navigation
await page.goto(url);
await page.goBack();
await page.reload();

# Locate
page.getByRole / getByText / getByLabel / getByPlaceholder / getByTestId
page.locator('css or //xpath').filter({ hasText })

# Act
.click() .dblclick() .fill() .type() .check() .selectOption()
.setInputFiles() .hover() .focus() .press('Enter')

# Assert
expect(page).toHaveURL(...)  toHaveTitle(...)
expect(locator).toBeVisible() toBeHidden() toHaveText() toHaveValue() toHaveCount()

# Config knobs
testDir, retries, workers, projects, use.baseURL,
use.trace, use.screenshot, use.video, use.actionTimeout