Running Playwright E2E Tests in CI/CD: GitHub Actions Setup
Running Playwright tests on your local machine is useful, but the real power comes from running them automatically on every push and pull request via CI/CD. GitHub Actions (free for public repos) makes this simple: every commit triggers your test suite, and PRs can't be merged without passing. This article covers GitHub Actions configuration, parallel test execution, artifact collection, and troubleshooting test failures in CI.
Why Run Playwright Tests in CI?
Running tests in CI ensures that: every code change is tested automatically (no human can forget to run tests before pushing), tests run in a consistent environment (same OS, browser versions, network conditions), and bugs are caught before code reaches production. Without CI, developers might ship untested code; with CI, tests are a non-negotiable gate.
Setting Up a Basic GitHub Actions Workflow
Create .github/workflows/playwright.yml:
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
# Check out the code
- uses: actions/checkout@v4
# Set up Node.js
- uses: actions/setup-node@v4
with:
node-version: 18
# Install dependencies
- run: npm install
# Install Playwright browsers
- run: npx playwright install --with-deps
# Run Playwright tests
- run: npm run test:e2e
# Upload test report (even if tests fail)
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
This workflow runs on every push to main or develop and every PR to main. It installs dependencies, downloads browser binaries, runs tests, and uploads the HTML report as an artifact.
Running Tests in Parallel Across Multiple Workers
By default, Playwright uses all available CPU cores. For GitHub Actions, increase parallelism by creating multiple job instances:
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
# Create 4 separate job instances, each running a shard of tests
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npx playwright install --with-deps
# Run a shard of the tests
- run: npm run test:e2e -- --shard=${{ matrix.shard }}/4
# Upload reports from each shard
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
This creates 4 parallel jobs, each running 1/4 of your tests. For a suite that takes 60 seconds sequentially, parallel sharding reduces CI time to ~15 seconds.
Handling Test Retries and Flaky Tests
Some tests are flaky (occasionally fail due to timing, network issues). Configure retries:
- run: npm run test:e2e -- --retries=2
Or in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0, // retry twice in CI, no retries locally
workers: process.env.CI ? 1 : undefined, // single worker in CI for stability
// ... rest of config
});
Retries help with flakiness, but don't mask real bugs. If a test fails 3+ times in a row, investigate the root cause instead of increasing retries.
Configuring Playwright for CI
Update your playwright.config.ts for CI:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// In CI, run slower, with fewer workers
workers: process.env.CI ? 1 : undefined,
// Set a reasonable timeout for CI (longer than local)
timeout: process.env.CI ? 60000 : 30000,
// Retries in CI, not locally
retries: process.env.CI ? 2 : 0,
// Always record videos of failures in CI for debugging
recordVideo: process.env.CI ? 'retain-on-failure' : 'off',
// Trace for debugging (larger file, save on failure)
trace: process.env.CI ? 'on-first-retry' : 'off',
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
// Start dev server if running tests
webServer: process.env.CI
? undefined // In CI, assume server is already running
: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: true,
},
projects: [
{ name: 'chromium', use: { ...devices.chromium } },
{ name: 'firefox', use: { ...devices.firefox } },
{ name: 'webkit', use: { ...devices.webkit } },
],
});
Starting the Dev Server in CI
If your tests need a running dev server, start it in CI:
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm install
- run: npx playwright install --with-deps
# Start the dev server in the background
- run: npm run dev &
# Wait for the server to be ready
- run: npx wait-on http://localhost:3000 --timeout 30000
# Run tests
- run: npm run test:e2e
# Upload reports
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
Or use a dedicated action to start the server:
- uses: BertalanD/desktop-headless-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
port: 3000
build-script: npm run build
run-script: npm run preview
Collecting and Reporting Test Results
Upload test reports and artifacts for easy debugging:
# Upload HTML report as artifact
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# Upload videos of failed tests
- uses: actions/upload-artifact@v3
if: always()
with:
name: test-videos
path: test-results/
retention-days: 7
# Publish test results as GitHub check
- uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results.xml
check_name: Playwright Test Results
In the GitHub Actions UI, you can see the report as an artifact. Click "playwright-report" to download and open the interactive HTML report.
Conditional Test Runs
Only run E2E tests when relevant code changes:
on:
push:
branches: [main]
paths:
- 'src/**' # Run on source changes
- 'tests/**' # Run on test changes
- '.github/workflows/playwright.yml' # Run on workflow changes
- '!**.md' # Don't run on README changes
jobs:
test:
# ... rest of config
This prevents unnecessary test runs on documentation updates.
Handling Environment Variables and Secrets
Store sensitive data (API keys, test credentials) in GitHub Secrets:
# In GitHub Settings → Secrets, create PLAYWRIGHT_TEST_API_KEY
env:
PLAYWRIGHT_TEST_API_KEY: ${{ secrets.PLAYWRIGHT_TEST_API_KEY }}
PLAYWRIGHT_TEST_BASE_URL: https://staging.example.com
jobs:
test:
runs-on: ubuntu-latest
steps:
# ...
- run: npm run test:e2e
In your tests, access environment variables:
test('api request with auth', async ({ page }) => {
const apiKey = process.env.PLAYWRIGHT_TEST_API_KEY;
await page.route('**/api/**', route => {
route.fulfill({
status: 200,
headers: { 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ success: true }),
});
});
});
Testing Against Multiple Node Versions
Test compatibility across Node versions:
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
# ... rest of steps
This creates separate jobs for each Node version, ensuring your tests pass on all supported versions.
Failure Notification and Debugging
Receive notifications when tests fail:
# Send Slack notification on failure
- uses: slackapi/slack-github-action@v1
if: failure()
with:
payload: |
{
"text": "Playwright tests failed on ${{ github.ref }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Playwright tests failed\n*Repo:* ${{ github.repository }}\n*Branch:* ${{ github.ref }}\n*Commit:* ${{ github.sha }}"
}
}
]
}
Or post a comment on the PR:
- uses: actions/github-script@v7
if: failure()
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Playwright tests failed. Check the artifacts for details.'
})
Optimizing CI Performance
Tips for faster CI:
- Cache dependencies: Store
node_modulesbetween runs
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
-
Shard tests across multiple jobs (covered above)
-
Only test changed code:
- run: npm run test:e2e -- --testPathPattern=$(git diff --name-only HEAD~1 | grep -oP 'tests/\K[^/]+' | sort -u | paste -sd '|')
-
Use smaller test dataset (mock APIs, use test fixtures)
-
Parallelize across OS: Test on Windows, macOS, Linux simultaneously
Key Takeaways
- GitHub Actions runs Playwright tests automatically on every push and PR, preventing untested code from reaching production.
- Shard tests across multiple jobs to reduce CI time: a 60-second suite becomes 15 seconds with 4 shards.
- Upload HTML reports and videos as artifacts for easy debugging.
- Configure timeouts and retries differently for CI vs. local (CI should be more lenient).
- Store secrets in GitHub Secrets and pass them as environment variables.
Frequently Asked Questions
How long should Playwright tests take in CI?
A typical suite of 50 tests takes 20–60 seconds with 4 parallel shards. If slower, investigate: are tests waiting for slow APIs? Can you mock them? Are tests running sequentially instead of in parallel?
Can I run Playwright tests in Docker?
Yes, use the Playwright Docker image: mcr.microsoft.com/playwright:v1.48.2-focal. This ensures consistent browser rendering across environments.
How do I debug a test that passes locally but fails in CI?
Use video recordings and traces:
export default defineConfig({
recordVideo: process.env.CI ? 'retain-on-failure' : 'off',
trace: process.env.CI ? 'on-first-retry' : 'off',
});
Download the video/trace from the artifact and play it locally.
Should I run tests on every branch or only main?
Run on all branches. Catch bugs early before PRs are merged. Use on: [push, pull_request] in your workflow.
How do I test against a staging environment in CI?
Set baseURL via environment variable:
env:
PLAYWRIGHT_TEST_BASE_URL: https://staging.example.com
steps:
- run: npm run test:e2e
Can I trigger tests manually?
Yes, add workflow_dispatch:
on:
push:
pull_request:
workflow_dispatch: # Allows manual trigger from Actions tab