Design and implement a testing strategy for pipelines
Deciding what to test and where, turning those tests into automated gates that can stop a release, and reporting the results so the pipeline tells you something useful when it goes red.
Quality and release gates
A gate is an automated condition that must hold before a deployment proceeds (or is considered complete). An approval is the human equivalent. Together with checks they are how governance is expressed in a pipeline.
coverage ≥ 80%
no critical CVEs| S[Deploy to staging] S --> PRE{Pre-deployment gate
+ approval} PRE -->|no active alerts
within business hours
reviewer approved| P[Deploy to production] P --> POST{Post-deployment gate} POST -->|health stable for 15 min| DONE[Release complete] POST -->|error rate spike| RB[Roll back]
Azure Pipelines — environment checks
In YAML pipelines, governance lives on the environment, not in the YAML file. Configure it under Pipelines → Environments → [name] → Approvals and checks.
| Check | Enforces |
|---|---|
| Approvals | Named users/groups must approve; configurable timeout and "requester cannot approve" |
| Branch control | Deployment may only run from allowed branches (e.g. refs/heads/main), optionally requiring branch protection |
| Business hours | Deployment only proceeds within a defined time window / days |
| Query Azure Monitor alerts | No active alerts matching the filter — the classic auto-rollback signal |
| Invoke REST API | An external system (CAB, ITSM, ServiceNow) returns success |
| Invoke Azure Function | Custom validation logic; supports async callback for long-running checks |
| Required template | The pipeline must extends an approved YAML template — the governance backbone |
| Evaluate artifact | Container image must satisfy a policy (Rego) before deployment |
| Exclusive lock | Only one run at a time may proceed to this environment |
Caveat — checks are configured in the UI, not the YAML
A very common trap: the YAML only names the environment. The approval, branch control and gates are configured on the environment object in the Azure DevOps UI (or via REST/CLI). If a question asks "where do you add a production approval to a YAML pipeline", the answer is on the environment — not a task, not a stage property.
"Required template" is how you enforce standards at scale
The required template check forces every pipeline deploying to an environment to extends: a centrally-owned template. Because an extends-template can restrict which tasks and steps are permitted, this is the mechanism for "ensure every production deployment runs the mandatory security scan" — the pipeline author cannot opt out.
Classic release pipeline gates
Classic (non-YAML) release pipelines have pre-deployment and post-deployment gates configured on each stage: Azure Monitor alerts, Invoke REST API, Invoke Azure Function, Query Work Items, and security/compliance assessment. They evaluate on an interval with a timeout, and all gates must pass simultaneously in one sampling window.
Caveat — gates must all pass in the same evaluation
Gates are re-sampled on a fixed interval until they all succeed together or the timeout expires. One gate passing at 10:00 and another at 10:05 is not enough — they must both be green in the same sampling pass.
GitHub Actions — environment protection rules
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production # protection rules configured in repo Settings → Environments
url: https://myapp.example.com
steps:
- run: ./deploy.sh
| Protection rule | Enforces |
|---|---|
| Required reviewers | Up to 6 users/teams must approve; the job waits (up to 30 days) |
| Wait timer | A forced delay (up to 43,200 minutes / 30 days) before the job runs |
| Deployment branches and tags | Only listed branches/tags (or protected branches) may deploy |
| Custom deployment protection rules | A GitHub App gates the deployment on an external signal (observability, ITSM) |
| Environment secrets & variables | Values only readable by jobs that reach this environment — after approval |
Environment secrets are a security control, not just convenience
Because environment secrets are only exposed once required reviewers approve, putting production credentials in an environment secret (rather than a repo secret) means a malicious PR workflow cannot reach them. This overlaps with Domain 4 and is a favourite "how do you protect production credentials" answer.
A comprehensive testing strategy
The test pyramid
few · slow · brittle · expensive"] I["Integration / API / contract
some · moderate speed"] U["Unit tests
many · milliseconds · cheap"] E --- I --- U
| Type | Scope | Speed | Runs | Fails tell you |
|---|---|---|---|---|
| Local / developer | Whatever the dev just changed | Instant | Before commit (pre-commit hook) | You broke your own change |
| Unit | One function/class in isolation, dependencies mocked | < 1 s | Every build and PR | Which line is wrong |
| Integration | Several components together, real DB/queue via containers | Seconds–minutes | Every build/PR | The contract between components broke |
| Contract | Consumer/provider API expectations | Fast | Every build | A service changed its API |
| End-to-end / UI | Full user journey through a deployed system | Minutes | Nightly / pre-release / post-deploy smoke | Something is wrong somewhere |
| Load / performance | System under sustained or increasing traffic | Slow | Pre-release, scheduled | It works but not at scale |
| Security (SAST/DAST/SCA) | Code, running app, dependencies | Varies | PR / build / staging | See Domain 4 |
Caveat — shift left, but keep the pyramid
The exam rewards answers that move testing earlier and cheaper. When a scenario says "the pipeline takes too long because of UI tests", the expected fix is to push coverage down the pyramid (more unit/integration, fewer E2E) and run the remaining E2E suite on a schedule or post-deployment — not to buy more agents.
Unit tests
Characteristics the exam expects you to recognise: isolated (dependencies mocked), fast (milliseconds), deterministic (same input → same result, always), independent (no ordering dependency), and with no network, file system or database access.
- task: DotNetCoreCLI@2
displayName: Run unit tests
inputs:
command: test
projects: '**/*Tests/*.csproj'
arguments: '--configuration Release --collect:"XPlat Code Coverage"'
publishTestResults: true # auto-publishes the .trx
Integration tests and service containers
Service containers give a job real backing services (database, cache, queue) without any external infrastructure — the standard answer to "test against a real database without a shared test server".
resources:
containers:
- container: pg
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
jobs:
- job: IntegrationTests
pool:
vmImage: ubuntu-latest
services:
postgres: pg
steps:
- script: dotnet test --filter Category=Integration
env:
DB_CONNECTION: >-
Host=localhost;Database=testdb;
Username=testuser;Password=testpass
jobs:
integration:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
env:
REDIS_URL: redis://localhost:6379
Health checks matter
Without --health-cmd (GitHub) or an equivalent readiness wait, tests can start before the container accepts connections — a classic source of flaky tests. Service containers require a Linux runner/agent with Docker available; they do not work on Microsoft-hosted macOS or Windows images in the same way.
Load and performance testing
| Test type | Load pattern | Answers |
|---|---|---|
| Load test | Constant, expected traffic | Does it meet SLOs under normal peak? |
| Stress test | Increase until failure | Where does it break, and how? |
| Spike test | Sudden surge then drop | Does autoscaling react fast enough? |
| Soak / endurance test | Moderate load for hours | Are there memory leaks or resource exhaustion? |
Azure Load Testing is the managed service: it runs Apache JMeter or Locust scripts (or simple URL-based tests) at scale from Azure, correlates results with server-side Azure Monitor metrics, and can fail the pipeline on threshold breach.
- task: AzureLoadTest@1
inputs:
azureSubscription: 'MyServiceConnection'
loadTestConfigFile: 'loadtest/config.yaml'
resourceGroup: 'my-rg'
loadTestResource: 'my-load-test'
# loadtest/config.yaml — thresholds that fail the run
# failureCriteria:
# - avg(response_time_ms) > 2000
# - percentage(error) > 5
# - p95(response_time_ms) > 3000
Caveat — regression detection needs a baseline
"Detect a performance regression before it reaches production" → run Azure Load Testing in the pipeline against staging with fail criteria, comparing against a recorded baseline. A load test with no thresholds only produces a report; it cannot gate anything.
Implementing tests in a pipeline
Configuring test tasks
| Task | For |
|---|---|
DotNetCoreCLI@2 (command: test) | .NET — xUnit, NUnit, MSTest |
VSTest@3 | Visual Studio test platform; supports test impact analysis and rerun-on-failure |
Maven@4 / Gradle@3 | Java (JUnit) |
Npm@1 / script | Node.js (Jest, Mocha, Vitest) |
PublishTestResults@2 | Publish any results in JUnit / NUnit / VSTest / xUnit / cTest format |
PublishCodeCoverageResults@2 | Publish Cobertura or JaCoCo coverage |
- task: PublishTestResults@2
# Without this condition the step is SKIPPED when tests fail —
# which is exactly when you need the report most.
condition: succeededOrFailed()
inputs:
testResultsFormat: 'JUnit' # JUnit | NUnit | VSTest | XUnit | CTest
testResultsFiles: '**/test-results/*.xml'
mergeTestResults: true
failTaskOnFailedTests: true
testRunTitle: 'Unit tests · $(Build.BuildNumber)'
Caveat — condition: succeededOrFailed()
This is one of the most reliably tested single lines in AZ-400. By default a step only runs if all previous steps succeeded, so a failing test suite means no test report is published. Adding condition: succeededOrFailed() (GitHub equivalent: if: always()) publishes the results either way. Use always() in Azure Pipelines only if you also want it to run when the run is cancelled.
Test agents and multi-configuration testing
jobs:
- job: Test
strategy:
matrix:
linux:
imageName: 'ubuntu-latest'
windows:
imageName: 'windows-latest'
mac:
imageName: 'macOS-latest'
maxParallel: 3
pool:
vmImage: $(imageName)
steps:
- script: dotnet test
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # see ALL failures, not just the first
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node: ['20', '22']
exclude:
- os: windows-latest
node: '20'
steps:
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}' }
- run: npm ci && npm test
- Slicing splits one large suite across N agents: Azure Pipelines uses
parallelstrategy with$(System.JobPositionInPhase)/$(System.TotalJobsInPhase); the VSTest task can distribute automatically. - Test Impact Analysis (VSTest task,
runOnlyImpactedTests: true) runs only the tests affected by the code changed since the last run — the answer to "reduce test time without losing relevant coverage". - Agent demands (
demands: HasSpecialSoftware) route a test job to agents with the right capabilities; self-hosted agents are required for private-network or licensed-tool testing.
Caveat — fail-fast: false
By default GitHub Actions cancels every other matrix job the moment one fails. When the requirement is "see the results for all platforms even if one fails", the answer is fail-fast: false.
Code coverage analysis
| Coverage type | Measures | Strength |
|---|---|---|
| Line / statement | % of lines executed | Easy to hit, weakest signal |
| Branch / decision | % of if/else, switch and loop paths taken | Most meaningful for conditional logic |
| Function / method | % of functions called at least once | Coarse, useful for dead-code detection |
- task: DotNetCoreCLI@2
inputs:
command: test
arguments: '--collect:"XPlat Code Coverage" --results-directory $(Agent.TempDirectory)'
- task: PublishCodeCoverageResults@2
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
# v2 infers the format; v1 required codeCoverageTool: 'Cobertura'
Enforcing a threshold
dotnet test \
/p:CollectCoverage=true \
/p:CoverletOutputFormat=cobertura \
/p:Threshold=80 \
/p:ThresholdType=line,branch \
/p:ThresholdStat=minimum
- task: SonarQubePrepare@6
inputs: { SonarQube: 'SonarConn', scannerMode: 'MSBuild', projectKey: 'my-app' }
- script: dotnet build && dotnet test --collect:"XPlat Code Coverage"
- task: SonarQubeAnalyze@6
- task: SonarQubePublish@6 # fails the build if the quality gate fails
inputs: { pollingTimeoutSec: '300' }
Caveat — coverage on new code
Demanding 80% coverage across a large legacy codebase is impractical, so the modern pattern — and SonarQube's default quality gate — is a threshold on new code only ("clean as you code"). If a scenario says "improve coverage without a huge remediation project", the answer is a gate on changed/new code coverage, not on the overall percentage.
Coverage is a necessary, not sufficient, metric
100% line coverage with no assertions proves nothing. Treat coverage as a floor that stops untested code merging — pair it with mutation testing or review of assertion quality if the scenario asks about test effectiveness rather than test presence.
3.2 rapid recap
| The requirement says… | Answer |
|---|---|
| Require manual approval before production (YAML) | Approvals on the environment |
Deploy only from main | Branch control check (Azure) / deployment branches (GitHub) |
| Don't deploy if monitoring shows active alerts | Query Azure Monitor alerts gate |
| Force every pipeline to run the mandatory security scan | Required template check + extends |
| Only one deployment to prod at a time | Exclusive lock check |
| Test against a real database without a shared server | Service containers in the job |
| Publish the test report when tests fail | condition: succeededOrFailed() / if: always() |
| See all platform results even if one fails | fail-fast: false |
| Run only tests affected by the change | Test Impact Analysis (VSTest) |
| Block merge when the pipeline's tests fail | Branch policy build validation / required status check |
| Fail the build if coverage drops below 80% | Coverage threshold in the runner or a SonarQube quality gate |
| Detect a performance regression pre-production | Azure Load Testing with failureCriteria against a baseline |
| Find the memory leak that appears after hours | Soak / endurance test |
| Protect production credentials from PR workflows | Environment secrets behind required reviewers |