How to Set Up End-to-End Testing with Playwright in a CI Pipeline
E2E testing fails teams not because the tools are bad, but because the setup is an afterthought. Playwright gets installed locally, tests pass on one developer's machine, and then the pipeline either skips them entirely or runs them serially on a single CI runner — grinding builds to a halt. Here is how to do it properly from the start.
Why Playwright Belongs in CI From Day One
End-to-end tests are the last line of defence before a bug reaches a user. They simulate real browser behaviour — clicking buttons, filling forms, navigating flows — in a way that unit tests simply cannot replicate. But their value collapses if they only run locally or after a deploy to staging.
Wiring Playwright into your GitHub Actions pipeline from the first sprint means:
- Every pull request is validated against real user flows before merge
- Broken changes are caught in minutes, not hours after deployment
- The test suite grows alongside features, not behind them
The upfront investment is one afternoon. The payoff compounds for the life of the project.
Project Setup
First, install Playwright and initialise it inside your project:
npm init playwright@latest
# Select: TypeScript, tests/ folder, GitHub Actions workflow: Yes
The --init flow generates a playwright.config.ts and a starter GitHub Actions YAML. Do not ignore that YAML — use it as your foundation, not a throwaway template.
In playwright.config.ts, set your base URL to an environment variable so the same config works locally, in CI, and against staging:
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
Setting trace: 'on-first-retry' means Playwright only collects the expensive trace data when something actually goes wrong — a sensible default that keeps CI fast.
Wiring Up GitHub Actions
Create .github/workflows/e2e.yml. The anatomy of a production-grade workflow has four key concerns: dependency caching, browser installation, test execution, and artifact upload.
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
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
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shard }}
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
A few deliberate decisions here worth unpacking.
Parallel Test Sharding
The matrix.shard strategy splits your test suite across four runners simultaneously. A suite that takes 20 minutes on a single runner finishes in roughly 5 minutes with four shards. This is not just a convenience — it determines whether developers actually wait for CI results or start merging anyway.
The fail-fast: false flag is equally important. Without it, one failing shard cancels the others, which means you lose visibility into how many tests actually broke. Let all shards complete, then review the full picture.
Only install the browsers you need. Unless your test matrix explicitly covers Firefox and WebKit, installing only Chromium cuts browser installation time by roughly 60%.
Handling Flaky Tests With Retries
Flaky tests are the trust-killers of any test suite. A test that fails intermittently trains developers to re-run CI without reading the output. Playwright has a native retry mechanism that quarantines flakiness without hiding it:
// playwright.config.ts
retries: process.env.CI ? 2 : 0,
This retries failing tests up to twice, but only in CI. Locally, a failing test fails immediately, which keeps the feedback loop tight during development. The trace collected on first retry gives you a timestamped, step-by-step replay of exactly what Playwright saw — network requests, DOM snapshots, and all.
If a specific test consistently flakes due to a third-party dependency or animation timing, annotate it rather than deleting it:
test('payment flow completes', async ({ page }) => {
test.slow(); // triples the default timeout for this test
// ...
});
Use test.slow() sparingly. Widespread use is a signal that your application has performance problems, not your tests.
Artifact Uploads for Failed Screenshots
The upload-artifact step only fires on failure (if: failure()). This is critical. On success, you do not need the report. On failure, you need every pixel.
Playwright's HTML reporter bundles screenshots, traces, and video (if enabled) into a self-contained folder. Uploading that as a GitHub Actions artifact means any team member can download it, open the HTML report in a browser, and replay the failure — without needing to reproduce it locally.
Set retention-days: 7 as a starting point. Long enough to debug post-merge failures, short enough not to accumulate gigabytes of old reports.
Structuring Tests for a Long-Lived Suite
A few structural decisions will determine whether your suite scales or becomes a liability:
- Use Page Object Models. Encapsulate selectors and actions in classes. When the UI changes, you update one file, not thirty tests.
- Isolate test state. Each test should create its own data via API calls in
beforeEach, not depend on previous tests leaving state behind. - Tag tests by criticality. Use
@smoke,@regression, and@slowtags so you can run a fast smoke suite on every commit and the full regression suite nightly. - Avoid
waitForTimeout. Fixed waits are flakiness in disguise. UsewaitForSelector,waitForResponse, orexpect(locator).toBeVisible()instead.
Why This Matters for Your Project
Whether you are building a SaaS product, a fintech platform, or an internal tool, your CI pipeline is the immune system of your codebase. A Playwright setup that runs in parallel, retries sensibly, and surfaces evidence on failure turns E2E testing from a bottleneck into a competitive advantage. Shipping fast without confidence is just shipping bugs faster. Set this up once, set it up right, and your team will catch what matters before your users ever do.





