Skip to main content
Back to blog

End-to-End Automated QA Pipeline Using Azure DevOps

A complete end-to-end automated QA pipeline in Azure DevOps — from code commit to production sign-off. Covers all stages: static checks, unit tests, integration, E2E, security, performance, and approval gates with full YAML.

InnovateBits6 min read
Share

This article presents a complete, production-ready automated QA pipeline in Azure DevOps — the kind used by mature engineering teams. Every stage is justified, every gate is actionable, and the whole thing completes in under 25 minutes for a medium-sized application.


Pipeline overview

Stage 1:  Static (2 min)     → lint, types, secrets, SAST
Stage 2:  Unit (3 min)       → unit tests + coverage gate
Stage 3:  Integration (5 min)→ API, service tests + DB containers
Stage 4:  Build (2 min)      → Docker image + push to registry
Stage 5:  Deploy Staging (1 min) → deploy to staging environment
Stage 6:  Smoke (3 min)      → critical path tests on staging
Stage 7:  E2E (8 min)        → full Playwright regression (4 shards)
Stage 8:  Security (3 min)   → dependency scan + DAST
Stage 9:  QA Gate (manual)   → QA lead approves for production
Stage 10: Deploy Production  → blue/green production deployment

Total automated time: ~27 minutes. Manual gate: asynchronous (QA approves when ready).


Complete pipeline YAML

# azure-pipelines.yml
trigger:
  branches:
    include: [main]
pr:
  branches:
    include: [main]
schedules:
  - cron: "0 1 * * 1-5"
    displayName: Nightly regression
    branches:
      include: [main]
    always: true
 
pool:
  vmImage: ubuntu-latest
 
variables:
  - group: qa-environment-secrets
  - group: release-config
  - name: IMAGE_NAME
    value: $(Build.Repository.Name)
  - name: IMAGE_TAG
    value: $(Build.BuildId)
  - name: isPR
    value: ${{ eq(variables['Build.Reason'], 'PullRequest') }}
  - name: isMain
    value: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/main') }}
 
stages:
  # ── 1. Static checks ─────────────────────────────────────────────────────
  - stage: Static
    displayName: Static Analysis
    jobs:
      - job: Checks
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npm run lint -- --format=junit --output-file=results/lint.xml
          - script: npm run typecheck
          - script: npm audit --audit-level=high
          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: results/lint.xml
              testRunTitle: Lint — $(Build.BuildNumber)
            condition: always()
 
  # ── 2. Unit tests ─────────────────────────────────────────────────────────
  - stage: Unit
    dependsOn: Static
    displayName: Unit Tests
    jobs:
      - job: Jest
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: |
              npm test -- \
                --coverage \
                --coverageReporters=cobertura \
                --reporter=junit \
                --outputFile=results/unit.xml
          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: results/unit.xml
              testRunTitle: Unit — $(Build.BuildNumber)
            condition: always()
          - task: PublishCodeCoverageResults@1
            inputs:
              codeCoverageTool: Cobertura
              summaryFileLocation: coverage/cobertura-coverage.xml
            condition: always()
 
  # ── 3. Integration tests ──────────────────────────────────────────────────
  - stage: Integration
    dependsOn: Unit
    displayName: Integration Tests
    jobs:
      - job: API
        services:
          postgres:
            image: postgres:16
            env:
              POSTGRES_PASSWORD: testpass
              POSTGRES_DB: testdb
            ports: ['5432:5432']
            options: --health-cmd "pg_isready" --health-retries 5
          redis:
            image: redis:7-alpine
            ports: ['6379:6379']
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npm run db:migrate
            env:
              DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
          - script: npm run test:integration -- --reporter=junit --outputFile=results/integration.xml
            env:
              DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
              REDIS_URL: redis://localhost:6379
          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: results/integration.xml
              testRunTitle: Integration — $(Build.BuildNumber)
            condition: always()
 
  # ── 4. Build Docker image ─────────────────────────────────────────────────
  - stage: Build
    dependsOn: Integration
    condition: and(succeeded(), eq(variables.isMain, 'true'))
    displayName: Build Image
    jobs:
      - job: Docker
        steps:
          - task: Docker@2
            inputs:
              command: buildAndPush
              containerRegistry: $(ACR_SERVICE_CONNECTION)
              repository: $(IMAGE_NAME)
              dockerfile: Dockerfile
              tags: |
                $(IMAGE_TAG)
                latest
 
  # ── 5. Deploy to staging ──────────────────────────────────────────────────
  - stage: DeployStaging
    dependsOn: Build
    displayName: Deploy Staging
    jobs:
      - deployment: Staging
        environment: staging
        strategy:
          runOnce:
            deploy:
              steps:
                - script: |
                    az webapp config container set \
                      --name $(STAGING_APP_NAME) \
                      --resource-group $(RESOURCE_GROUP) \
                      --docker-custom-image-name $(ACR_NAME).azurecr.io/$(IMAGE_NAME):$(IMAGE_TAG)
                  displayName: Update staging container
                  env:
                    AZURE_SUBSCRIPTION: $(AZURE_SUBSCRIPTION)
 
  # ── 6. Smoke tests ────────────────────────────────────────────────────────
  - stage: Smoke
    dependsOn: DeployStaging
    displayName: Smoke Tests
    jobs:
      - job: PlaywrightSmoke
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npx playwright install --with-deps chromium
          - script: npx playwright test --grep @smoke
            env:
              BASE_URL: $(STAGING_URL)
          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: results/smoke.xml
              testRunTitle: Smoke — $(Build.BuildNumber)
            condition: always()
 
  # ── 7. Full E2E (sharded) ─────────────────────────────────────────────────
  - stage: E2E
    dependsOn: Smoke
    displayName: E2E Tests
    condition: and(succeeded(), ne(variables.isPR, 'true'))
    jobs:
      - job: Shard
        strategy:
          matrix:
            S1: { SHARD: '1/4' }
            S2: { SHARD: '2/4' }
            S3: { SHARD: '3/4' }
            S4: { SHARD: '4/4' }
          maxParallel: 4
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npx playwright install --with-deps chromium
          - script: npx playwright test --shard=$(SHARD) --reporter=blob
            env:
              BASE_URL: $(STAGING_URL)
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: blob-report
              artifact: blob-$(System.JobName)
            condition: always()
 
      - job: MergeAndPublish
        dependsOn: Shard
        condition: always()
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - task: DownloadPipelineArtifact@2
            inputs:
              targetPath: all-blobs
              patterns: 'blob-*/**'
          - script: npx playwright merge-reports --reporter=html,junit all-blobs
          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: results/merged.xml
              testRunTitle: E2E Regression — $(Build.BuildNumber)
            condition: always()
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: playwright-report
              artifact: e2e-report
            condition: always()
 
  # ── 8. Security ───────────────────────────────────────────────────────────
  - stage: Security
    dependsOn: Smoke
    displayName: Security Scan
    condition: and(succeeded(), ne(variables.isPR, 'true'))
    jobs:
      - job: OWASP
        steps:
          - script: |
              docker run --rm \
                -v $(Build.ArtifactStagingDirectory):/zap/wrk \
                ghcr.io/zaproxy/zaproxy:stable \
                zap-baseline.py -t $(STAGING_URL) -r zap-report.html -I
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: $(Build.ArtifactStagingDirectory)/zap-report.html
              artifact: security-report
            condition: always()
 
  # ── 9. QA approval gate ───────────────────────────────────────────────────
  - stage: QAApproval
    dependsOn: [E2E, Security]
    displayName: QA Sign-Off
    condition: and(succeeded(), ne(variables.isPR, 'true'))
    jobs:
      - job: Approval
        pool: server
        timeoutInMinutes: 2880   # 48 hours
        steps:
          - task: ManualValidation@0
            inputs:
              notifyUsers: $(QA_LEAD_EMAIL)
              instructions: |
                QA sign-off required for $(Build.BuildNumber).
                Review: E2E results and security report.
                Approve only if pass rate >= 95% and no critical security issues.
 
  # ── 10. Production deployment ─────────────────────────────────────────────
  - stage: Production
    dependsOn: QAApproval
    displayName: Deploy Production
    jobs:
      - deployment: Prod
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./scripts/deploy-production.sh $(IMAGE_TAG)
                  displayName: Blue/green production deploy

Common errors and fixes

Error: Stage 6 (Smoke) fails because staging isn't ready yet after deploy Fix: Add a readiness check before smoke tests: curl --retry 10 --retry-delay 5 $(STAGING_URL)/health. This waits up to 50 seconds for the app to start.

Error: E2E shards don't merge because artifact names conflict Fix: Each shard uploads to a unique artifact name (blob-$(System.JobName)). The merge job downloads all artifacts matching blob-*/**. Ensure System.JobName is unique per shard (it is when using matrix strategy).

Error: Security stage causes pipeline to fail even when only informational findings exist Fix: Use -I flag with ZAP baseline to ignore informational alerts. Only fail on WARN or FAIL level findings.

Error: Production deployment runs even when E2E stage partially failed Fix: The QA approval gate requires human review, which catches this. But also add condition: and(succeeded('E2E'), succeeded('Security')) to the QAApproval stage for automatic enforcement.

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