Skip to main content
Back to blog

Playwright + Azure DevOps CI/CD Integration: Real Example

A real-world example of integrating a Playwright test framework with Azure DevOps CI/CD. Covers project setup, playwright.config.ts for CI, parallel execution, trace collection, Azure Test Plans integration, and multi-environment testing.

InnovateBits4 min read
Share

This article demonstrates a production Playwright + Azure DevOps integration used by a real QA team — a 4-person QA team running 280 Playwright tests across 3 environments with a 12-minute pipeline runtime.


Project structure

tests/
├── e2e/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── logout.spec.ts
│   ├── checkout/
│   │   ├── cart.spec.ts
│   │   └── payment.spec.ts
│   └── shared/
│       ├── smoke.spec.ts          # @smoke tagged tests
│       └── regression.spec.ts    # @regression tagged
├── fixtures/
│   ├── auth.ts                    # Authentication fixtures
│   └── test-data.ts
├── setup/
│   └── global-setup.ts           # Auth state creation
├── playwright.config.ts
└── azure-pipelines.yml

playwright.config.ts optimised for CI

import { defineConfig, devices } from '@playwright/test'
 
const isCI = !!process.env.CI
 
export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: isCI,
  retries: isCI ? 2 : 0,
  workers: isCI ? 6 : 4,
  timeout: isCI ? 45_000 : 30_000,
  expect: {
    timeout: isCI ? 10_000 : 5_000,
  },
  globalSetup: './tests/setup/global-setup.ts',
  reporter: [
    ['list'],
    ['junit', { outputFile: 'test-results/results.xml' }],
    ['html', { outputFolder: 'playwright-report', open: 'never' }],
    isCI ? ['github'] : ['dot'],  // GitHub annotations in CI
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    storageState: 'tests/setup/.auth/user.json',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
    actionTimeout: isCI ? 15_000 : 8_000,
    navigationTimeout: isCI ? 30_000 : 15_000,
  },
  projects: [
    // Setup project — creates auth state
    { name: 'setup', testMatch: /global-setup\.ts/ },
    // Chrome (main)
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    // Mobile Chrome
    {
      name: 'mobile',
      use: { ...devices['Pixel 7'] },
      dependencies: ['setup'],
      testMatch: '**/mobile/**/*.spec.ts',
    },
  ],
})

Global setup for authentication

// tests/setup/global-setup.ts
import { chromium, FullConfig } from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
 
async function globalSetup(config: FullConfig) {
  const authDir = path.join(__dirname, '.auth')
  if (!fs.existsSync(authDir)) fs.mkdirSync(authDir, { recursive: true })
 
  const browser = await chromium.launch()
  const page = await browser.newPage()
 
  await page.goto(`${config.projects[0].use.baseURL}/login`)
  await page.fill('[data-testid="email"]', process.env.TEST_EMAIL!)
  await page.fill('[data-testid="password"]', process.env.TEST_PASSWORD!)
  await page.click('[data-testid="sign-in"]')
  await page.waitForURL('**/dashboard', { timeout: 30_000 })
 
  await page.context().storageState({ path: path.join(authDir, 'user.json') })
  await browser.close()
}
 
export default globalSetup

Multi-environment pipeline

trigger:
  branches:
    include: [main]
 
pr:
  branches:
    include: [main]
 
pool:
  vmImage: ubuntu-latest
 
variables:
  - group: playwright-secrets
 
stages:
  # ── PR validation: smoke tests only ──────────────────────────────────────
  - stage: SmokePR
    displayName: Smoke Tests (PR)
    condition: eq(variables['Build.Reason'], 'PullRequest')
    jobs:
      - job: Smoke
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              grepTag: '@smoke'
              runTitle: Smoke — PR $(System.PullRequest.PullRequestNumber)
              environment: staging
 
  # ── Main branch: full regression ─────────────────────────────────────────
  - stage: Regression
    displayName: Full Regression
    condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
    jobs:
      - job: Shard1
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              shard: '1/4'
              runTitle: Regression Shard 1/4
              environment: staging
      - job: Shard2
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              shard: '2/4'
              runTitle: Regression Shard 2/4
              environment: staging
      - job: Shard3
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              shard: '3/4'
              runTitle: Regression Shard 3/4
              environment: staging
      - job: Shard4
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              shard: '4/4'
              runTitle: Regression Shard 4/4
              environment: staging
 
  # ── Nightly: UAT environment ──────────────────────────────────────────────
  - stage: NightlyUAT
    displayName: Nightly UAT Regression
    condition: eq(variables['Build.Reason'], 'Schedule')
    jobs:
      - job: UATFull
        steps:
          - template: .azure/playwright-job.yml
            parameters:
              runTitle: Nightly UAT — $(Build.BuildNumber)
              environment: uat

Template .azure/playwright-job.yml:

parameters:
  - name: grepTag
    default: ''
  - name: shard
    default: ''
  - name: runTitle
    default: 'Playwright'
  - name: environment
    default: 'staging'
 
steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
  - script: npm ci
  - script: npx playwright install --with-deps chromium
  - script: |
      ARGS=""
      if [ -n "${{ parameters.grepTag }}" ]; then
        ARGS="$ARGS --grep ${{ parameters.grepTag }}"
      fi
      if [ -n "${{ parameters.shard }}" ]; then
        ARGS="$ARGS --shard=${{ parameters.shard }}"
      fi
      npx playwright test $ARGS
    env:
      BASE_URL: $(${{ parameters.environment }}_URL)
      TEST_EMAIL: $(TEST_EMAIL)
      TEST_PASSWORD: $(TEST_PASSWORD)
      CI: true
  - task: PublishTestResults@2
    inputs:
      testResultsFormat: JUnit
      testResultsFiles: test-results/results.xml
      testRunTitle: ${{ parameters.runTitle }}
    condition: always()
  - task: PublishPipelineArtifact@1
    inputs:
      targetPath: playwright-report
      artifact: playwright-report-$(System.JobName)
    condition: always()

Common errors and fixes

Error: Global setup times out creating auth state Fix: The login page may be slow on the first load in CI. Increase the waitForURL timeout to 60 seconds. Also check that TEST_EMAIL and TEST_PASSWORD environment variables are correctly set.

Error: Tests pass individually but fail when run in parallel Fix: Tests share auth state but not browser contexts. Each test gets its own page with the shared storage state. If tests are writing data that other tests read, add unique identifiers (UUIDs) to created records.

Error: playwright.config.ts import error in pipeline Fix: Ensure @playwright/test is in devDependencies and npm ci runs before npx playwright test. The node_modules directory must exist before Playwright config is loaded.

Free newsletter

Stay ahead in AI-driven QA

Get practical tutorials on test automation, AI testing, and quality engineering — straight to your inbox. No spam, unsubscribe any time.

Discussion

Sign in with GitHub to comment · powered by Giscus