Playwright E2E Testing: A Professional Guide to End-to-End Testing for Web Applications

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Why E2E Testing Gets Skipped — and What It Costs You

Many teams write solid unit tests, but then deploy to staging only to discover something’s broken: the “Checkout” button doesn’t work on mobile, or the login form fails on Safari. I ran into exactly this situation — a React project with 82% unit test coverage, but a completely broken checkout flow on iPhone because a CSS z-index was hiding the submit button.

Unit tests can’t catch that kind of bug because they test individual functions in isolation. E2E tests are different — they simulate real user behavior: open a browser, fill out a form, click a button, verify the result.

Selenium used to be the go-to choice, but the setup is cumbersome, WebDriver version mismatches are common, and it’s slow. I once had a suite of 80 Selenium tests that took 12 minutes to run on CI. Playwright, released in 2020 by Microsoft, addresses nearly all of those problems: a modern API, support for Chromium/Firefox/WebKit, and parallelism by default — the equivalent suite now runs in about 2–3 minutes.

This guide walks through everything from installation to running real tests against a web application — a great starting point if you’re writing E2E tests for the first time.

Installing Playwright

Requirements

  • Node.js 18+ (check with node -v)
  • npm or yarn

Initializing the Project

The fastest way is to use Playwright’s built-in init command:

npm init playwright@latest

The command will ask a few questions:

  • TypeScript or JavaScript? → choose TypeScript if your project already uses TS
  • Where to put tests? → defaults to tests/
  • Add a GitHub Actions workflow? → Yes if you’re using CI
  • Install browsers? → Yes

Once complete, your project structure will look like this:

my-project/
├── tests/
│   └── example.spec.ts
├── playwright.config.ts
├── package.json
└── node_modules/

Installing Browsers Manually (if needed)

# Install all browsers
npx playwright install

# Or install Chromium only
npx playwright install chromium

Detailed Configuration

The playwright.config.ts File

playwright.config.ts is where you control the entire behavior of your test suite — timeouts, browsers, retries, and reporting. Here’s the config I use as a baseline:

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

export default defineConfig({
  // Directory containing test files
  testDir: './tests',

  // Run up to 4 tests in parallel
  workers: 4,

  // Retry once on failure (reduces flakiness)
  retries: 1,

  // Timeout per test: 30 seconds
  timeout: 30_000,

  // Reporters: HTML report + terminal output
  reporter: [['html', { open: 'never' }], ['list']],

  use: {
    // Base URL — use baseURL instead of hardcoding
    baseURL: 'http://localhost:3000',

    // Take a screenshot on test failure
    screenshot: 'only-on-failure',

    // Record video on test failure
    video: 'retain-on-failure',

    // Enable trace for debugging (replay step by step)
    trace: 'on-first-retry',
  },

  // Test across multiple browsers
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

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

Writing Your First Test

Now let’s write a real test. Create the file tests/login.spec.ts:

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

test.describe('Login Page', () => {

  test('successful login with valid credentials', async ({ page }) => {
    // Navigate to the login page
    await page.goto('/login');

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

    // Click the submit button
    await page.click('button[type="submit"]');

    // Verify redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome');
  });

  test('shows error message with wrong password', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    // Verify the error message is displayed
    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('Incorrect password');
  });

});

Using Page Object Model for Reusability

As you write more tests, you’ll notice yourself copy-pasting selectors all over the place. Page Object Model (POM) solves this by grouping all selectors for a given page into a dedicated class:

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[name="email"]');
    this.passwordInput = page.locator('[name="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.error-message');
  }

  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();
  }
}

Using it in a test:

// tests/login.spec.ts
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('[email protected]', 'secret123');
  await expect(page).toHaveURL('/dashboard');
});

Running Tests and Reviewing Results

Basic Test Commands

# Run all tests
npx playwright test

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

# Run only on Chrome
npx playwright test --project=chromium

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

# Run a specific test by name
npx playwright test -g "successful login"

# Open the HTML report
npx playwright show-report

Using Trace Viewer for Debugging

Test failing and you don’t know where to start? That’s when Trace Viewer earns its keep. It records every step like a video, complete with a DOM snapshot, network requests, and console logs at each point in time — no console.log statements required.

# Run with tracing enabled
npx playwright test --trace on

# Open the trace from the most recent run
npx playwright show-trace test-results/login-chromium/trace.zip

My debugging workflow: run the failing test → open the trace → inspect the screenshot at the failing step → check the DOM to see if the selector is correct. Much faster than re-running tests repeatedly with scattered log statements.

CI/CD Integration with GitHub Actions

If you chose “Yes” during init, Playwright already generated a GitHub Actions workflow file. Here’s the template I typically use:

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

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

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

      - name: Run E2E tests
        run: npx playwright test
        env:
          CI: true

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Practical Tips from the Field

For selectors, my priority order is: data-testid → ARIA role → label → text → CSS class. CSS classes and XPath selectors are brittle during UI refactors — rename one class and dozens of tests go red immediately.

// Good — stable, unaffected by styling changes
await page.click('[data-testid="submit-btn"]');
await page.click('role=button[name="Sign in"]');

// Avoid — breaks easily
await page.click('.btn.btn-primary.login-form__submit');

Network mocking is another area where Playwright outperforms most other tools. Need to test a “No products found” screen without deleting real data? Use page.route() to intercept the REST API request:

// Mock the API to return an empty list
await page.route('**/api/products', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([]),
  });
});

await page.goto('/products');
await expect(page.locator('.empty-state')).toBeVisible();

Another tool I regularly use when preparing test data: toolcraft.app — quickly format API JSON responses to understand the structure before writing assertions, much more convenient than installing a browser extension.

Wrapping Up

Playwright scales well from small to large projects — simple API, excellent debugging, fast execution. Start by writing tests for your most critical flows (login, checkout, form submission) and expand from there.

Compared to Selenium: no separate WebDriver needed, a far smarter selector API, and Trace Viewer is unmatched for debugging. Using Cypress? Playwright is worth trying — it supports more browsers out of the box and runs tests in parallel by default, no extra configuration required.

Share: