Chapter 1 Β· Foundations

What is Playwright?

Playwright is a modern, open-source end-to-end testing framework by Microsoft that lets you write reliable, fast tests for web applications across all major browsers β€” Chromium, Firefox, and WebKit β€” using a single API.

πŸ‘‹ Coming from Selenium?
Think of Playwright as Selenium evolved. You'll recognize the core idea (automate a browser), but Playwright was built from scratch with modern web apps in mind β€” async-first, no flakiness, no driver downloads, no external servers. Everything you know about test design, POM, and test strategy still applies. The syntax just gets cleaner and faster.

Key Capabilities

🌐
Cross-browser

Single API works with Chromium (Chrome/Edge), Firefox, and WebKit (Safari). Write once, run everywhere.

⚑
Auto-waiting

Playwright automatically waits for elements to be actionable before performing any action. No more Thread.sleep().

πŸ”—
API Testing Built-in

Test REST/GraphQL APIs with the same framework. Mix UI and API tests seamlessly.

πŸ•΅οΈ
Network Interception

Intercept, mock, and modify network requests in real time β€” great for isolating test environments.

πŸ“Έ
Visual Regression

Built-in screenshot comparison for pixel-level visual testing. No extra plugins needed.

πŸ”
Trace Viewer

Rich debugging experience with timeline, screenshots, network, and console logs all in one view.

Playwright's Supported Languages

Playwright supports multiple programming languages. For this guide, we'll use TypeScript/JavaScript which is the primary and most feature-rich option.

TypeScript β˜… JavaScript Python Java .NET / C#
Chapter 2 Β· Foundations

Playwright vs Selenium

You've mastered Selenium β€” here's how Playwright compares on the things that matter most in day-to-day automation.

Feature Selenium Playwright
Architecture WebDriver protocol (W3C) β€” browser drivers required CDP/WebSocket β€” direct browser communication, no separate driver
Setup Install browser + matching driver (chromedriver etc.) npx playwright install β€” downloads everything needed
Auto-waiting Manual β€” explicit waits or custom helpers Built-in β€” waits for actionability before every action
Flakiness High β€” timing issues very common Very low β€” auto-retry assertions, auto-wait
Test runner External (JUnit, TestNG, pytest, NUnit) Built-in (@playwright/test) β€” no separate runner needed
Parallel execution Grid required (Selenium Grid / Selenoid) Native β€” workers per file or per test, no infrastructure needed
API Testing Not built-in β€” need RestAssured / Axios separately Built-in request context β€” same framework
Network mocking Limited (BrowserMob proxy etc.) Native route() API β€” no external tools
Iframes switchTo().frame() β€” easy to lose context frameLocator() β€” contextual, no switching needed
Shadow DOM Needs JavaScript execution Transparent β€” works through shadow roots automatically
Visual testing External tools needed Built-in toMatchSnapshot()
Debugging Standard browser DevTools Trace Viewer, Inspector, step-through debugger
Screenshots/Video Screenshots only (manually) Screenshots + Video + Traces (configurable)
Multiple tabs/contexts Complex β€” manual window handles First-class BrowserContext β€” isolated, easy to manage
Mobile emulation Basic viewport only Full device emulation (geolocation, touch, viewport)
Language support JS, Python, Java, C#, Ruby JS/TS, Python, Java, C# (TypeScript best supported)
πŸ’‘ Key Mindset Shift
In Selenium you think: "Find the element, then wait for it, then interact."
In Playwright you think: "Describe what you want to do β€” Playwright handles the rest."

Playwright's auto-wait eliminates most of the timing logic you wrote in Selenium. Trust it.
Chapter 3 Β· Foundations

Architecture Overview

Understanding Playwright's three-level hierarchy is essential. Everything in Playwright flows through: Browser β†’ BrowserContext β†’ Page.

Playwright Object Hierarchy
PLAYWRIGHT 🌐 Browser (chromium / firefox / webkit) Single browser binary. Chromium uses CDP; Firefox/WebKit use Playwright's own protocol. πŸ“ BrowserContext 1 Like an incognito window β€” isolated cookies, storage, auth πŸ“„ Page 1 Tab / browser tab Has its own URL, DOM πŸ“„ Page 2 Another tab Same context Context-level state βœ“ Cookies & localStorage βœ“ Authenticated sessions βœ“ Permissions (geolocation etc.) βœ“ HTTP headers / proxy πŸ“ BrowserContext 2 Completely separate β€” different user / role πŸ“„ Page 3 Admin user Different cookies πŸ“„ Page 4 Guest user No auth Isolation guarantee βœ— Doesn't share state with Context 1 βœ“ Perfect for multi-user test scenarios βœ“ Can load saved storageState βœ“ Each test gets a fresh context
πŸ”„ Selenium Equivalent
In Selenium: one WebDriver = one browser session with global cookies.
In Playwright: Browser is the process, BrowserContext is the session (like an incognito profile), Page is a tab. This lets you run multiple isolated sessions in the same browser process β€” great for testing multi-user flows.

How Playwright Communicates with Browsers

@playwright/test Node.js process Your test code runs here WebSocket protocol Browser Driver Bundled with Playwright (no manual install) Native protocol Browser πŸ”΅ Chromium (Chrome/Edge) 🦊 Firefox 🧭 WebKit (Safari) ⚑ Unlike Selenium, no separate ChromeDriver/GeckoDriver needed β€” Playwright bundles its own patched browsers
Chapter 4 Β· Foundations

Installation & Setup

Prerequisites

ℹ️ Requirements
Node.js 18+ (LTS recommended). Check with node --version. That's it β€” no browser drivers, no Selenium Grid, no separate test runner to install.

Quick Start (Recommended)

bashTerminal
# Create a new Playwright project (interactive setup wizard)
npm init playwright@latest

# Or add to an existing project
npm install --save-dev @playwright/test

# Install browsers (Chromium, Firefox, WebKit)
npx playwright install

# Install only specific browsers
npx playwright install chromium
npx playwright install chromium firefox

What the Setup Wizard Creates

textProject Structure
my-project/
β”œβ”€β”€ tests/                      # Your test files go here
β”‚   └── example.spec.ts
β”œβ”€β”€ tests-examples/            # Example tests from Playwright
β”‚   └── demo-todo-app.spec.ts
β”œβ”€β”€ playwright.config.ts        # Main configuration file
β”œβ”€β”€ package.json
└── .github/
    └── workflows/
        └── playwright.yml      # CI/CD workflow (GitHub Actions)

Running Tests

bashTerminal
# Run all tests (headless by default)
npx playwright test

# Run with visible browser (headed mode)
npx playwright test --headed

# Run specific file
npx playwright test tests/login.spec.ts

# Run tests matching a title
npx playwright test -g "should login successfully"

# Run on specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox

# Debug mode (opens Playwright Inspector)
npx playwright test --debug

# Open HTML test report
npx playwright show-report

VS Code Extension (Highly Recommended)

Install the Playwright Test for VS Code extension by Microsoft. It gives you:

bashInstall via CLI
code --install-extension ms-playwright.playwright
Chapter 5 Β· Foundations

Your First Test

Let's write a complete test from scratch and understand every line.

TypeScripttests/login.spec.ts
// 1. Import the test runner and assertion library
import { test, expect } from '@playwright/test';

// 2. A test file can have multiple test() blocks
// test() takes: (name, async function with { page })
test('user can log in successfully', async ({ page }) => {

  // 3. Navigate to a URL
  await page.goto('https://example.com/login');

  // 4. Locate elements (Playwright's recommended approach)
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret123');

  // 5. Click a button by its accessible role + name
  await page.getByRole('button', { name: 'Sign in' }).click();

  // 6. Assert the result
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

test('shows error with invalid credentials', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByLabel('Email').fill('bad@user.com');
  await page.getByLabel('Password').fill('wrong');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('alert')).toContainText('Invalid credentials');
});

Test Anatomy

test() test function 'test name' Descriptive test title async ({ page, ... }) Fixtures injected here { navigate, act, assert } The 3-step test body Standard "Arrange β†’ Act β†’ Assert" pattern navigate() = arrange actions = act expect() = assert Every test is async β€” always use await before Playwright calls

Generating Tests with Codegen (Great Starting Point!)

bashTerminal
# Record your interactions and generate test code automatically
npx playwright codegen https://example.com

# Record and save to file
npx playwright codegen --target typescript -o tests/recorded.spec.ts https://example.com

# Record for a specific viewport (mobile)
npx playwright codegen --viewport-size="375,667" https://example.com
πŸ’‘ Codegen Tip
Codegen is a great way to quickly generate locators and get the shape of a test. Always clean up the generated code β€” it won't pick the most resilient locators automatically, but it saves typing and gives you a working starting point.
Chapter 6 Β· Core Concepts

Locators & Selectors

Locators are how Playwright finds elements on the page. Playwright strongly recommends using semantic, user-visible locators over CSS/XPath β€” they're more resilient and closer to how users interact with your app.

πŸ”„ Selenium Comparison
In Selenium: driver.findElement(By.xpath("//button[@type='submit']"))
In Playwright: page.getByRole('button', { name: 'Submit' })

The Playwright version is shorter, more readable, and won't break if the DOM structure changes β€” it finds the button the same way a screen reader would.

The Locator Priority Order (Best to Worst)

1
getByRole() β€” Most Recommended

Finds elements by ARIA role and accessible name. Mirrors how screen readers and users perceive the page. Most resilient to HTML changes.

2
getByLabel() β€” For Form Inputs

Finds form fields by their associated <label> text. Works with aria-label and aria-labelledby too.

3
getByPlaceholder() β€” For Input Fields

Finds inputs by their placeholder attribute text.

4
getByText() β€” Visible Text Content

Finds elements by their visible text content. Great for buttons, headings, paragraphs.

5
getByAltText() / getByTitle() β€” Media & Tooltip Elements

For images (alt text) and elements with title attributes.

6
getByTestId() β€” Test-Specific Attributes

Uses data-testid (or custom attribute). Requires adding attributes to your app code, but is very stable.

7
locator('css') / locator('xpath') β€” Escape Hatch

When none of the above work. CSS is preferred over XPath. Keep these specific and as short as possible.

Locator Examples

TypeScriptLocator Examples
// ── getByRole ──────────────────────────────────────────────────
page.getByRole('button');                      // any button
page.getByRole('button', { name: 'Submit' });   // button with text "Submit"
page.getByRole('link', { name: 'Home' });       // <a> with text "Home"
page.getByRole('checkbox');                   // checkbox input
page.getByRole('heading', { name: 'Products' });// h1-h6 with text
page.getByRole('textbox', { name: 'Search' });  // input type=text
page.getByRole('listitem');                   // <li> elements
page.getByRole('row');                        // table rows

// ── getByLabel ─────────────────────────────────────────────────
page.getByLabel('Email address');             // <input> with <label>Email address</label>
page.getByLabel('Subscribe to newsletter');   // checkbox with label

// ── getByPlaceholder ───────────────────────────────────────────
// Use when there is no label β€” finds by placeholder attribute
page.getByPlaceholder('Search products...');
page.getByPlaceholder('Enter your email');
// ⚠️ Prefer getByLabel when a label exists β€” placeholder is a fallback

// ── getByAltText / getByTitle ──────────────────────────────────
page.getByAltText('Company logo');            // <img alt="Company logo">
page.getByTitle('Close dialog');             // element with title="Close dialog"

// ── getByText ──────────────────────────────────────────────────
// ⚠️ Default is SUBSTRING match (case-sensitive), NOT exact!
page.getByText('Welcome back, John');                   // substring match (default)
page.getByText('Welcome back, John', { exact: true });  // exact full-string match
page.getByText('Welcome');                              // matches any element containing 'Welcome'
page.getByText(/welcome/i);                             // regex (case insensitive)

// ── getByTestId ─────────────────────────────────────────────────
page.getByTestId('submit-button');           // data-testid="submit-button"

// ── locator (CSS / XPath fallback) ─────────────────────────────
page.locator('.btn-primary');               // CSS class
page.locator('#login-form');                 // CSS ID
page.locator('input[type="email"]');         // CSS attribute
page.locator('//button[@type="submit"]');    // XPath

Chaining & Filtering Locators

TypeScriptAdvanced Locator Patterns
// ── Chaining: narrow scope to a parent element ─────────────────
// Find the Submit button INSIDE a specific form
page.locator('#checkout-form').getByRole('button', { name: 'Submit' });

// ── filter(): narrow a locator result by text or inner locator ─
// Find all list items, but only the ones containing "urgent"
page.getByRole('listitem').filter({ hasText: 'urgent' });

// Find table rows that contain a specific cell value
page.getByRole('row').filter({ hasText: 'John Doe' });

// ── nth(): picking from a list ─────────────────────────────────
page.getByRole('listitem').first();           // first item
page.getByRole('listitem').last();            // last item
page.getByRole('listitem').nth(2);            // 0-indexed, so this is 3rd

// ── and(): combine locators (must match BOTH) ──────────────────
page.getByRole('button').and(page.locator('.primary'));

// ── or(): match either locator ─────────────────────────────────
page.getByRole('button', { name: 'OK' }).or(page.getByRole('button', { name: 'Accept' }));

// ── frameLocator(): interact with iframes ─────────────────────
const frame = page.frameLocator('iframe[title="Payment form"]');
await frame.getByLabel('Card number').fill('4111111111111111');
⚠️ Locator vs ElementHandle
Playwright has two ways to reference elements: Locators (recommended) and ElementHandles (legacy). Always use Locators. They automatically re-query the DOM when you interact with them, handle stale element issues, and support auto-waiting. ElementHandles are like Selenium's WebElement β€” they can go stale.
Chapter 7 Β· Core Concepts

Actions (Interactions)

Actions are how you interact with the page β€” clicking, typing, selecting, uploading files, and more. Every action auto-waits for the element to be ready before executing.

Mouse & Keyboard Actions

TypeScriptCommon Actions
// ── Click variants ─────────────────────────────────────────────
await locator.click();                      // regular click
await locator.click({ button: 'right' });   // right-click
await locator.dblclick();                  // double-click
await locator.click({ modifiers: ['Shift'] });  // Shift+Click
await locator.click({ position: { x: 10, y: 20 } }); // click at offset

// ── Text input ─────────────────────────────────────────────────
await locator.fill('Hello World');          // clears and types (recommended)
await locator.clear();                       // clear the field
await locator.pressSequentially('Hello');  // types char by char (for fields with JS events)

// ── Keyboard ───────────────────────────────────────────────────
await locator.press('Enter');              // press a key
await locator.press('Control+A');           // key combo
await locator.press('ArrowDown');           // arrow keys
await locator.press('Tab');                // tab navigation
await page.keyboard.type('Hello World');   // global keyboard (use when no focus)

// ── Select dropdowns ──────────────────────────────────────────
await locator.selectOption('option-value');   // by value attribute
await locator.selectOption({ label: 'United States' }); // by visible text
await locator.selectOption(['val1', 'val2']); // multi-select

// ── Checkboxes & Radio buttons ────────────────────────────────
await locator.check();                      // check a checkbox
await locator.uncheck();                    // uncheck
await locator.setChecked(true);             // set to specific state

// ── Mouse operations ──────────────────────────────────────────
await locator.hover();                      // mouse over
await locator.focus();                      // focus the element
await locator.blur();                       // remove focus

// ── Drag and drop ────────────────────────────────────────────
await page.dragAndDrop('#source', '#target');
await locator.dragTo(page.locator('#target'));

// ── File upload ────────────────────────────────────────────────
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');
await page.getByLabel('Upload').setInputFiles(['file1.jpg', 'file2.jpg']);

// ── Screenshots ───────────────────────────────────────────────
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ path: 'full.png', fullPage: true });
await locator.screenshot();                // screenshot of just the element

// ── Scrolling ─────────────────────────────────────────────────
await locator.scrollIntoViewIfNeeded();     // scroll element into view
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));

// ── Mobile tap (for touch events) ─────────────────────────────
await locator.tap();                       // touch tap (use with mobile device emulation)

Handling Dialogs (alert / confirm / prompt)

TypeScriptDialog Handling
// ── Register handler BEFORE the action that triggers the dialog ─
// ⚠️ If you don't handle a dialog, Playwright auto-dismisses it

// Accept all dialogs (alert / confirm / beforeunload)
page.on('dialog', dialog => dialog.accept());

// Inspect and selectively handle
page.on('dialog', async dialog => {
  console.log(dialog.type());     // 'alert' | 'confirm' | 'prompt' | 'beforeunload'
  console.log(dialog.message());  // the dialog text
  if (dialog.type() === 'confirm') {
    await dialog.accept();          // click OK
  } else {
    await dialog.dismiss();         // click Cancel
  }
});

// Handle a prompt() and enter a value
page.on('dialog', dialog => dialog.accept('My typed value'));

Handling New Tabs & Popups

TypeScriptNew Tab / Popup Handling
// ── Wait for a new page to open (link with target="_blank") ────
// IMPORTANT: Set up the waitForEvent BEFORE clicking the link
const pagePromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open in new tab' }).click();
const newPage = await pagePromise;     // the new tab's Page object
await newPage.waitForLoadState();
await expect(newPage).toHaveTitle(/Expected Title/);

// ── Handle window.open() popups ────────────────────────────────
const [popup] = await Promise.all([
  context.waitForEvent('page'),         // listen on context level
  page.getByRole('button', { name: 'Share' }).click(),
]);
await popup.waitForLoadState();
// Now interact with popup like any page

Auto-waiting: What Actually Happens

All 6 actionability checks when you call locator.click() 1. Attached Exists in DOM 2. Visible Not hidden/opacity:0 3. Stable No animation running 4. Enabled Not disabled attr 5. In viewport Scrolled into view 6. Receives events Not covered by overlay βœ“ CLICK! Action fires Any check fails β†’ Playwright retries automatically until actionTimeout (default 30s) Timeout Error (after 30s default) Clear message: which check failed and why All 6 checks run β€” this is why most Selenium explicit waits are unnecessary in Playwright!
Chapter 8 Β· Core Concepts

Assertions & Expectations

Playwright uses the expect() API for assertions. The key feature: assertions automatically retry until the condition is met or the timeout is reached β€” no more flaky tests from race conditions.

πŸ”„ From Selenium's Assert.* or JUnit assertThat()
In Selenium/JUnit: Assert.assertEquals("Welcome", driver.findElement(...).getText()) β€” instant, fails immediately if condition isn't met.
In Playwright: await expect(locator).toHaveText('Welcome') β€” retries for up to 5 seconds before failing. This eliminates the need for explicit waits before assertions.

Page Assertions

TypeScriptPage-level Assertions
// URL assertions
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/dashboard/);    // regex

// Page title
await expect(page).toHaveTitle('My App - Dashboard');
await expect(page).toHaveTitle(/Dashboard/);

// Visual regression (screenshots)
await expect(page).toMatchSnapshot();
await expect(page).toMatchSnapshot({ maxDiffPixelRatio: 0.01 });

Element Assertions

TypeScriptElement Assertions
const btn = page.getByRole('button', { name: 'Submit' });

// Visibility
await expect(btn).toBeVisible();           // element is in DOM and visible
await expect(btn).toBeHidden();            // element is hidden or not in DOM
await expect(btn).toBeAttached();          // element is in the DOM

// Enabled/disabled state
await expect(btn).toBeEnabled();           // not disabled
await expect(btn).toBeDisabled();          // is disabled

// Text content
await expect(btn).toHaveText('Submit Order');   // exact text match
await expect(btn).toContainText('Submit');      // partial text match
await expect(page.locator('ul')).toHaveText(['Item 1', 'Item 2']); // array of texts

// Form values
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByLabel('Accept')).toBeChecked();

// Attributes & CSS
await expect(btn).toHaveAttribute('type', 'submit');
await expect(btn).toHaveClass(/btn-primary/);
await expect(btn).toHaveCSS('color', 'rgb(255, 0, 0)');

// Count
await expect(page.getByRole('listitem')).toHaveCount(5);

// Negate with .not
await expect(btn).not.toBeVisible();
await expect(page).not.toHaveURL('/error');

Advanced: expect.poll() and toPass()

TypeScriptPolling & Retry Assertions
// ── expect.poll() β€” retry a non-Playwright value ──────────────
// Perfect when checking a DB value, API response, or custom condition
await expect.poll(async () => {
  const response = await page.request.get('/api/job-status');
  return (await response.json()).status;
}, {
  timeout: 30000,    // poll for up to 30s
  intervals: [1000, 2000, 5000],  // backoff intervals
}).toBe('completed');

// ── expect(fn).toPass() β€” retry a block of assertions ─────────
// Retries the entire block until all assertions inside pass
await expect(async () => {
  const response = await page.request.get('/api/report');
  expect(response.status()).toBe(200);
  const data = await response.json();
  expect(data.rows.length).toBeGreaterThan(0);
}).toPass({ timeout: 30000 });

Soft Assertions (Don't Stop on Failure)

TypeScriptSoft Assertions
// Soft assertions: test continues even if assertion fails
// All failures are reported at the end of the test
test('validate product page', async ({ page }) => {
  await page.goto('/product/123');

  // These won't stop the test if they fail
  await expect.soft(page.getByRole('heading')).toHaveText('Product Name');
  await expect.soft(page.getByText('$99.99')).toBeVisible();
  await expect.soft(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();

  // This is a hard assertion β€” still stops if it fails
  await expect(page).toHaveURL(/product/);
});
πŸ’‘ Use Soft Assertions for
Validating multiple independent fields on a page (forms, product pages, dashboards) where you want to see ALL failures at once, not just the first one. This is equivalent to "soft asserts" in TestNG or using SoftAssertions in AssertJ.
Chapter 9 Β· Core Concepts

Waiting Strategies

Playwright's auto-wait handles 90% of cases. Here's when and how to handle the other 10%.

πŸ”„ Selenium Waits vs Playwright Waits
Selenium: Implicit wait (global), Explicit wait (WebDriverWait + ExpectedConditions), FluentWait
Playwright: Everything is implicitly waited. You only add waits for complex async scenarios.
TypeScriptWaiting Patterns
// ── Auto-wait (built-in, no code needed) ──────────────────────
// These all auto-wait for the element to be actionable:
await locator.click();         // waits: visible, stable, enabled, in viewport
await locator.fill('text');    // waits: visible, stable, enabled, editable
await expect(locator).toBeVisible();  // retries assertion for 5s

// ── Wait for navigation ────────────────────────────────────────
await page.waitForURL('**/dashboard');        // wait for URL to match
await page.waitForURL(/dashboard/, { timeout: 10000 });

// ── Wait for element state ─────────────────────────────────────
await locator.waitFor({ state: 'visible' });   // visible, hidden, attached, detached
await locator.waitFor({ state: 'hidden' });

// ── Wait for network ───────────────────────────────────────────
// Wait for a specific API call to complete (great for async forms)
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);

// Wait for a specific outgoing request to be sent
const requestPromise = page.waitForRequest('**/api/analytics');
await page.getByRole('button', { name: 'Track' }).click();
const req = await requestPromise;
expect(req.method()).toBe('POST');

// Wait for page to have no pending network requests
await page.waitForLoadState('networkidle');  // ⚠️ Use sparingly β€” can be slow
await page.waitForLoadState('domcontentloaded');

// ── Wait for a function / condition ───────────────────────────
await page.waitForFunction(() => window.myAppReady === true);

// ── AVOID: Hard sleeps (like Selenium's Thread.sleep) ─────────
// ❌ await page.waitForTimeout(3000);  // Only for debugging!
Chapter 10 Β· Intermediate

Configuration (playwright.config.ts)

The config file is the control center of your test suite β€” define browsers, timeouts, retries, base URL, reporters, and more.

TypeScriptplaywright.config.ts β€” Full Example
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // Where to look for test files
  testDir: './tests',

  // Run all tests in parallel by default
  fullyParallel: true,

  // CI: don't run test.only β€” fail build if committed accidentally
  forbidOnly: !!process.env.CI,

  // Retry failed tests β€” 0 locally, 2 in CI
  retries: process.env.CI ? 2 : 0,

  // ⚠️ undefined = HALF the logical CPU count (not full count)
  workers: process.env.CI ? 4 : undefined,

  // Reporters: list = terminal, html = HTML report file
  reporter: [
    ['list'],                    // terminal output
    ['html', { open: 'never' }], // HTML report (don't auto-open)
    ['junit', { outputFile: 'results.xml' }], // for CI
  ],

  // Run a global setup script ONCE before the entire test suite
  globalSetup: './global-setup.ts',

  // Run a global teardown script ONCE after all tests finish
  globalTeardown: './global-teardown.ts',

  // Configure assertion retry timeout (default: 5000ms)
  expect: {
    timeout: 5000,
  },

  // Global settings applied to all tests
  use: {
    // Base URL β€” use relative URLs in tests: page.goto('/login')
    baseURL: process.env.BASE_URL || 'http://localhost:3000',

    // Collect traces on first retry (for debugging CI failures)
    trace: 'on-first-retry',

    // Screenshot on failure
    screenshot: 'only-on-failure',

    // Video recording
    video: 'on-first-retry',

    // Default timeout for actions (30s)
    actionTimeout: 30000,

    // Default timeout for navigation
    navigationTimeout: 30000,
  },

  // Test projects = matrix of browser + config combinations
  projects: [
    // Run setup project first (e.g., global login)
    {
      name: 'setup',
      testMatch: '**/*.setup.ts',
    },

    // Desktop browsers
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },

    // Mobile emulation
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 13'] },
    },
  ],

  // Start dev server before running tests (optional)
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Timeout Hierarchy

Global Timeout (default: none β€” full suite runtime) --timeout flag or config.timeout Test Timeout (default: 30,000ms) test.setTimeout() or config.timeout Action Timeout (default: 30s) Per click/fill/etc. β†’ config.use.actionTimeout Assertion Timeout (default: 5s) For expect() retries β†’ config.expect.timeout
Chapter 11 Β· Intermediate

Hooks & Test Organization

TypeScriptHooks and describe() blocks
import { test, expect } from '@playwright/test';

// Group related tests
test.describe('Shopping Cart', () => {

  // Runs ONCE before all tests in this describe block
  test.beforeAll(async ({ browser }) => {
    // Create shared resources (e.g., database setup)
  });

  // Runs before EACH test β€” most common hook
  test.beforeEach(async ({ page }) => {
    await page.goto('/cart');
    // Set up a known state before each test
  });

  // Runs after EACH test
  test.afterEach(async ({ page }, testInfo) => {
    if (testInfo.status !== 'passed') {
      await page.screenshot({ path: `failure-${testInfo.title}.png` });
    }
  });

  // Runs ONCE after all tests
  test.afterAll(async () => {
    // Cleanup shared resources (e.g., delete test data)
  });

  test('can add item to cart', async ({ page }) => { /* ... */ });
  test('can remove item from cart', async ({ page }) => { /* ... */ });
});

// ── Test control ───────────────────────────────────────────────
test.only('run only this test', async ({ page }) => { /* ... */ });
test.skip('skip this test', async ({ page }) => { /* ... */ });
test.fixme('known broken test', async ({ page }) => { /* ... */ });
test.fail('expected to fail', async ({ page }) => { /* ... */ });

// Conditional skip
test.skip(process.platform === 'win32', 'Not supported on Windows');

// ── Test tags (for filtering) ─────────────────────────────────
// Old approach: embed @tag in test name
test('checkout flow @smoke @critical', async ({ page }) => { /* ... */ });
// New approach (v1.42+): use the tag option β€” preferred
test('checkout flow', { tag: ['@smoke', '@critical'] }, async ({ page }) => { /* ... */ });
// Run: npx playwright test --grep @smoke
// Skip: npx playwright test --grep-invert @slow

// ── test.step() β€” group actions into labeled steps ─────────────
// Steps appear as collapsible sections in the Trace Viewer
test('complete checkout', async ({ page }) => {
  await test.step('Add product to cart', async () => {
    await page.goto('/products');
    await page.getByRole('button', { name: 'Add to cart' }).click();
  });

  await test.step('Proceed to checkout', async () => {
    await page.getByRole('link', { name: 'Checkout' }).click();
    await expect(page).toHaveURL(/checkout/);
  });

  await test.step('Fill shipping details', async () => {
    await page.getByLabel('Address').fill('123 Main St');
    await page.getByRole('button', { name: 'Place Order' }).click();
  });

  await expect(page.getByText('Order confirmed')).toBeVisible();
});
Chapter 12 Β· Intermediate

Page Object Model

POM separates page interaction logic from test logic. If the UI changes, you update one class β€” not every test. Your Selenium POM knowledge applies directly here.

TypeScriptpages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;

  // Define locators as class properties
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorText(): Promise<string> {
    return await this.errorMessage.innerText();
  }
}
TypeScripttests/login.spec.ts β€” Using the Page Object
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});
Chapter 13 Β· Intermediate

Fixtures

Fixtures are Playwright's powerful dependency injection system. Instead of creating page objects in every test, fixtures provide them automatically β€” with proper setup and teardown.

ℹ️ What are Fixtures?
Think of fixtures like Spring dependency injection for tests. You declare what your test needs in the function parameters, and Playwright instantiates and tears down those dependencies automatically. page, browser, context are all built-in fixtures.
TypeScriptfixtures/index.ts β€” Custom Fixtures with POM
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

// Define the type for our custom fixtures
type MyFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  loggedInPage: DashboardPage;  // already logged-in state
};

export const test = base.extend<MyFixtures>({

  // Auto-create LoginPage for every test that requests it
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  // A fixture that logs in before the test, tears down after
  loggedInPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('user@test.com', 'password');
    // ↑ Setup  ← the 'use' call is where the test body runs β†’  Teardown ↓
    await use(new DashboardPage(page));
    // Cleanup happens here after test completes
    await page.evaluate(() => localStorage.clear());
  },
});

// Re-export expect so tests only import from this file
export { expect } from '@playwright/test';
TypeScripttests/dashboard.spec.ts β€” Using Custom Fixtures
import { test, expect } from '../fixtures';  // your custom test object

// No login code needed β€” fixture handles it!
test('dashboard shows user name', async ({ loggedInPage }) => {
  await expect(loggedInPage.userGreeting).toBeVisible();
});

test('login form validates email', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login('notanemail', 'pass');
  // assertions...
});
Chapter 14 Β· Advanced

API Testing

Playwright has a built-in API testing context β€” no need for a separate tool like RestAssured or Axios in your test suite. This lets you mix UI and API tests seamlessly.

πŸ’‘ Why Test APIs with Playwright?
You can use API calls to set up test data much faster than UI interactions, or to verify backend state after a UI action. This hybrid approach gives you much faster, more reliable tests.
TypeScriptAPI Testing Basics
import { test, expect, request } from '@playwright/test';

// ── Standalone API test (no browser) ──────────────────────────
test('GET /api/users returns list', async ({ request }) => {
  const response = await request.get('https://api.example.com/users');

  expect(response.status()).toBe(200);
  expect(response.headers()['content-type']).toContain('application/json');

  const body = await response.json();
  expect(body.users).toHaveLength(10);
  expect(body.users[0]).toMatchObject({
    id: expect.any(Number),
    email: expect.stringContaining('@'),
  });
});

test('POST /api/users creates a user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Jane Doe', email: 'jane@example.com' },
    headers: { 'Authorization': 'Bearer token123' },
  });

  expect(response.status()).toBe(201);
  const user = await response.json();
  expect(user.id).toBeDefined();
});

// ── Hybrid: API setup + UI verification ───────────────────────
test('created product appears in product list', async ({ request, page }) => {
  // 1. Create data via API (fast, no UI needed)
  const apiResponse = await request.post('/api/products', {
    data: { name: 'Test Widget', price: 9.99 }
  });
  const { id } = await apiResponse.json();

  // 2. Verify it shows in the UI
  await page.goto('/products');
  await expect(page.getByText('Test Widget')).toBeVisible();

  // 3. Cleanup via API
  await request.delete(`/api/products/${id}`);
});

API Context with Base URL & Auth

TypeScriptReusable API Context
import { request } from '@playwright/test';

// Create a reusable authenticated API context
const apiContext = await request.newContext({
  baseURL: 'https://api.example.com',
  extraHTTPHeaders: {
    'Accept': 'application/json',
    'Authorization': `Bearer ${process.env.API_TOKEN}`,
  },
});

// Now use relative URLs
await apiContext.get('/users');      // β†’ https://api.example.com/users
await apiContext.post('/orders', { data: { /* ... */ } });

// Always dispose when done
await apiContext.dispose();
Chapter 15 Β· Advanced

Network Interception

Playwright's page.route() lets you intercept any network request, modify it, mock the response, or abort it β€” perfect for testing error states, offline scenarios, and slow APIs.

TypeScriptNetwork Interception Patterns
// ── Mock an API response ────────────────────────────────────────
await page.route('**/api/products', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Mocked Product', price: 19.99 }
    ]),
  });
});

// ── Test error states (500, 404, network failure) ──────────────
await page.route('**/api/orders', async route => {
  await route.fulfill({ status: 500, body: 'Internal Server Error' });
});

// ── Abort a request (simulate network offline) ─────────────────
await page.route('**/*.png', route => route.abort());

// ── Modify a real request (add headers, change body) ──────────
await page.route('**/api/**', async route => {
  const headers = { ...route.request().headers(), 'x-test-header': 'true' };
  await route.continue({ headers });      // continue with modified request
});

// ── Load response from file (fixtures) ────────────────────────
await page.route('**/api/products', route => route.fulfill({
  path: 'tests/fixtures/products.json'  // serve from local file
}));

// ── Remove route when done ─────────────────────────────────────
await page.unroute('**/api/products');

// ── Intercept and inspect a request ───────────────────────────
page.on('request', req => {
  if (req.url().includes('/api/')) {
    console.log(`➜ ${req.method()} ${req.url()}`);
  }
});

route.fetch() β€” Modify Real Responses

The most powerful pattern: make the real network request, then modify the response before Playwright hands it back to the browser. Perfect for changing one field in a large API response without mocking the whole thing.

TypeScriptroute.fetch() β€” Intercept + Modify Real Responses
// ── Fetch the real response, then modify it ───────────────────
await page.route('**/api/products', async route => {
  // 1. Make the REAL request
  const response = await route.fetch();
  const json = await response.json();

  // 2. Modify a single field (e.g. inject an out-of-stock item)
  json[0].stock = 0;

  // 3. Return the modified response to the browser
  await route.fulfill({ response, json });
});

// ── Add latency to test loading/skeleton states ────────────────
await page.route('**/api/heavy-report', async route => {
  await page.waitForTimeout(2000);   // simulate 2s delay
  await route.continue();
});

// ── Remove a field to test missing-data UI states ─────────────
await page.route('**/api/user', async route => {
  const response = await route.fetch();
  const json = await response.json();
  delete json.avatar;       // test "no profile photo" UI state
  await route.fulfill({ response, json });
});
Chapter 16 Β· Advanced

Authentication & Storage State

Instead of logging in through the UI before every test (slow!), Playwright can save and reuse authenticated browser state. One login β†’ entire test suite reuses the session.

TypeScriptauth.setup.ts β€” Login Once, Save State
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/dashboard');

  // Save cookies + localStorage to a file
  await page.context().storageState({ path: authFile });
});
TypeScriptplaywright.config.ts β€” Use Saved Auth
projects: [
  {
    name: 'setup',
    testMatch: '**/*.setup.ts',
  },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      // Load saved auth state β€” every test starts already logged in!
      storageState: 'playwright/.auth/user.json',
    },
    dependencies: ['setup'],  // run setup project first
  },
],
πŸ’‘ Multiple Roles
Create a setup file per role: admin.setup.ts, user.setup.ts, guest.setup.ts. Use different storageState files per project. Each test project can simulate a different user role β€” no UI login in tests at all.
Chapter 17 Β· Advanced

Visual Regression Testing

Playwright's built-in snapshot testing compares screenshots pixel-by-pixel to catch unintended UI changes.

ℹ️ Two APIs: Know Which to Use
toHaveScreenshot() β€” the recommended API for visual UI testing. Designed specifically for screenshots with smart defaults and better options.
toMatchSnapshot() β€” for arbitrary data snapshots (JSON, text, binary). Use this for non-screenshot comparisons, not for UI testing.
TypeScriptVisual Testing
// ── toHaveScreenshot() β€” recommended for UI visual testing ────
test('homepage visual snapshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');   // full page
});

// Compare a specific element (component-level visual test)
test('product card visual', async ({ page }) => {
  await page.goto('/products/1');
  await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card.png');
});

// With tolerance for minor differences
await expect(page).toHaveScreenshot({
  maxDiffPixelRatio: 0.01,    // allow 1% pixel difference
  threshold: 0.2,             // per-pixel color diff tolerance (0–1)
  fullPage: true,             // capture full scrollable page
});

// Mask dynamic content (dates, avatars, ads) to avoid false failures
await expect(page).toHaveScreenshot({
  mask: [page.getByTestId('dynamic-date'), page.getByTestId('user-avatar')],
});

// First run CREATES the baseline snapshots (.png files in __screenshots__ folder)
// Subsequent runs COMPARE against the baseline
// To update baselines: npx playwright test --update-snapshots
⚠️ Visual Testing Tips
Snapshots are OS and browser-specific β€” chromium on Mac will differ from chromium on Linux. Always generate baseline snapshots in the same environment you'll run CI. Use --update-snapshots intentionally, never automatically in CI. Consider masking dynamic content (dates, ads, avatars) with mask option.
Chapter 18 Β· Advanced

Parallel Execution

Playwright runs tests in parallel by default β€” no Selenium Grid required. Understanding how this works helps you write safe parallel tests.

Test Files login.spec.ts cart.spec.ts checkout.spec profile.spec Workers (parallel processes) Worker 1 Runs login.spec.ts tests in sequence Each test gets a fresh page Worker 2 Runs cart.spec.ts tests in sequence Completely isolated from Worker 1 Worker 3 Runs checkout + profile in parallel Workers = CPU count by default All Results Merged & reported together HTML / JSON / JUnit ~3x faster than serial ⚠ Tests within a file run serially (in order). Tests across files run in parallel.
TypeScriptParallel Execution Options
// ── playwright.config.ts ───────────────────────────────────────

// Run test files in parallel (default)
fullyParallel: false,   // files in parallel, tests within file = serial
fullyParallel: true,    // EVERYTHING in parallel (tests within files too)

workers: 4,              // fixed number of workers
workers: '50%',          // 50% of CPU count
workers: undefined,      // auto (defaults to CPU count)

// ── In test files ─────────────────────────────────────────────
// Force a describe block to run serially (e.g., dependent tests)
test.describe.serial('user checkout flow', () => {
  test('add to cart', async ({ page }) => { /* ... */ });
  test('checkout', async ({ page }) => { /* ... */ });    // runs after 'add to cart'
});

// ── Test sharding for large suites ────────────────────────────
# CI: split across 3 machines
# Machine 1: npx playwright test --shard=1/3
# Machine 2: npx playwright test --shard=2/3
# Machine 3: npx playwright test --shard=3/3
Chapter 19 Β· Expert

Debugging Tools

Playwright has the best debugging experience of any test framework. Multiple powerful tools at your disposal.

Playwright Inspector

bashLaunching Debug Tools
# Open Playwright Inspector β€” step through tests
npx playwright test --debug

# Debug a specific test
npx playwright test tests/login.spec.ts --debug

# Set PWDEBUG=1 to open inspector for all runs
PWDEBUG=1 npx playwright test

# Pause in middle of test (add to code)
# await page.pause();

# View trace files after a run
npx playwright show-trace trace.zip

πŸ†• Playwright UI Mode β€” Interactive Test Runner

πŸ’‘ UI Mode is a Game Changer
UI Mode (--ui) is a full GUI for running and debugging tests interactively. It combines the test runner, trace viewer, time-travel debugging, and live reload all in one window. For day-to-day development, it's faster than CLI + Inspector separately.
bashPlaywright UI Mode
# Launch interactive UI Mode
npx playwright test --ui

# What you get in UI Mode:
#  βœ“ File tree on the left β€” click any test to run it
#  βœ“ Watch mode β€” tests re-run on file save
#  βœ“ Time-travel trace viewer built in β€” click any action to replay
#  βœ“ Filter by tag, status, or keyword
#  βœ“ Pick locator tool directly in the browser window
#  βœ“ Run failed tests only with one click
Playwright UI Mode Panels Test Tree All spec files βœ“ Filter by tag βœ“ Run individually βœ“ Watch mode βœ“ Pass/fail status Live Browser See page as test runs βœ“ Pick locator tool βœ“ Inspect elements βœ“ Live page state βœ“ Console output Trace Timeline Time-travel replay βœ“ Click any action βœ“ DOM before/after βœ“ Network requests βœ“ test.step() labels All 3 panels update in sync as you click through the timeline

Trace Viewer

ℹ️ What the Trace Viewer Shows
The Trace Viewer is like a flight recorder for your tests. After a failure, open the trace to see: Timeline of every action, DOM snapshots at each step (before and after), Network requests with response details, Console logs, and Source code at each step. It's the fastest way to diagnose CI failures without re-running tests.
TypeScriptConfiguring Traces
// playwright.config.ts β€” configure when to collect traces
use: {
  trace: 'off',              // never (production)
  trace: 'on',               // always
  trace: 'on-first-retry',   // ← recommended for CI
  trace: 'retain-on-failure',// save trace only when test fails
},

// Manually start/stop tracing
await context.tracing.start({ screenshots: true, snapshots: true });
// ... test actions ...
await context.tracing.stop({ path: 'trace.zip' });

// View it: npx playwright show-trace trace.zip

Useful Debugging Snippets

TypeScriptDebug Helpers
// Pause execution β€” opens Inspector, lets you explore the page
await page.pause();

// Check what a locator matches
const count = await page.getByRole('button').count();
console.log(`Found ${count} buttons`);

// Get all texts from a list
const texts = await page.getByRole('listitem').allTextContents();

// Print the HTML of an element for debugging
const html = await page.locator('#my-component').innerHTML();

// Highlight element (inject red border for 2s)
await page.locator('#element').evaluate(el =>
  el.style.outline = '3px solid red'
);

// Screenshot at a specific point for debugging
await page.screenshot({ path: 'debug-state.png' });

// testInfo in hooks β€” access test details + attach custom files to report
test('example', async ({ page }, testInfo) => {
  console.log(testInfo.title);    // test name
  console.log(testInfo.status);   // passed/failed/skipped
  console.log(testInfo.retry);    // which retry attempt (0 = first run)
  console.log(testInfo.outputDir);// where test output files are saved
});

// ── testInfo.attach() β€” embed custom files in the HTML report ──
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== 'passed') {
    // Attach a screenshot β€” visible in HTML report
    await testInfo.attach('on-failure screenshot', {
      body: await page.screenshot(),
      contentType: 'image/png',
    });

    // Attach a log file or JSON data
    await testInfo.attach('api-response', {
      body: JSON.stringify(someApiData, null, 2),
      contentType: 'application/json',
    });
  }
});
Chapter 20 Β· Expert

Reporters & CI/CD

Built-in Reporters

ReporterUse caseConfig
listDefault terminal output β€” shows each test as it runs['list']
dotMinimal output β€” a dot per test, good for CI['dot']
htmlRich HTML report with screenshots, traces, history['html', { open: 'never' }]
jsonMachine-readable output for custom processing['json', { outputFile: 'results.json' }]
junitJUnit XML β€” for Jenkins, Azure DevOps, etc.['junit', { outputFile: 'results.xml' }]
githubAnnotates GitHub PRs with test failures['github']
blobFor merging sharded test results['blob']

GitHub Actions CI Setup

yaml.github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test
        env:
          BASE_URL: https://staging.example.com
          API_TOKEN: ${{ secrets.API_TOKEN }}

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()   # upload even when tests fail!
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
Chapter 21 Β· Expert

Mobile, Media & Multi-browser Testing

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

// Test on a specific device emulation
test.use({ ...devices['iPhone 14 Pro'] });

test('mobile checkout', async ({ page }) => {
  await page.goto('/checkout');
  // Test on a 390x844 viewport with touch events
});

// Geolocation testing
test('shows local stores', async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 28.6139, longitude: 77.2090 },  // New Delhi
    permissions: ['geolocation'],
  });
  const page = await context.newPage();
  // ... test geolocation-based features
});

// Run with a specific locale/timezone
test.use({
  locale: 'en-IN',
  timezoneId: 'Asia/Kolkata',
});

page.emulateMedia() β€” Dark Mode, Print, Forced Colours

TypeScriptMedia Emulation
// ── Dark mode visual testing ───────────────────────────────────
test('dark mode renders correctly', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage-dark.png');
});

// ── Or configure it in test.use() for a whole describe block ───
test.use({ colorScheme: 'dark' });       // 'light' | 'dark' | 'no-preference'

// ── Test print layout ──────────────────────────────────────────
await page.emulateMedia({ media: 'print' });
await expect(page).toHaveScreenshot('invoice-print.png');

// ── Forced colors (accessibility) ─────────────────────────────
await page.emulateMedia({ forcedColors: 'active' });

// ── Reduce motion (accessibility) β€” also useful in CI ──────────
await page.emulateMedia({ reducedMotion: 'reduce' });
Chapter 22 Β· Expert

Parameterized & Data-Driven Tests

Run the same test logic across multiple data sets without code duplication. Three clean patterns β€” pick the one that fits your scenario.

TypeScriptPattern 1: forEach Loop (Simple)
// ── simplest approach: loop over an array of data ──────────────
const browsers = [
  { user: 'admin@test.com',  role: 'admin',  canDelete: true  },
  { user: 'editor@test.com', role: 'editor', canDelete: false },
  { user: 'viewer@test.com', role: 'viewer', canDelete: false },
];

browsers.forEach(({ user, role, canDelete }) => {
  test(`${role} can${canDelete ? '' : 'not'} delete records`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(user);
    // ...
    const deleteBtn = page.getByRole('button', { name: 'Delete' });
    if (canDelete) {
      await expect(deleteBtn).toBeVisible();
    } else {
      await expect(deleteBtn).toBeHidden();
    }
  });
});
TypeScriptPattern 2: Fixtures with Test Data (Recommended for Large Sets)
// ── Load data from a JSON file and inject via fixture ─────────
// tests/data/login-scenarios.json
// [
//   { "email": "valid@user.com", "password": "correct", "expectURL": "/dashboard" },
//   { "email": "invalid@user.com", "password": "wrong", "expectError": "Invalid credentials" }
// ]

import scenarios from './data/login-scenarios.json';

for (const scenario of scenarios) {
  test(`login: ${scenario.email}`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(scenario.email);
    await page.getByLabel('Password').fill(scenario.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    if (scenario.expectURL) {
      await expect(page).toHaveURL(scenario.expectURL);
    } else {
      await expect(page.getByRole('alert')).toContainText(scenario.expectError);
    }
  });
}
TypeScriptPattern 3: Cross-project Matrix (Config-driven)
// playwright.config.ts β€” run same tests with different env data
projects: [
  {
    name: 'staging-chrome',
    use: {
      ...devices['Desktop Chrome'],
      baseURL: 'https://staging.example.com',
    },
  },
  {
    name: 'production-chrome',
    use: {
      ...devices['Desktop Chrome'],
      baseURL: 'https://www.example.com',
    },
  },
],
// Same tests run against both environments β€” one project per env
// Run only staging: npx playwright test --project=staging-chrome
⚠️ Avoid Generating Too Many Tests
Data-driven tests are powerful, but a 50-row CSV Γ— 3 browsers = 150 test cases that are slow to run and hard to maintain. Use data-driven for genuinely distinct scenarios (different user roles, edge case inputs, boundary values) β€” not to replace manual parameterization of minor variations.
Chapter 23 Β· Expert

Best Practices

Test Design

βœ… One test, one behaviour

Each test should verify one user scenario. Keep tests short and focused β€” easier to debug when they fail.

βœ… Independent tests

Never rely on test execution order. Each test should set up and clean up its own state. Use beforeEach / API calls to prepare data.

βœ… Use getByRole first

Semantic locators are resilient. If devs rename a CSS class, your test still passes. If they rename an ARIA label, it was an accessibility regression anyway.

βœ… Never use hardcoded waits

Replace waitForTimeout(3000) with a meaningful assertion or waitForResponse(). Hard sleeps are flaky and slow.

βœ… Use environment variables

Never hardcode URLs, credentials, or tokens. Use .env files and process.env. Use .env.example to document required variables.

βœ… API for test setup/teardown

Create and delete test data via APIs, not UI interactions. UI setup is slow β€” API setup keeps tests fast and isolated.

Anti-patterns to Avoid

TypeScript❌ Anti-patterns vs βœ… Recommended
// ❌ Fragile XPath β€” breaks when HTML structure changes
page.locator('//div[@class="container"]/div[2]/button[1]');
// βœ… Role-based β€” resilient
page.getByRole('button', { name: 'Submit Order' });

// ❌ Hard sleep β€” flaky and slow
await page.waitForTimeout(3000);
// βœ… Wait for something meaningful
await expect(page.getByText('Order confirmed')).toBeVisible();

// ❌ Testing implementation details
await expect(page.locator('.btn-success')).toHaveClass('btn-success');
// βœ… Test the user-visible outcome
await expect(page.getByRole('status')).toHaveText('Order placed successfully');

// ❌ Chained awaits that could fail silently
const el = await page.locator('.item').elementHandle();  // avoid ElementHandles
// βœ… Use locators directly
await page.locator('.item').click();

// ❌ Logging in via UI before every test
test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'user@test.com');  // SLOW
});
// βœ… Use storageState to reuse auth (see Auth section)
storageState: 'playwright/.auth/user.json',

Naming Conventions

textFile & Test Naming
tests/
β”œβ”€β”€ auth/
β”‚   β”œβ”€β”€ login.spec.ts           ← feature.spec.ts
β”‚   └── registration.spec.ts
β”œβ”€β”€ checkout/
β”‚   β”œβ”€β”€ cart.spec.ts
β”‚   └── payment.spec.ts
β”œβ”€β”€ *.setup.ts                  ← global setup files

Test names: "feature β€” scenario β€” expected outcome"
  βœ… "login β€” valid credentials β€” redirects to dashboard"
  βœ… "checkout β€” empty cart β€” shows empty state message"
  ❌ "test1"
  ❌ "loginTest"
🎯 The Golden Rule
Write tests from the user's perspective. A user clicks "Sign in", sees a dashboard β€” not "calls POST /auth/login, gets 200 OK". Your test names, locators, and assertions should all describe what a user sees and does, not what the code does under the hood. This makes tests more readable and much more resilient to refactoring.

🎭 Quick Reference Card

Locator Priority (best β†’ last)

  • getByRole()
  • getByLabel()
  • getByPlaceholder()
  • getByText()
  • getByTestId()
  • locator('css')

Most Used Assertions

  • toBeVisible()
  • toHaveText()
  • toHaveURL()
  • toBeEnabled()
  • toHaveScreenshot()
  • toHaveCount()

Key CLI Commands

  • playwright test
  • --ui (UI mode)
  • --debug
  • --headed
  • show-report
  • codegen <url>

Key Patterns

  • storageState (auth)
  • route.fetch()
  • test.step()
  • expect.poll()
  • page.on('dialog')
  • waitForEvent('popup')