How to Set Up End-to-End Testing With Playwright in CI/CD

Your unit tests are green. Your staging deploy looks fine. Then a customer screenshots a broken checkout flow at 2 AM. End-to-end (E2E) tests exist precisely to catch this — yet most engineering teams either skip them entirely or maintain a flaky suite that gets bypassed before every release anyway.

The problem is rarely the concept. It is the implementation. Poorly configured E2E tests are slow, non-deterministic, and developer-hostile. Playwright, when set up correctly, eliminates most of those complaints. This guide walks through a production-ready configuration — parallelisation, retries, artifact collection, and a GitHub Actions workflow that runs cleanly on every pull request.


Why Playwright Over Other E2E Tools

Playwright is not the only browser automation framework, but it is currently the most practical choice for teams shipping modern web applications. A few reasons stand out:

  • Multi-browser support out of the box — Chromium, Firefox, and WebKit with a single config.
  • Auto-waiting — Playwright waits for elements to be actionable before interacting, which eliminates most timing-related flakiness without manual sleep() calls.
  • Trace viewer — a built-in visual debugger that replays exactly what happened during a failed test, invaluable in CI where you cannot watch the browser yourself.
  • First-class TypeScript support — no extra setup needed.

Project Setup

Install Playwright into an existing Node.js project:

npm init playwright@latest
# Choose: TypeScript, tests/ folder, GitHub Actions workflow, install browsers

This scaffolds a playwright.config.ts and an example test. The generated GitHub Actions file is a starting point — not production-ready as-is.


Configuring playwright.config.ts for CI

The default config runs tests serially in a single worker. That is fine locally; it is unacceptable in CI. Here is a configuration built for speed and reliability:

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,       // prevent .only from breaking CI
  retries: process.env.CI ? 2 : 0,    // retry flaky tests in CI only
  workers: process.env.CI ? 4 : 2,    // parallelise across workers
  reporter: [
    ['html', { open: 'never' }],
    ['github'],                        // annotates PRs with test results
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',           // capture trace only when a retry happens
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  ],
});

Key decisions explained

fullyParallel: true runs every individual test file in its own worker simultaneously, not just files in parallel batches. On a 4-core CI runner this alone can cut suite runtime by 60-70%.

retries: 2 in CI only means a test must fail three consecutive times before it is marked as failed. This tolerates the occasional network hiccup or animation race without hiding genuinely broken behaviour.

trace: 'on-first-retry' strikes the right balance — you only pay the storage cost of a trace when something actually went wrong.


Writing Tests That Are Not Flaky by Design

Configuration helps, but test design matters more. A few principles that eliminate most sources of flakiness:

  • Use semantic selectors. Prefer getByRole('button', { name: 'Submit' }) over CSS selectors tied to implementation details like div.btn-primary.
  • Never assert on timing. Do not write expect(something).toBeTruthy() after a fixed timeout. Use toBeVisible() or toHaveText() — Playwright will wait automatically up to the configured timeout.
  • Isolate state. Each test should create its own data and not depend on another test's side effects. Use beforeEach hooks to seed state via API calls, not through the UI.
  • Mock third-party services. Use page.route() to intercept external API calls. Your E2E suite should not break because a payment sandbox is down.

The GitHub Actions Workflow

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: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

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

      - name: Start application
        run: npm run build && npm run start &
        env:
          NODE_ENV: test

      - name: Wait for app to be ready
        run: npx wait-on http://localhost:3000 --timeout 60000

      - name: Run Playwright tests
        run: npx playwright test
        env:
          BASE_URL: http://localhost:3000
          CI: true

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

Important notes on this workflow

timeout-minutes: 30 at the job level is a safety valve. Hung browser processes in CI can otherwise block a runner for hours.

--with-deps installs the OS-level dependencies (fonts, shared libraries) that Playwright browsers need on bare Ubuntu runners. Skipping this is the single most common reason Playwright fails silently in CI.

if: always() on the artifact upload step ensures the HTML report is saved even when tests fail — which is exactly when you need it most.


Sharding for Larger Suites

Once your suite grows beyond roughly 50 tests, consider sharding across multiple parallel jobs:

strategy:
  matrix:
    shard: [1/3, 2/3, 3/3]
steps:
  - name: Run Playwright tests
    run: npx playwright test --shard=${{ matrix.shard }}

Playwright merges the blob reports from each shard automatically when you run npx playwright merge-reports. This scales linearly — three shards, roughly one-third the wall-clock time.


Treating the Test Suite as a Deployment Gate

The final step is cultural, not technical: configure your GitHub branch protection rules to require the E2E job to pass before a PR can merge. This transforms the test suite from a nice-to-have into an actual safety net.

Teams that skip this step always find a reason to merge "just this once" — and that is how broken flows reach production at 2 AM.


Why This Matters for Your Project

Whether you are building a SaaS dashboard, a fintech mobile backend, or an e-commerce platform, the cost of a production regression far outweighs the investment in a well-configured E2E suite. With Playwright parallelised across workers, retries tuned for CI, and trace artifacts automatically uploaded on failure, your pipeline becomes the first line of defence — catching regressions in minutes rather than after a customer complaint. The setup described here can be applied to any Node.js web application and adapted to any deployment target within an afternoon.