Domain 3.2 · part of 50–55%

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.

flowchart LR B[Build] --> QG{Quality gate} QG -->|tests pass
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]
Gates run before and after a deployment; approvals are the human gate in the middle.

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.

CheckEnforces
ApprovalsNamed users/groups must approve; configurable timeout and "requester cannot approve"
Branch controlDeployment may only run from allowed branches (e.g. refs/heads/main), optionally requiring branch protection
Business hoursDeployment only proceeds within a defined time window / days
Query Azure Monitor alertsNo active alerts matching the filter — the classic auto-rollback signal
Invoke REST APIAn external system (CAB, ITSM, ServiceNow) returns success
Invoke Azure FunctionCustom validation logic; supports async callback for long-running checks
Required templateThe pipeline must extends an approved YAML template — the governance backbone
Evaluate artifactContainer image must satisfy a policy (Rego) before deployment
Exclusive lockOnly 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

GitHub Actions environment-gated deployment
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 ruleEnforces
Required reviewersUp to 6 users/teams must approve; the job waits (up to 30 days)
Wait timerA forced delay (up to 43,200 minutes / 30 days) before the job runs
Deployment branches and tagsOnly listed branches/tags (or protected branches) may deploy
Custom deployment protection rulesA GitHub App gates the deployment on an external signal (observability, ITSM)
Environment secrets & variablesValues 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

flowchart TB E["End-to-end / UI
few · slow · brittle · expensive"] I["Integration / API / contract
some · moderate speed"] U["Unit tests
many · milliseconds · cheap"] E --- I --- U
Push testing down the pyramid: the cheaper and faster a test, the more of them you should have.
TypeScopeSpeedRunsFails tell you
Local / developerWhatever the dev just changedInstantBefore commit (pre-commit hook)You broke your own change
UnitOne function/class in isolation, dependencies mocked< 1 sEvery build and PRWhich line is wrong
IntegrationSeveral components together, real DB/queue via containersSeconds–minutesEvery build/PRThe contract between components broke
ContractConsumer/provider API expectationsFastEvery buildA service changed its API
End-to-end / UIFull user journey through a deployed systemMinutesNightly / pre-release / post-deploy smokeSomething is wrong somewhere
Load / performanceSystem under sustained or increasing trafficSlowPre-release, scheduledIt works but not at scale
Security (SAST/DAST/SCA)Code, running app, dependenciesVariesPR / build / stagingSee 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.

azure-pipelines.yml .NET unit tests with coverage
- 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".

azure-pipelines.yml service container
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
GitHub Actions service container
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 typeLoad patternAnswers
Load testConstant, expected trafficDoes it meet SLOs under normal peak?
Stress testIncrease until failureWhere does it break, and how?
Spike testSudden surge then dropDoes autoscaling react fast enough?
Soak / endurance testModerate load for hoursAre 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.

azure-pipelines.yml Azure Load Testing with fail criteria
- 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

TaskFor
DotNetCoreCLI@2 (command: test).NET — xUnit, NUnit, MSTest
VSTest@3Visual Studio test platform; supports test impact analysis and rerun-on-failure
Maven@4 / Gradle@3Java (JUnit)
Npm@1 / scriptNode.js (Jest, Mocha, Vitest)
PublishTestResults@2Publish any results in JUnit / NUnit / VSTest / xUnit / cTest format
PublishCodeCoverageResults@2Publish Cobertura or JaCoCo coverage
azure-pipelines.yml publishing results even on failure
- 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

azure-pipelines.yml matrix across OS
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
GitHub Actions matrix across OS × version
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

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 typeMeasuresStrength
Line / statement% of lines executedEasy to hit, weakest signal
Branch / decision% of if/else, switch and loop paths takenMost meaningful for conditional logic
Function / method% of functions called at least onceCoarse, useful for dead-code detection
azure-pipelines.yml collect and publish coverage
- 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

bash fail the build in the test runner
dotnet test \
  /p:CollectCoverage=true \
  /p:CoverletOutputFormat=cobertura \
  /p:Threshold=80 \
  /p:ThresholdType=line,branch \
  /p:ThresholdStat=minimum
azure-pipelines.yml SonarQube quality gate
- 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 mainBranch control check (Azure) / deployment branches (GitHub)
Don't deploy if monitoring shows active alertsQuery Azure Monitor alerts gate
Force every pipeline to run the mandatory security scanRequired template check + extends
Only one deployment to prod at a timeExclusive lock check
Test against a real database without a shared serverService containers in the job
Publish the test report when tests failcondition: succeededOrFailed() / if: always()
See all platform results even if one failsfail-fast: false
Run only tests affected by the changeTest Impact Analysis (VSTest)
Block merge when the pipeline's tests failBranch 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-productionAzure Load Testing with failureCriteria against a baseline
Find the memory leak that appears after hoursSoak / endurance test
Protect production credentials from PR workflowsEnvironment secrets behind required reviewers