Playwright Basics

A comprehensive, hands-on guide to end-to-end testing with Playwright and TypeScript. From installation to CI/CD, learn everything you need to write reliable browser tests for modern web applications.

Introduction

What is Playwright?

Playwright is an open-source end-to-end testing framework developed by Microsoft. It allows you to automate Chromium, Firefox, and WebKit browsers with a single API. Unlike older tools, Playwright was built from the ground up for the modern web — it handles single-page applications, iframes, shadow DOM, and multiple tabs natively.

Why Choose Playwright?

  • Cross-browser — One API works with Chromium (Chrome, Edge), Firefox, and WebKit (Safari) on Windows, macOS, and Linux.
  • Auto-waiting — Playwright automatically waits for elements to be actionable before performing operations. No more manual sleep calls or flaky waits.
  • Web-first assertions — Assertions retry until the condition is met or a timeout is reached, eliminating most race-condition flakiness.
  • Test isolation — Every test gets a fresh browser context (cookies, storage, cache) out of the box. Tests cannot interfere with each other.
  • Powerful tooling — Built-in test generator (Codegen), Trace Viewer for post-mortem debugging, and an HTML reporter for rich test reports.
  • TypeScript first — Full TypeScript support with auto-complete, type checking, and excellent IDE integration.

How Does Playwright Compare?

FeaturePlaywrightCypressSelenium
Cross-browserChromium, Firefox, WebKitChromium, Firefox, WebKit (limited)All major browsers
Language supportTypeScript, JavaScript, Python, Java, C#JavaScript / TypeScript onlyJava, Python, C#, Ruby, JS
Auto-waitingBuilt-in, comprehensiveBuilt-inManual waits required
Multi-tab / multi-originFull supportLimited / workaroundsSupported
iframesFirst-class APISupported (some limitations)Supported
Parallel executionBuilt-in, file-level & test-levelVia Cypress Cloud or pluginsVia Selenium Grid
Trace viewerBuilt-in with DOM snapshotsVideo only (screenshots in Cloud)Not built-in
Network interceptionFull API mocking & modificationcy.intercept()Limited
Key TakeawayPlaywright combines the developer experience of Cypress with the cross-browser capabilities of Selenium, while adding powerful modern features like Trace Viewer and auto-waiting that neither competitor offers out of the box.

Installation

The fastest way to get started is the official init command, which scaffolds an entire project with configuration, example tests, and optionally a CI workflow.

terminalbash
1# Create a new Playwright project from scratch
2npm init playwright@latest
3
4# You will be prompted:
5#   Do you want to use TypeScript or JavaScript? -> TypeScript
6#   Where to put your end-to-end tests? -> tests
7#   Add a GitHub Actions workflow? -> true
8#   Install Playwright browsers? -> true

The command installs @playwright/test (the test runner) and downloads browser binaries for Chromium, Firefox, and WebKit. After it finishes you will have a project structure like this:

project structuretext
1my-playwright-project/
2├── node_modules/
3├── tests/
4│   └── example.spec.ts        # Sample test
5├── tests-examples/
6│   └── demo-todo-app.spec.ts  # Full demo test
7├── .github/
8│   └── workflows/
9│       └── playwright.yml     # CI workflow
10├── playwright.config.ts           # Main configuration
11├── package.json
12└── tsconfig.json
TipIf you already have a project and want to add Playwright to it, simply install the package: npm install -D @playwright/test and then run npx playwright install to download the browsers.

Installing Specific Browsers

By default all three browsers are installed, which takes some disk space. If you only need Chromium, you can install selectively:

terminalbash
1# Install only Chromium
2npx playwright install chromium
3
4# Install Chromium and Firefox, skip WebKit
5npx playwright install chromium firefox
6
7# Install browsers along with OS-level dependencies (useful in Docker / CI)
8npx playwright install --with-deps chromium

Configuration

The heart of every Playwright project is playwright.config.ts. This file controls which browsers to test, timeouts, retries, reporters, and much more. Here is a thoroughly annotated example:

playwright.config.tstypescript
1import { defineConfig, devices } from '@playwright/test';
2
3export default defineConfig({
4  // Directory that contains your test files
5  testDir: './tests',
6
7  // Maximum time a single test can run before it is forcibly stopped
8  timeout: 30_000,
9
10  // Assertion-level timeout (how long expect() waits before failing)
11  expect: {
12    timeout: 5_000,
13  },
14
15  // Run tests in parallel across files
16  fullyParallel: true,
17
18  // Fail the entire suite immediately if any test fails (useful in CI)
19  forbidOnly: !!process.env.CI,
20
21  // Retry failed tests once in CI, never locally
22  retries: process.env.CI ? 2 : 0,
23
24  // Limit parallel workers in CI to avoid resource contention
25  workers: process.env.CI ? 1 : undefined,
26
27  // Which reporter to use
28  reporter: [
29    ['html', { open: 'never' }],
30    ['list'],
31  ],
32
33  // Shared settings applied to every test in every project
34  use: {
35    // Base URL lets you write relative paths: page.goto('/login')
36    baseURL: 'http://localhost:3000',
37
38    // Capture a trace on the first retry of a failed test
39    trace: 'on-first-retry',
40
41    // Capture a screenshot when a test fails
42    screenshot: 'only-on-failure',
43
44    // Record video only when retrying
45    video: 'on-first-retry',
46  },
47
48  // Define browser projects to test against
49  projects: [
50    {
51      name: 'chromium',
52      use: { ...devices['Desktop Chrome'] },
53    },
54    {
55      name: 'firefox',
56      use: { ...devices['Desktop Firefox'] },
57    },
58    {
59      name: 'webkit',
60      use: { ...devices['Desktop Safari'] },
61    },
62
63    // Mobile viewports
64    {
65      name: 'Mobile Chrome',
66      use: { ...devices['Pixel 5'] },
67    },
68    {
69      name: 'Mobile Safari',
70      use: { ...devices['iPhone 13'] },
71    },
72  ],
73
74  // Automatically start your dev server before running tests
75  webServer: {
76    command: 'npm run dev',
77    url: 'http://localhost:3000',
78    reuseExistingServer: !process.env.CI,
79    timeout: 120_000,
80  },
81});

Key Configuration Options Explained

  • testDir — The directory where Playwright looks for *.spec.ts files. Defaults to the project root if omitted.
  • timeout — Maximum time per test. If a test exceeds this, Playwright terminates it and marks it failed. Default is 30 seconds.
  • fullyParallel — When true, tests inside a single file can run in parallel (not just across files).
  • retries — How many times to retry a failed test. Great for CI where transient failures may occur. Keep it at 0 locally so you catch real bugs.
  • projects — Each project represents a browser configuration. You can also use projects for different environments (staging vs production) or test suites (smoke vs full regression).
  • webServer — Tells Playwright to start your development server before running tests. It waits for the URL to respond before proceeding.
WarningDo not set forbidOnly: true locally. This option is designed for CI and will cause any test with test.only() to fail the entire suite, which is disruptive during development.

Writing Your First Test

Playwright test files use the .spec.ts extension by convention. Each file imports test and expect from @playwright/test. The test function receives a destructured page object, which represents a single browser tab.

tests/homepage.spec.tstypescript
1import { test, expect } from '@playwright/test';
2
3// A test block: "test" accepts a name and an async callback.
4// The callback receives a "page" object that represents a browser tab.
5test('homepage has correct title and link', async ({ page }) => {
6  // Navigate to the application
7  await page.goto('/');
8
9  // Assert the page title
10  await expect(page).toHaveTitle(/My Application/);
11
12  // Find a link by its visible text and click it
13  await page.getByRole('link', { name: 'Get Started' }).click();
14
15  // Assert the URL changed after clicking
16  await expect(page).toHaveURL(/.*getting-started/);
17});

Grouping Tests with describe

Use test.describe() to group related tests and share setup logic with test.beforeEach(). This is similar to describe/beforeEach in Jest or Mocha.

tests/auth.spec.tstypescript
1import { test, expect } from '@playwright/test';
2
3test.describe('User Authentication', () => {
4  // Runs once before all tests in this describe block
5  test.beforeEach(async ({ page }) => {
6    await page.goto('/login');
7  });
8
9  test('should display login form', async ({ page }) => {
10    await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
11    await expect(page.getByLabel('Email')).toBeVisible();
12    await expect(page.getByLabel('Password')).toBeVisible();
13  });
14
15  test('should show error for invalid credentials', async ({ page }) => {
16    await page.getByLabel('Email').fill('wrong@example.com');
17    await page.getByLabel('Password').fill('badpassword');
18    await page.getByRole('button', { name: 'Sign In' }).click();
19
20    await expect(page.getByText('Invalid email or password')).toBeVisible();
21  });
22
23  test('should redirect to dashboard on success', async ({ page }) => {
24    await page.getByLabel('Email').fill('user@example.com');
25    await page.getByLabel('Password').fill('correctpassword');
26    await page.getByRole('button', { name: 'Sign In' }).click();
27
28    await expect(page).toHaveURL(/.*dashboard/);
29    await expect(page.getByText('Welcome back')).toBeVisible();
30  });
31});
TipEach test runs in a completely isolated browser context. Cookies, localStorage, and session data are not shared between tests, even within the same file. This eliminates an entire class of flaky test issues.

Running Your Tests

Use the CLI to run tests. Playwright provides many useful flags:

terminalbash
1# Run all tests
2npx playwright test
3
4# Run in headed mode (see the browser)
5npx playwright test --headed
6
7# Run a single file
8npx playwright test tests/auth.spec.ts
9
10# Run tests matching a name
11npx playwright test -g "should redirect"
12
13# Run only Chromium
14npx playwright test --project=chromium
15
16# Debug mode: steps through each action with Inspector
17npx playwright test --debug

Locators

Locators are the way you find elements on the page. Playwright provides multiple locator strategies, but the recommended approach is to use role-based locators whenever possible. These mirror how users and assistive technology interact with your app, making tests more resilient and accessible.

Role-based Locators (Recommended)

locators-role.tstypescript
1// ----- By Role (preferred - most resilient to DOM changes) -----
2// Buttons
3await page.getByRole('button', { name: 'Submit' });
4await page.getByRole('button', { name: /submit/i }); // regex, case-insensitive
5
6// Links
7await page.getByRole('link', { name: 'Documentation' });
8
9// Headings
10await page.getByRole('heading', { name: 'Welcome', level: 1 });
11
12// Form elements
13await page.getByRole('textbox', { name: 'Email' });
14await page.getByRole('checkbox', { name: 'Accept terms' });
15await page.getByRole('combobox', { name: 'Country' });
16
17// Navigation, dialog, etc.
18await page.getByRole('navigation');
19await page.getByRole('dialog');

Text, Label, Placeholder, and TestID

locators-text.tstypescript
1// ----- By Text Content -----
2await page.getByText('Welcome to the site');
3await page.getByText(/welcome/i);             // partial, case-insensitive
4await page.getByText('Welcome', { exact: true }); // exact match only
5
6// ----- By Label (for form inputs) -----
7await page.getByLabel('Email address');
8await page.getByLabel('Password');
9
10// ----- By Placeholder -----
11await page.getByPlaceholder('Search...');
12
13// ----- By Alt Text (images) -----
14await page.getByAltText('Company logo');
15
16// ----- By Title Attribute -----
17await page.getByTitle('Close dialog');
18
19// ----- By Test ID (escape hatch when nothing else works) -----
20await page.getByTestId('submit-button');
21// Matches: <button data-testid="submit-button">Send</button>

Chaining and Filtering

Real-world pages often have multiple elements with the same role or text. Chaining and filtering let you narrow your search scope to find exactly the right element.

locators-chain.tstypescript
1// Chaining narrows the search scope to children of the parent locator
2const productCard = page.locator('.product-card').filter({ hasText: 'Laptop' });
3await productCard.getByRole('button', { name: 'Add to cart' }).click();
4
5// .nth() selects by index (0-based)
6await page.getByRole('listitem').nth(2).click();
7
8// .first() and .last()
9await page.getByRole('listitem').first().click();
10await page.getByRole('listitem').last().click();
11
12// Filter by another locator
13await page
14  .getByRole('listitem')
15  .filter({ has: page.getByRole('heading', { name: 'Premium' }) })
16  .getByRole('button', { name: 'Buy' })
17  .click();
18
19// CSS and XPath (use sparingly - they break more easily)
20await page.locator('css=div.card >> text=Details').click();
21await page.locator('xpath=//div[@class="card"]//button').click();
Locator Priority GuideUse locators in this order of preference: (1) getByRole — (2) getByLabel / getByPlaceholder — (3) getByText — (4) getByTestId — (5) CSS / XPath as a last resort. The higher the priority, the more resilient the locator is to DOM refactoring.

Interactions

Once you have located an element, you need to interact with it. Playwright provides methods for clicking, typing, selecting, dragging, and more. All interactions auto-wait for the element to be visible, enabled, and stable before acting.

Click Actions

interactions-click.tstypescript
1// Standard click
2await page.getByRole('button', { name: 'Submit' }).click();
3
4// Double click
5await page.getByText('Select this text').dblclick();
6
7// Right click (context menu)
8await page.getByText('Show options').click({ button: 'right' });
9
10// Shift + Click (multi-select)
11await page.getByText('Item 3').click({ modifiers: ['Shift'] });
12
13// Click at specific position relative to the element center
14await page.locator('#canvas').click({ position: { x: 100, y: 200 } });
15
16// Force click (bypasses actionability checks - use as last resort)
17await page.getByRole('button', { name: 'Hidden' }).click({ force: true });

Form Interactions

interactions-form.tstypescript
1// Type into text fields
2await page.getByLabel('Username').fill('john_doe');
3await page.getByLabel('Bio').fill('Hello, I am a tester.');
4
5// Clear a field first, then type character by character (simulates real typing)
6await page.getByLabel('Search').clear();
7await page.getByLabel('Search').pressSequentially('Playwright', { delay: 50 });
8
9// Select from dropdown
10await page.getByLabel('Country').selectOption('US');
11await page.getByLabel('Country').selectOption({ label: 'United States' });
12await page.getByLabel('Colors').selectOption(['red', 'green']); // multi-select
13
14// Check / uncheck
15await page.getByLabel('Accept terms').check();
16await page.getByLabel('Notifications').uncheck();
17
18// Upload a file
19await page.getByLabel('Upload resume').setInputFiles('resume.pdf');
20await page.getByLabel('Upload photos').setInputFiles(['photo1.png', 'photo2.png']);
21// Clear file selection
22await page.getByLabel('Upload resume').setInputFiles([]);

Keyboard and Mouse

interactions-kb.tstypescript
1// Press individual keys
2await page.keyboard.press('Enter');
3await page.keyboard.press('Escape');
4await page.keyboard.press('Tab');
5
6// Key combinations
7await page.keyboard.press('Control+a');  // Select all
8await page.keyboard.press('Control+c');  // Copy
9await page.keyboard.press('Meta+v');     // Paste (Mac)
10
11// Type a string as keyboard input
12await page.keyboard.type('Hello World');
13
14// Hold and release keys manually
15await page.keyboard.down('Shift');
16await page.keyboard.press('ArrowDown');
17await page.keyboard.press('ArrowDown');
18await page.keyboard.up('Shift');
19
20// Hover
21await page.getByText('Menu').hover();
22
23// Drag and drop
24await page.locator('#source').dragTo(page.locator('#target'));
WarningAvoid using { force: true } unless you have a very specific reason. Forcing clicks bypasses Playwright's actionability checks, which means your test might interact with elements a real user could never reach. If you find yourself needing force frequently, the application likely has accessibility issues worth fixing.

Assertions

Assertions verify that the application is in the expected state. Playwright uses web-first assertions that automatically retry until the condition is satisfied or a timeout is reached. This is fundamentally different from immediate assertions in unit testing frameworks and is key to eliminating flakiness.

Common Assertions

tests/assertions.spec.tstypescript
1import { test, expect } from '@playwright/test';
2
3test('assertions showcase', async ({ page }) => {
4  await page.goto('/products');
5
6  // ----- Visibility -----
7  await expect(page.getByText('Product Catalog')).toBeVisible();
8  await expect(page.getByText('Loading...')).toBeHidden();
9
10  // ----- Text Content -----
11  await expect(page.getByRole('heading')).toHaveText('Product Catalog');
12  await expect(page.getByRole('heading')).toContainText('Product');
13
14  // ----- Page Title & URL -----
15  await expect(page).toHaveTitle(/Products/);
16  await expect(page).toHaveURL(/\/products$/);
17
18  // ----- Element Count -----
19  await expect(page.getByRole('listitem')).toHaveCount(12);
20
21  // ----- CSS Classes & Attributes -----
22  await expect(page.locator('.nav-link.active')).toHaveClass(/active/);
23  await expect(page.getByRole('link', { name: 'Docs' }))
24    .toHaveAttribute('href', '/docs');
25
26  // ----- Input Values -----
27  await expect(page.getByLabel('Search')).toHaveValue('');
28  await page.getByLabel('Search').fill('laptop');
29  await expect(page.getByLabel('Search')).toHaveValue('laptop');
30
31  // ----- Enabled / Disabled -----
32  await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
33  await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled();
34
35  // ----- Checked State -----
36  await expect(page.getByLabel('Remember me')).not.toBeChecked();
37  await page.getByLabel('Remember me').check();
38  await expect(page.getByLabel('Remember me')).toBeChecked();
39});

Waiting and Advanced Assertions

While Playwright auto-waits for most operations, there are scenarios where you need explicit waiting — for example, waiting for a network response or for an element to disappear.

tests/waiting.spec.tstypescript
1// Playwright assertions auto-retry by default (up to expect.timeout).
2// You can also configure the timeout per assertion:
3await expect(page.getByText('Data loaded'))
4  .toBeVisible({ timeout: 10_000 }); // wait up to 10 seconds
5
6// Wait for a specific condition using page.waitForSelector
7await page.waitForSelector('.results-table', { state: 'visible' });
8
9// Wait for a network response
10const responsePromise = page.waitForResponse('**/api/products');
11await page.getByRole('button', { name: 'Load Products' }).click();
12const response = await responsePromise;
13expect(response.status()).toBe(200);
14
15// Wait for navigation
16await Promise.all([
17  page.waitForURL('**/dashboard'),
18  page.getByRole('button', { name: 'Login' }).click(),
19]);
20
21// Wait for element to be detached from DOM
22await expect(page.locator('.spinner')).toHaveCount(0);
23
24// Soft assertions - don't stop the test on failure
25await expect.soft(page.getByText('Beta')).toBeVisible();
26await expect.soft(page.getByText('v2.0')).toBeVisible();
27// Test continues even if the above fail; failures are collected at the end
TipUse expect.soft() when you want to check multiple independent conditions without stopping the test at the first failure. All soft assertion failures are reported at the end. This is great for verifying a dashboard where multiple widgets should all render correctly.

Screenshots and Videos

Visual evidence is invaluable for debugging test failures, especially in CI environments where you cannot watch the browser in real time. Playwright supports on-demand screenshots, visual comparison testing, and automatic video recording.

Taking Screenshots

tests/visual.spec.tstypescript
1import { test, expect } from '@playwright/test';
2
3test('capture screenshots', async ({ page }) => {
4  await page.goto('/dashboard');
5
6  // Full page screenshot
7  await page.screenshot({ path: 'screenshots/dashboard-full.png', fullPage: true });
8
9  // Viewport only (what the user sees)
10  await page.screenshot({ path: 'screenshots/dashboard-viewport.png' });
11
12  // Screenshot of a specific element
13  const chart = page.locator('.revenue-chart');
14  await chart.screenshot({ path: 'screenshots/revenue-chart.png' });
15
16  // Visual comparison (golden file testing)
17  // First run creates the reference image, subsequent runs compare against it
18  await expect(page).toHaveScreenshot('dashboard.png', {
19    maxDiffPixelRatio: 0.01,  // Allow 1% pixel difference
20  });
21
22  // Element-level visual comparison
23  await expect(page.locator('.sidebar')).toHaveScreenshot('sidebar.png');
24});

Video Recording Configuration

Configure video recording in your playwright.config.ts file. The recommended approach is to record only on failure or first retry to save disk space.

playwright.config.tstypescript
1// In playwright.config.ts
2import { defineConfig } from '@playwright/test';
3
4export default defineConfig({
5  use: {
6    // Record video for every test (generates large files)
7    // video: 'on',
8
9    // Record only when retrying a failed test (recommended)
10    video: 'on-first-retry',
11
12    // Record only for failed tests
13    // video: 'retain-on-failure',
14
15    // Video resolution
16    video: {
17      mode: 'on-first-retry',
18      size: { width: 1280, height: 720 },
19    },
20
21    // Screenshot options
22    screenshot: 'only-on-failure',
23  },
24});
Visual Regression TestingThe toHaveScreenshot() method compares against a golden reference image. On the first run it creates the reference. On subsequent runs it compares pixel by pixel. Use npx playwright test --update-snapshots to refresh reference images after intentional visual changes.
TipStore screenshot baselines in your Git repository. This way, pull request reviews can show visual diffs alongside code changes. Set maxDiffPixelRatio or maxDiffPixels to account for minor rendering differences across platforms.

Reporting

Playwright ships with several built-in reporters and supports custom reporters. The HTML reporter and Trace Viewer are particularly powerful for diagnosing failures.

Reporter Configuration

playwright.config.tstypescript
1// playwright.config.ts - Reporter configuration
2import { defineConfig } from '@playwright/test';
3
4export default defineConfig({
5  reporter: [
6    // Built-in terminal reporter for live output
7    ['list'],
8
9    // HTML report with full details, screenshots, traces
10    ['html', {
11      open: 'never',           // 'always' | 'never' | 'on-failure'
12      outputFolder: 'playwright-report',
13    }],
14
15    // JUnit XML for CI/CD systems (Jenkins, GitLab, etc.)
16    ['junit', { outputFile: 'results/junit.xml' }],
17
18    // JSON reporter for custom processing
19    ['json', { outputFile: 'results/results.json' }],
20  ],
21
22  use: {
23    // Trace captures a timeline of every action, network request, and DOM snapshot
24    trace: 'on-first-retry',
25    // Options: 'on' | 'off' | 'on-first-retry' | 'retain-on-failure'
26  },
27});

Useful CLI Commands

terminalbash
1# Run tests
2npx playwright test
3
4# Open the HTML report after running tests
5npx playwright show-report
6
7# View a trace file (download from HTML report or CI artifacts)
8npx playwright show-trace trace.zip
9
10# Run with specific reporter override
11npx playwright test --reporter=dot
12
13# Run tests in headed mode (see the browser)
14npx playwright test --headed
15
16# Run a single test file
17npx playwright test tests/login.spec.ts
18
19# Run tests matching a name pattern
20npx playwright test -g "should display error"

Trace Viewer

The Trace Viewer is one of Playwright's most powerful debugging tools. It records a complete timeline of your test including:

  • Every action performed (clicks, fills, navigations)
  • DOM snapshots before and after each action
  • Network requests and responses
  • Console logs and errors
  • Screenshots at each step

You can open traces in the browser at trace.playwright.dev by dragging and dropping the trace zip file, or use the CLI command npx playwright show-trace trace.zip.

CI/CD Integration

Playwright works with all major CI/CD platforms. Below is a complete GitHub Actions workflow that runs tests, uploads the HTML report, and preserves trace files for failed tests:

.github/workflows/playwright.ymlyaml
1# .github/workflows/playwright.yml
2name: Playwright Tests
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10jobs:
11  test:
12    timeout-minutes: 15
13    runs-on: ubuntu-latest
14    steps:
15      - uses: actions/checkout@v4
16
17      - uses: actions/setup-node@v4
18        with:
19          node-version: 20
20
21      - name: Install dependencies
22        run: npm ci
23
24      - name: Install Playwright Browsers
25        run: npx playwright install --with-deps
26
27      - name: Run Playwright tests
28        run: npx playwright test
29
30      - name: Upload HTML report
31        uses: actions/upload-artifact@v4
32        if: always()
33        with:
34          name: playwright-report
35          path: playwright-report/
36          retention-days: 14
37
38      - name: Upload test traces
39        uses: actions/upload-artifact@v4
40        if: failure()
41        with:
42          name: playwright-traces
43          path: test-results/
44          retention-days: 7
WarningIn CI environments, always use npx playwright install --with-deps to install OS-level dependencies (fonts, libraries) that browsers need. Without these, you will get cryptic browser launch errors.

Full Example: E-commerce Checkout Test

Let us put everything together with a realistic test suite for an e-commerce application. This example demonstrates login helpers, search, form filling, API response interception, visual verification, and grouped tests with shared setup.

tests/checkout.spec.tstypescript
1import { test, expect, type Page } from '@playwright/test';
2
3// ---------------------------------------------------------------------------
4// Real-world E2E test: Online store checkout flow
5// ---------------------------------------------------------------------------
6
7// Helper function to log in before tests that need authentication
8async function loginAsCustomer(page: Page) {
9  await page.goto('/login');
10  await page.getByLabel('Email').fill('customer@example.com');
11  await page.getByLabel('Password').fill('SecurePass123!');
12  await page.getByRole('button', { name: 'Sign In' }).click();
13  await expect(page).toHaveURL(/.*account/);
14}
15
16test.describe('E-commerce Checkout Flow', () => {
17  test.beforeEach(async ({ page }) => {
18    await loginAsCustomer(page);
19  });
20
21  test('should search for a product and add it to cart', async ({ page }) => {
22    // Use the search bar
23    await page.getByPlaceholder('Search products...').fill('Wireless Headphones');
24    await page.getByPlaceholder('Search products...').press('Enter');
25
26    // Verify search results appeared
27    await expect(page.getByRole('heading', { name: 'Search Results' })).toBeVisible();
28    await expect(page.getByRole('listitem')).toHaveCount(3);
29
30    // Click the first product
31    await page
32      .getByRole('listitem')
33      .first()
34      .getByRole('link', { name: /Wireless Headphones/i })
35      .click();
36
37    // Verify product detail page
38    await expect(page).toHaveURL(/.*\/products\/\d+/);
39    await expect(page.getByRole('heading', { level: 1 })).toContainText('Wireless Headphones');
40
41    // Select options and add to cart
42    await page.getByLabel('Color').selectOption('Black');
43    await page.getByRole('button', { name: 'Add to Cart' }).click();
44
45    // Verify cart badge updated
46    await expect(page.getByTestId('cart-count')).toHaveText('1');
47  });
48
49  test('should complete checkout with valid payment', async ({ page }) => {
50    // Assume the product is already in the cart (from a previous action or fixture)
51    await page.goto('/cart');
52    await expect(page.getByRole('heading', { name: 'Shopping Cart' })).toBeVisible();
53
54    // Verify cart has items
55    const cartItems = page.getByRole('listitem').filter({
56      has: page.locator('[data-testid="cart-item"]'),
57    });
58    await expect(cartItems).toHaveCount(1);
59
60    // Proceed to checkout
61    await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
62    await expect(page).toHaveURL(/.*\/checkout/);
63
64    // Fill shipping information
65    await page.getByLabel('Full Name').fill('Jane Doe');
66    await page.getByLabel('Address').fill('123 Test Street');
67    await page.getByLabel('City').fill('San Francisco');
68    await page.getByLabel('State').selectOption('CA');
69    await page.getByLabel('ZIP Code').fill('94102');
70    await page.getByRole('button', { name: 'Continue to Payment' }).click();
71
72    // Fill payment information
73    await page.getByLabel('Card Number').fill('4242424242424242');
74    await page.getByLabel('Expiration').fill('12/28');
75    await page.getByLabel('CVC').fill('123');
76
77    // Place order and wait for API response
78    const orderPromise = page.waitForResponse(
79      (resp) => resp.url().includes('/api/orders') && resp.status() === 201
80    );
81    await page.getByRole('button', { name: 'Place Order' }).click();
82    const orderResponse = await orderPromise;
83    const orderData = await orderResponse.json();
84
85    // Verify confirmation page
86    await expect(page).toHaveURL(/.*\/order-confirmation/);
87    await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
88    await expect(page.getByText(orderData.orderId)).toBeVisible();
89
90    // Screenshot the confirmation for records
91    await page.screenshot({
92      path: `screenshots/order-${orderData.orderId}.png`,
93      fullPage: true,
94    });
95  });
96
97  test('should show validation errors for empty checkout form', async ({ page }) => {
98    await page.goto('/checkout');
99
100    // Click submit without filling anything
101    await page.getByRole('button', { name: 'Continue to Payment' }).click();
102
103    // Verify all required field errors appear
104    const requiredFields = ['Full Name', 'Address', 'City', 'ZIP Code'];
105    for (const fieldName of requiredFields) {
106      await expect(
107        page.getByText(`${fieldName} is required`)
108      ).toBeVisible();
109    }
110
111    // The URL should NOT have changed
112    await expect(page).toHaveURL(/.*\/checkout/);
113  });
114});

What This Example Demonstrates

  • Reusable helper functions — The loginAsCustomer function is extracted so multiple test suites can share it.
  • test.beforeEach — Every test starts logged in, reducing duplication.
  • Diverse locator strategies — The tests use getByPlaceholder, getByRole, getByLabel, getByText, and getByTestId depending on context.
  • Network response interception — The checkout test waits for the actual API response and uses the returned order ID in assertions.
  • Chaining and filtering — Cart items are located by filtering list items that contain a specific test ID.
  • Dynamic screenshots — The confirmation screenshot uses the order ID in the file name for traceability.
  • Form validation testing — The third test verifies client-side validation without needing any backend interaction.
TipFor larger projects, consider using Playwright fixtures to encapsulate login, database seeding, and other reusable setup. Fixtures compose better than helper functions and provide automatic cleanup.
Next StepsNow that you have the fundamentals, explore these advanced topics: API testing with request context, component testing, authentication state reuse via storageState, page object model patterns, and parameterized tests with test.describe.configure.