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.
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 deployCommon 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.
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