Series Navigation
← Part 9: Parallel Execution, Sharding and Reporting
→ Part 11: Docker Setup and Running Tests in Kubernetes Pods
What We're Building
A production CI pipeline that:
- Runs on every pull request and merge to main
- Uses a Docker container for consistent browser environments
- Runs tests in parallel across multiple workers
- Uploads the HTML report as a CI artifact
- Retries flaky tests once
- Sends a Slack notification on failure
The Docker Image
The Playwright team publishes an official Docker image with all browsers pre-installed:
# Dockerfile.playwright
FROM mcr.microsoft.com/playwright:v1.44.0-jammy
WORKDIR /app
# Install dependencies first (cached layer)
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
Using Docker eliminates "works on my machine" problems — the CI environment matches exactly.
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
playwright:
name: Playwright Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
options: --user root
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4] # 4 parallel shards
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run Playwright tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
CI: true
BASE_URL: ${{ secrets.BASE_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload blob report
if: always() # upload even if tests fail
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-reports/
retention-days: 7
merge-reports:
name: Merge Reports
needs: playwright
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: playwright-html-report
path: playwright-report/
retention-days: 14
- name: Notify Slack on failure
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_TITLE: '🔴 Playwright Tests Failed'
SLACK_MESSAGE: |
Branch: ${{ github.ref_name }}
Commit: ${{ github.sha }}
Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GitLab CI
# .gitlab-ci.yml
stages:
- test
- report
variables:
BASE_URL: "https://staging.yourapp.com"
.playwright-base:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
before_script:
- npm ci
artifacts:
when: always
paths:
- blob-reports/
- test-results/
reports:
junit: results.xml
expire_in: 1 week
playwright-shard-1:
extends: .playwright-base
stage: test
script:
- npx playwright test --shard=1/4
variables:
SHARD: "1"
playwright-shard-2:
extends: .playwright-base
stage: test
script:
- npx playwright test --shard=2/4
variables:
SHARD: "2"
playwright-shard-3:
extends: .playwright-base
stage: test
script:
- npx playwright test --shard=3/4
variables:
SHARD: "3"
playwright-shard-4:
extends: .playwright-base
stage: test
script:
- npx playwright test --shard=4/4
variables:
SHARD: "4"
merge-report:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
stage: report
when: always
needs:
- playwright-shard-1
- playwright-shard-2
- playwright-shard-3
- playwright-shard-4
before_script:
- npm ci
script:
- npx playwright merge-reports --reporter html ./blob-reports
artifacts:
paths:
- playwright-report/
expire_in: 2 weeks
GitLab Test Summary
GitLab reads JUnit XML natively and shows a test results panel in every MR. Configure JUnit output:
// playwright.config.ts
reporter: process.env.CI ? [
['dot'],
['junit', { outputFile: 'results.xml' }],
['blob', { outputDir: './blob-reports' }]
] : [
['list'],
['html', { open: 'on-failure' }]
],
The playwright.config.ts for CI
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config({ path: process.env.CI ? '.env.ci' : '.env.test' });
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // fail if test.only is committed
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
timeout: 30_000,
globalSetup: './tests/global-setup.ts',
expect: {
timeout: 10_000,
},
reporter: process.env.CI ? [
['dot'],
['junit', { outputFile: 'results.xml' }],
['blob', { outputDir: './blob-reports' }],
] : [
['list'],
['html', { open: 'on-failure' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
storageState: '.auth/user.json',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
// Only run Firefox on main branch to save time on PRs
grep: process.env.CI_COMMIT_BRANCH === 'main' ? undefined : /never/,
},
],
});
forbidOnly: !!process.env.CI is a safety net — if someone commits test.only accidentally, the CI build fails rather than running just one test silently.
Running Against Different Environments
Use environment variables to point tests at different deployments:
# GitHub Actions — run against preview deployment
- name: Run tests against PR preview
run: npx playwright test
env:
BASE_URL: ${{ steps.deploy.outputs.preview_url }}
# GitLab — different BASE_URL per environment
playwright-staging:
extends: .playwright-base
variables:
BASE_URL: "https://staging.yourapp.com"
only:
- main
playwright-preview:
extends: .playwright-base
variables:
BASE_URL: "$CI_ENVIRONMENT_URL"
only:
- merge_requests
Caching Dependencies
Node modules take time to install. Cache them:
# GitHub Actions
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# GitLab
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
Security — Managing Secrets
Never hardcode credentials in test code or CI config files.
GitHub Actions: Store in Settings → Secrets → Actions, access as ${{ secrets.MY_SECRET }}
GitLab: Settings → CI/CD → Variables, access as $MY_VARIABLE
// global-setup.ts — reads from environment
async function globalSetup() {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error(
'TEST_USER_EMAIL and TEST_USER_PASSWORD must be set. ' +
'Copy .env.example to .env.test and fill in values.'
);
}
// ...
}
The Complete File Structure
After completing this series, your project looks like:
my-playwright-project/
├── .auth/ # gitignored session files
│ ├── user.json
│ └── admin.json
├── tests/
│ ├── fixtures/
│ │ └── index.ts # custom test + page object fixtures
│ ├── pages/
│ │ ├── BasePage.ts
│ │ ├── LoginPage.ts
│ │ ├── ShopPage.ts
│ │ └── CheckoutPage.ts
│ ├── components/
│ │ └── DataTable.ts
│ ├── matchers/
│ │ └── currency.ts # custom expect matchers
│ ├── screenshots/ # visual test baselines
│ ├── global-setup.ts # one-time auth setup
│ ├── login.spec.ts
│ ├── checkout.spec.ts
│ ├── admin.spec.ts
│ └── api.spec.ts
├── .github/workflows/
│ └── playwright.yml # GitHub Actions
├── .gitlab-ci.yml # GitLab CI
├── Dockerfile.playwright # consistent environment
├── playwright.config.ts
├── .env.example # template (committed)
├── .env.test # actual values (gitignored)
└── package.json
What You've Built
Across this 10-part series you've gone from zero to a production-grade Playwright framework with:
- ✅ Multi-browser support (Chromium, Firefox, WebKit)
- ✅ Semantic, resilient locators using
getByRole,getByLabel,getByTestId - ✅ Web-first assertions with auto-waiting and soft assertion support
- ✅ Page Object Model keeping tests maintainable at scale
- ✅ Network mocking for fast, deterministic tests
- ✅
storageStateauthentication — login once, reuse everywhere - ✅ Visual regression testing across viewports and themes
- ✅ Parallel workers and multi-machine sharding
- ✅ Full CI/CD integration with GitHub Actions and GitLab
This foundation handles test suites of any size — from a startup's 20 tests to an enterprise's 2,000.
The best next step: pick one real application you're working on and start writing. The skills compound fast once you're applying them to real problems.
Have questions about any part of this series? Use the comments or reach out — happy to go deeper on any topic.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.