Skip to main content

Playwright Complete Guide: Modern End-to-End Testing

Created: February 22, 2026 Larry Qu 5 min read

Introduction

Playwright is a modern end-to-end testing framework that enables reliable cross-browser automation. This guide covers everything from basic setup to advanced testing patterns.

Why Playwright?

Feature Playwright Cypress Selenium
Speed ⚡⚡⚡ ⚡⚡
Browser Support All major All major All major
API Testing ✅ Built-in Limited
Mobile ✅ Native Limited
Auto-wait ✅ Smart

Getting Started

Installation

# Install Playwright
npm init playwright@latest

# Install browsers
npx playwright install chromium
npx playwright install firefox
npx playwright install webkit

Basic Test

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

test('homepage has title', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example/);
});

test('login flow works', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', '[email protected]');
  await page.fill('[name="password"]', 'password123');
  await page.click('[type="submit"]');
  await expect(page).toHaveURL('/dashboard');
});

Locators

Finding Elements

// By text
await page.click('text=Submit');
await page.click('button:has-text("Submit")');

// By CSS
await page.click('#submit-btn');
await page.click('.btn.primary');

// By XPath
await page.click('//button[@type="submit"]');

// By role
await page.click('button[name="submit"]');
await page.getByRole('button', { name: 'Submit' }).click();

// By label
await page.fill('label:has-text("Email") >> ..input', '[email protected]');
await page.getByLabel('Email').fill('[email protected]');

// By placeholder
await page.fill('[placeholder="Enter email"]', '[email protected]');
await page.getByPlaceholder('Enter email').fill('[email protected]');

// By test ID
await page.getByTestId('submit-btn').click();

Waiting

// Auto-wait is built-in!
// But you can explicitly wait:

// Wait for element visible
await page.waitForSelector('.loading', { state: 'hidden' });

// Wait for URL
await page.waitForURL('/dashboard');

// Wait for response
await page.waitForResponse(response => response.url().includes('/api'));

// Wait for navigation
await page.waitForLoadState('networkidle');

Interactions

Forms

// Fill form
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'password');

// Checkbox
await page.check('#remember-me');
await page.uncheck('#remember-me');

// Select
await page.selectOption('#country', 'US');
await page.selectOption('#country', ['US', 'CA']);

// File upload
await page.setInputFiles('#upload', 'path/to/file.pdf');

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

Keyboard & Mouse

// Keyboard
await page.press('[name="search"]', 'Enter');
await page.press('[name="search"]', 'Control+a');
await page.keyboard.press('Tab');

// Hover
await page.hover('.menu-item');

// Click with options
await page.click('.btn', { button: 'right' }); // right click
await page.dblclick('.btn');
await page.click('.btn', { modifiers: ['Shift'] });

Assertions

Built-in Assertions

// Expect
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveScreenshot('homepage.png');

// Element assertions
await expect(page.locator('.user-name')).toHaveText('John Doe');
await expect(page.locator('.user-name')).toContainText('John');
await expect(page.locator('#checkbox')).toBeChecked();
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#email')).toBeEnabled();
await expect(page.locator('#submit')).toBeDisabled();

// Count
await expect(page.locator('.item')).toHaveCount(5);

// Custom
await expect(page.locator('.total')).toPass({
  timeout: 5000,
  hasNot: /error/i,
});

Soft Assertions

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

test('soft assertions', async ({ page }) => {
  await softExpect(page).toHaveTitle('Dashboard');
  await softExpect(page.locator('.user')).toHaveText('John');
  // Won't fail test if these fail
});

API Testing

Making Requests

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

test('API request', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'John',
      email: '[email protected]',
    },
  });
  
  expect(response.ok()).toBeTruthy();
  expect(response.status()).to);
  
Be(201  const data = await response.json();
  expect(data.name).toBe('John');
});

Mocking API

test('with mocked API', async ({ page }) => {
  await page.route('/api/user', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ name: 'Mocked User' }),
    });
  });
  
  await page.goto('/profile');
  await expect(page.locator('.user-name')).toHaveText('Mocked User');
});

Authentication

Login Once

import { test as base } from '@playwright/test';

const test = base.extend({
  authenticatedPage: async ({ page }, use }) {
    await page.goto('/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'password');
    await page.click('[type="submit"]');
    
    await use(page);
  },
});

test('dashboard', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/dashboard');
  // Already logged in!
});

Storage State

// login.spec.ts
test('login and save state', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', '[email protected]');
  await page.fill('[name="password"]', 'password');
  await page.click('[type="submit"]');
  
  await page.context().storageState({ path: 'storageState.json' });
});

// other-tests.spec.ts
test.use({ storageState: 'storageState.json' });

test('dashboard', async ({ page }) => {
  await page.goto('/dashboard'); // Already logged in
});

Page Object Pattern

// pages/Dashboard.ts
export class DashboardPage {
  constructor(private page: Page) {}
  
  async goto() {
    await this.page.goto('/dashboard');
  }
  
  get userName() {
    return this.page.locator('.user-name');
  }
  
  async logout() {
    await this.page.click('#logout-btn');
  }
}

// tests/dashboard.spec.ts
test('dashboard shows user', async ({ page }) => {
  const dashboard = new DashboardPage(page);
  await dashboard.goto();
  
  await expect(dashboard.userName).toHaveText('John Doe');
});

CI Integration

GitHub Actions

# .github/workflows/playwright.yml
name: Playwright Tests

on: [push, pull_request]

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 Playwright tests
        run: npx playwright test
      
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Configuration

playwright.config.ts

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Best Practices

1. Use Test Isolation

// ✅ Good - each test is independent
test('create user', async ({ page }) => {
  await createUser({ name: 'John' });
  await expect(page.locator('.user')).toHaveText('John');
});

test('delete user', async ({ page }) => {
  await deleteUser(1);
  await expect(page.locator('.user')).not.toBeVisible();
});

// ❌ Bad - tests depend on each other
let userId: number;

test('create user', async ({ page }) => {
  userId = await createUser({ name: 'John' });
});

test('delete user', async ({ page }) => {
  await deleteUser(userId); // Depends on previous test!
});

2. Avoid Sleeps

// ❌ Bad - arbitrary wait
await page.waitForTimeout(2000);

// ✅ Good - wait for condition
await expect(page.locator('.loaded')).toBeVisible();

3. Use Locators Wisely

// ✅ Good - specific locators
await page.getByRole('button', { name: 'Submit' }).click();

// ❌ Bad - fragile locators
await page.click('body > div > div:nth-child(2) > button');

Conclusion

Playwright is excellent for:

  • Cross-browser testing
  • API testing
  • Mobile testing
  • CI/CD integration
  • Modern web apps

Start with basic tests, then add API mocking and CI integration.


External Resources

Resources

Comments

Share this article

Scan to read on mobile