Skip to main content
โšก Calmops

Playwright Complete Guide: Modern End-to-End Testing

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

Comments