← Blog

"Playwright Series #10: CI/CD Integration — GitLab, GitHub Actions and Docker"

The final post in the series. Wire your complete Playwright suite into GitLab CI and GitHub Actions with Docker containers, sharding, artifact uploads and Slack notifications on failure.

reading now
views
comments

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:

  1. ✅ Multi-browser support (Chromium, Firefox, WebKit)
  2. ✅ Semantic, resilient locators using getByRole, getByLabel, getByTestId
  3. ✅ Web-first assertions with auto-waiting and soft assertion support
  4. ✅ Page Object Model keeping tests maintainable at scale
  5. ✅ Network mocking for fast, deterministic tests
  6. storageState authentication — login once, reuse everywhere
  7. ✅ Visual regression testing across viewports and themes
  8. ✅ Parallel workers and multi-machine sharding
  9. ✅ 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.

0 / 1000