DevToolBoxGRATUIT
Blog

Guide Complet Playwright E2E Testing

14 minpar DevToolBox

Playwright E2E Testing: The Complete Guide for 2026

Playwright is the leading end-to-end testing framework for web applications. Created by Microsoft, it supports Chromium, Firefox, and WebKit with a single API, runs tests in parallel by default, and provides powerful features like auto-waiting, network interception, and visual regression testing. This guide covers setup, writing tests, advanced patterns, and CI/CD integration.

Why Playwright?

FeaturePlaywrightCypressSelenium
Multi-browserChromium, Firefox, WebKitChromium, Firefox, WebKitAll browsers
Parallel executionBuilt-in, per-workerPaid (Cypress Cloud)Via Selenium Grid
Auto-waitingYes (all actions)Yes (DOM only)Manual waits
Network mockingBuilt-in (route API)Built-in (intercept)External tools
Language supportJS/TS, Python, Java, C#JS/TS onlyMany languages
iframes / tabsFull supportLimitedFull support
Test generatorcodegen (records actions)Cypress StudioSelenium IDE
Trace viewerBuilt-in (visual timeline)Screenshots + videoLogs

Setup

# Initialize Playwright in your project
npm init playwright@latest

# This creates:
# playwright.config.ts — configuration
# tests/ — test directory
# tests-examples/ — example tests

# Or add to existing project
npm install -D @playwright/test
npx playwright install  # Download browser binaries

# Run the code generator to record tests
npx playwright codegen https://your-app.com

Configuration

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

export default defineConfig({
  testDir: './tests',
  testMatch: '**/*.spec.ts',

  // Run tests in parallel
  fullyParallel: true,
  workers: process.env.CI ? 2 : undefined,  // Auto-detect locally

  // Fail the build on CI if test.only is left in source
  forbidOnly: !!process.env.CI,

  // Retry on CI only
  retries: process.env.CI ? 2 : 0,

  // Reporter
  reporter: process.env.CI
    ? [['html', { open: 'never' }], ['github']]
    : [['html', { open: 'on-failure' }]],

  // Shared settings for all tests
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',          // Capture trace on retry
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 10_000,
    navigationTimeout: 30_000,
  },

  // Browser projects
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Mobile viewports
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 15'] },
    },
  ],

  // Start dev server before running tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Writing Tests

Basic Test Structure

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

test.describe('Home page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('has correct title', async ({ page }) => {
    await expect(page).toHaveTitle(/DevToolBox/);
  });

  test('navigation links work', async ({ page }) => {
    // Click a navigation link
    await page.getByRole('link', { name: 'Tools' }).click();

    // Assert the URL changed
    await expect(page).toHaveURL(/.*tools/);

    // Assert content loaded
    await expect(
      page.getByRole('heading', { name: 'Developer Tools' })
    ).toBeVisible();
  });

  test('search functionality', async ({ page }) => {
    const searchInput = page.getByPlaceholder('Search tools...');

    // Type in the search box
    await searchInput.fill('json');

    // Assert filtered results appear
    await expect(page.getByText('JSON Formatter')).toBeVisible();
    await expect(page.getByText('JSON to CSV')).toBeVisible();
  });
});

Locator Strategies

// Playwright locator strategies — from most to least recommended

// 1. getByRole — accessibility-based (best practice)
page.getByRole('button', { name: 'Submit' });
page.getByRole('heading', { name: 'Welcome', level: 1 });
page.getByRole('link', { name: 'Sign in' });
page.getByRole('textbox', { name: 'Email' });
page.getByRole('checkbox', { name: 'Remember me' });
page.getByRole('dialog');
page.getByRole('navigation');

// 2. getByText — for non-interactive text
page.getByText('No results found');
page.getByText(/welcome/i);  // Case-insensitive regex

// 3. getByLabel — form inputs by label
page.getByLabel('Password');
page.getByLabel('Email address');

// 4. getByPlaceholder — inputs by placeholder
page.getByPlaceholder('Enter your email');

// 5. getByTestId — data-testid attribute (when no better option)
page.getByTestId('user-avatar');
page.getByTestId('notification-badge');

// 6. CSS / XPath — last resort
page.locator('.card:nth-child(3)');
page.locator('[data-status="active"]');

// Chaining and filtering
page.getByRole('listitem')
  .filter({ hasText: 'Product A' })
  .getByRole('button', { name: 'Add to cart' });

// nth element
page.getByRole('listitem').nth(2);
page.getByRole('listitem').first();
page.getByRole('listitem').last();

Form Testing

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

test.describe('Login form', () => {
  test('successful login', async ({ page }) => {
    await page.goto('/login');

    // Fill form fields
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('securepassword');

    // Submit form
    await page.getByRole('button', { name: 'Sign in' }).click();

    // Assert redirect and success state
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('shows validation errors', async ({ page }) => {
    await page.goto('/login');

    // Submit empty form
    await page.getByRole('button', { name: 'Sign in' }).click();

    // Assert error messages
    await expect(page.getByText('Email is required')).toBeVisible();
    await expect(page.getByText('Password is required')).toBeVisible();
  });

  test('shows server error for wrong credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('wrong@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(
      page.getByText('Invalid email or password')
    ).toBeVisible();
  });
});

Network Mocking and Interception

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

test('displays products from API', async ({ page }) => {
  // Mock the API response
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Widget', price: 9.99 },
        { id: 2, name: 'Gadget', price: 24.99 },
        { id: 3, name: 'Doohickey', price: 14.99 },
      ]),
    });
  });

  await page.goto('/products');

  await expect(page.getByText('Widget')).toBeVisible();
  await expect(page.getByText('$9.99')).toBeVisible();
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

test('handles API errors gracefully', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({ status: 500 });
  });

  await page.goto('/products');
  await expect(page.getByText('Failed to load products')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('intercepts and modifies requests', async ({ page }) => {
  // Add auth header to all API requests
  await page.route('**/api/**', async (route) => {
    await route.continue({
      headers: {
        ...route.request().headers(),
        Authorization: 'Bearer test-token-123',
      },
    });
  });

  await page.goto('/dashboard');
});

test('waits for specific network request', async ({ page }) => {
  await page.goto('/products');

  // Click "Load more" and wait for the API call
  const responsePromise = page.waitForResponse('**/api/products?page=2');
  await page.getByRole('button', { name: 'Load more' }).click();
  const response = await responsePromise;

  expect(response.status()).toBe(200);
});

Page Object Model

// pages/LoginPage.ts
import { type Page, type Locator, expect } 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.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

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

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }

  async expectLoggedIn() {
    await expect(this.page).toHaveURL('/dashboard');
  }
}

// tests/login.spec.ts — using the Page Object
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('successful login', async () => {
    await loginPage.login('admin@test.com', 'password123');
    await loginPage.expectLoggedIn();
  });

  test('invalid credentials', async () => {
    await loginPage.login('wrong@test.com', 'wrong');
    await loginPage.expectError('Invalid email or password');
  });
});

Visual Regression Testing

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

test('homepage matches snapshot', async ({ page }) => {
  await page.goto('/');

  // Full page screenshot comparison
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    maxDiffPixelRatio: 0.01,  // Allow 1% pixel difference
  });
});

test('component visual test', async ({ page }) => {
  await page.goto('/components');

  // Screenshot a specific element
  const card = page.getByTestId('feature-card');
  await expect(card).toHaveScreenshot('feature-card.png');
});

// Update snapshots: npx playwright test --update-snapshots

Authentication State

// tests/auth.setup.ts — run once, save auth state
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('admin@test.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');

  // Save signed-in state to file
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts — reuse auth state
export default defineConfig({
  projects: [
    // Setup project — runs first
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // Tests that need authentication
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Running Tests

# Run all tests
npx playwright test

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

# Run tests matching a pattern
npx playwright test -g "login"

# Run in specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox

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

# Run in debug mode (step through)
npx playwright test --debug

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

# Show HTML report
npx playwright show-report

# Generate code by recording actions
npx playwright codegen http://localhost:3000

# Update visual snapshots
npx playwright test --update-snapshots

CI/CD Integration

# .github/workflows/e2e.yml
name: E2E Tests

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

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

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

      - name: Build application
        run: npm run build

      - name: Run E2E tests
        run: npx playwright test

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

      - name: Upload test traces
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-traces
          path: test-results/

Best Practices

  • Use role-based locators — getByRole is the most resilient and accessible selector strategy
  • Avoid hard waits — Playwright auto-waits; use assertions instead of sleep()
  • Isolate tests — each test should work independently; do not rely on test execution order
  • Mock external APIs — use page.route() to avoid flaky tests from network issues
  • Use Page Objects — encapsulate page interactions for maintainable, DRY tests
  • Run in CI with retries — set retries: 2 to handle transient failures
  • Capture traces on failure — trace: on-first-retry gives you a full debugging timeline
  • Test critical user flows — focus E2E tests on business-critical paths, not unit-level details

When testing APIs alongside your E2E tests, use our JSON Formatter to inspect mock data payloads. For understanding HTTP responses in your test assertions, check our HTTP Status Codes Reference. For setting up your test configuration files, our JSON vs YAML vs TOML comparison helps you choose the right format.

𝕏 Twitterin LinkedIn
Cet article vous a-t-il aidé ?

Restez informé

Recevez des astuces dev et les nouveaux outils chaque semaine.

Pas de spam. Désabonnez-vous à tout moment.

Essayez ces outils associés

{ }JSON Formatter.*Regex TesterJSXHTML to JSX

Articles connexes

CI/CD Pipeline : Bonnes pratiques avec GitHub Actions

Construisez des pipelines CI/CD robustes avec GitHub Actions — tests, dĂ©ploiement et sĂ©curitĂ©.

Guide Complet GitHub Actions : Workflows CI/CD

Apprenez GitHub Actions des bases aux avancées.

Bonnes pratiques TypeScript 2026 : Mode strict, types utilitaires et patterns

Maßtrisez les bonnes pratiques TypeScript : configuration stricte, types utilitaires avancés et unions discriminées.