What Is Playwright?

Playwright is a modern browser automation framework created by Microsoft. It provides a single API to control Chromium, Firefox, and WebKit browsers. Released in 2020, it has rapidly become the most popular choice for new test automation projects.

Why Playwright?

FeaturePlaywrightSelenium
Auto-waitingBuilt-inManual waits required
Multi-browserChromium, Firefox, WebKitRequires separate drivers
SpeedVery fastModerate
LocatorsRole-based, text, test-idCSS, XPath, ID
DebuggingTrace Viewer, InspectorScreenshots only
API testingBuilt-inRequires separate tool
CodegenBuilt-inNot available
Parallel executionNativeRequires Grid
LanguagesJS/TS, Python, Java, C#All major languages

Setting Up Playwright

JavaScript/TypeScript

# Create a new project
npm init playwright@latest

# This creates:
# playwright.config.ts — configuration
# tests/ — test directory
# package.json — with Playwright dependency

Python

pip install playwright
playwright install

Project Structure

project/
├── playwright.config.ts
├── tests/
│   ├── login.spec.ts
│   ├── checkout.spec.ts
│   └── search.spec.ts
├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── CheckoutPage.ts
├── fixtures/
│   └── test-data.json
└── package.json

Writing Your First Test

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

test('user can login and see dashboard', async ({ page }) => {
  await page.goto('https://app.example.com/login');
  await page.fill('[data-testid="email"]', 'admin@test.com');
  await page.fill('[data-testid="password"]', 'secret123');
  await page.click('[data-testid="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('.welcome')).toHaveText('Welcome, Admin');
});

test('invalid login shows error', async ({ page }) => {
  await page.goto('https://app.example.com/login');
  await page.fill('[data-testid="email"]', 'wrong@test.com');
  await page.fill('[data-testid="password"]', 'wrongpass');
  await page.click('[data-testid="submit"]');

  await expect(page.locator('.error')).toHaveText('Invalid credentials');
  await expect(page).toHaveURL('/login');
});

Powerful Locators

Playwright provides multiple locator strategies beyond CSS and XPath:

// By role — the most resilient locator strategy
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('admin@test.com');
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('heading', { name: 'Welcome' });

// By label
await page.getByLabel('Email').fill('admin@test.com');
await page.getByLabel('Password').fill('secret');

// By placeholder
await page.getByPlaceholder('Enter your email').fill('admin@test.com');

// By text
await page.getByText('Sign In').click();
await page.getByText('Welcome, Admin');

// By test ID (recommended for custom attributes)
await page.getByTestId('login-submit').click();
await page.getByTestId('email-input').fill('admin@test.com');

Locator Chaining and Filtering

// Chain locators to narrow down
await page.locator('.product-card').filter({ hasText: 'Wireless Mouse' })
  .getByRole('button', { name: 'Add to Cart' }).click();

// Nth element
await page.locator('.product-card').nth(0).click();
await page.locator('.product-card').first().click();
await page.locator('.product-card').last().click();

// Has child
await page.locator('.card', { has: page.locator('.discount-badge') }).click();

Auto-Waiting

Playwright automatically waits for elements to be actionable:

// No manual waits needed — Playwright handles it
await page.click('#submit');
// Playwright waits for: element visible, stable, enabled, receiving events

await page.fill('#email', 'test@test.com');
// Playwright waits for: element visible, enabled, editable

await expect(page.locator('.result')).toHaveText('Success');
// Playwright retries until the assertion passes (within timeout)

Web-First Assertions

// These auto-retry until they pass or timeout
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL(/dashboard/);
await expect(page.locator('.status')).toHaveText('Active');
await expect(page.locator('.item')).toHaveCount(5);
await expect(page.locator('#btn')).toBeVisible();
await expect(page.locator('#btn')).toBeEnabled();
await expect(page.locator('#input')).toHaveValue('hello');
await expect(page.locator('.badge')).toHaveCSS('color', 'rgb(0, 128, 0)');

Configuration

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

export default defineConfig({
  testDir: './tests',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'results.xml' }],
  ],

  use: {
    baseURL: 'https://app.example.com',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 13'] },
    },
  ],
});

Advanced Features

Trace Viewer

The Trace Viewer is Playwright’s killer debugging feature:

# Record traces on failure (configured above)
npx playwright test

# View the trace
npx playwright show-trace trace.zip

The trace shows: screenshots at each step, DOM snapshots, network requests, console logs, and source code.

Codegen — Record Tests

# Open browser and record actions as test code
npx playwright codegen https://app.example.com

Codegen opens a browser and records your interactions, generating Playwright test code in real-time.

API Testing (Built-in)

test('create user via API', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Test User',
      email: 'test@example.com',
    },
  });

  expect(response.ok()).toBeTruthy();
  const user = await response.json();
  expect(user.name).toBe('Test User');
});

// Mix API and UI testing
test('admin creates user via API, verifies in UI', async ({ page, request }) => {
  // Create via API (fast)
  const response = await request.post('/api/users', {
    data: { name: 'New User', email: 'new@test.com' }
  });
  const user = await response.json();

  // Verify in UI
  await page.goto(`/admin/users/${user.id}`);
  await expect(page.locator('.user-name')).toHaveText('New User');
});

Network Interception

// Mock API responses
await page.route('/api/users', route => {
  route.fulfill({
    status: 200,
    body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
  });
});

// Wait for specific network request
const [response] = await Promise.all([
  page.waitForResponse('/api/checkout'),
  page.click('#pay-button'),
]);
expect(response.status()).toBe(200);

Multiple Browser Contexts

test('two users interact in real-time', async ({ browser }) => {
  const adminContext = await browser.newContext();
  const userContext = await browser.newContext();

  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();

  // Admin sends a message
  await adminPage.goto('/admin/chat');
  await adminPage.fill('#message', 'Hello user!');
  await adminPage.click('#send');

  // User sees the message
  await userPage.goto('/chat');
  await expect(userPage.locator('.message')).toHaveText('Hello user!');

  await adminContext.close();
  await userContext.close();
});

Page Object Model with Fixtures

// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type Fixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect } from '@playwright/test';

// test file
import { test, expect } from '../fixtures';

test('admin sees dashboard', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('admin@test.com', 'secret');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

Running Tests

# Run all tests
npx playwright test

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

# Run with specific browser
npx playwright test --project=firefox

# Run in headed mode (see the browser)
npx playwright test --headed

# Run with UI mode (interactive)
npx playwright test --ui

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

# Generate HTML report
npx playwright show-report

Exercise: Build a Playwright Test Suite

Create a complete Playwright test suite for a web application:

  1. Initialize a Playwright project with TypeScript
  2. Configure 3 browser projects (Chrome, Firefox, WebKit)
  3. Create Page Objects for Login, Dashboard, and Settings
  4. Write 8 tests: login (valid/invalid), navigation, form submission, API + UI mix, network mock, multi-viewport, screenshot comparison
  5. Configure traces and screenshots on failure
  6. Run tests in parallel across all browsers
  7. Generate and review the HTML report

Key Takeaways

  • Playwright’s auto-waiting eliminates most flaky tests caused by timing issues
  • Role-based locators are the most resilient strategy for finding elements
  • The Trace Viewer provides unmatched debugging capabilities
  • Built-in API testing allows mixing fast API setup with UI verification
  • Codegen records browser interactions and generates test code
  • Native parallel execution across Chromium, Firefox, and WebKit
  • Playwright fixtures integrate naturally with Page Object Model