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 likediv.btn-primary. - Never assert on timing. Do not write
expect(something).toBeTruthy()after a fixed timeout. UsetoBeVisible()ortoHaveText()— Playwright will wait automatically up to the configuredtimeout. - Isolate state. Each test should create its own data and not depend on another test's side effects. Use
beforeEachhooks 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.





