Skip to content
End To End Tester

Testing in Azure DevOps Pipelines

A full YAML pipeline with stages, jobs, parallel test slicing and the best built-in test reporting of any CI platform.

1 min read · updated 19 September 2026

Azure Pipelines is the enterprise .NET default, and its test reporting is genuinely the best of the platforms covered here — published results feed Test Analytics, which tracks per-test pass rate, duration and flakiness over time without any extra tooling.

#A complete pipeline

yaml
# azure-pipelines.yml
trigger:
  branches: { include: [main] }

pr:
  branches: { include: [main] }

variables:
  buildConfiguration: 'Release'
  DOTNET_NOLOGO: true
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

stages:
  # ---------------------------------------------------------------- #
  - stage: Build
    jobs:
      - job: Compile
        pool: { vmImage: 'ubuntu-latest' }
        steps:
          - task: UseDotNet@2
            inputs: { version: '9.x' }

          - task: Cache@2
            inputs:
              key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
              restoreKeys: 'nuget | "$(Agent.OS)"'
              path: $(NUGET_PACKAGES)

          - script: dotnet build --configuration $(buildConfiguration)
            displayName: Build

  # ---------------------------------------------------------------- #
  - stage: Test
    dependsOn: Build
    jobs:
      - job: Unit
        pool: { vmImage: 'ubuntu-latest' }
        steps:
          - task: UseDotNet@2
            inputs: { version: '9.x' }

          - script: |
              dotnet test \
                --configuration $(buildConfiguration) \
                --filter "Category!=Integration" \
                --logger "trx;LogFileName=unit.trx" \
                --collect:"XPlat Code Coverage" \
                --results-directory $(Agent.TempDirectory)/TestResults
            displayName: Unit tests

          # condition: always() — a report is needed most when the step failed
          - task: PublishTestResults@2
            condition: always()
            inputs:
              testResultsFormat: VSTest
              testResultsFiles: '**/unit.trx'
              testRunTitle: 'Unit tests'
              failTaskOnFailedTests: true

          - task: PublishCodeCoverageResults@2
            condition: always()
            inputs:
              summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'

      - job: Integration
        pool: { vmImage: 'ubuntu-latest' }
        steps:
          - task: UseDotNet@2
            inputs: { version: '9.x' }
          # The hosted Ubuntu agent has Docker, so Testcontainers just works.
          - script: dotnet test --filter "Category=Integration" --logger "trx;LogFileName=integration.trx"
            displayName: Integration tests
          - task: PublishTestResults@2
            condition: always()
            inputs: { testResultsFormat: VSTest, testResultsFiles: '**/integration.trx' }

      - job: E2E
        pool: { vmImage: 'ubuntu-latest' }
        # Four agents run this job; each slices the suite itself.
        strategy:
          parallel: 4
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '22.x' }
          - script: npm ci && npx playwright install --with-deps chromium
          - script: |
              npx playwright test \
                --shard=$((System.JobPositionInPhase))/$((System.TotalJobsInPhase)) \
                --reporter=junit,blob
            displayName: Playwright
            env:
              PLAYWRIGHT_JUNIT_OUTPUT_NAME: results.xml

          - task: PublishTestResults@2
            condition: always()
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: 'results.xml'
              testRunTitle: 'E2E shard $(System.JobPositionInPhase)'

          - task: PublishPipelineArtifact@1
            condition: always()
            inputs:
              targetPath: 'blob-report'
              artifact: 'blob-$(System.JobPositionInPhase)'

  # ---------------------------------------------------------------- #
  - stage: Deploy
    dependsOn: Test
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: Production
        environment: production        # approvals and gates attach here
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh

#Test slicing

Two mechanisms, depending on the runner.

Manual slicing, as above: parallel: 4 starts four agents and sets System.JobPositionInPhase (1-based) and System.TotalJobsInPhase. You pass them to whatever your runner's shard flag is. This is what Playwright sharding needs.

Automatic slicing with VSTest for .NET:

yaml
- task: VSTest@3
  inputs:
    testSelector: testAssemblies
    testAssemblyVer2: '**/*Tests.dll'
    runInParallel: true
    # Slice by previous run time — the best distribution available here.
    distributionBatchType: basedOnTestCases
    rerunFailedTests: true
    rerunMaxAttempts: 2

rerunFailedTests with Test Analytics is how Azure DevOps does flaky detection: a test that fails and then passes on rerun is marked flaky rather than counted as a pass, and it appears in the analytics view with a history.

That is the feature worth choosing this platform for.

#Test Analytics

Once PublishTestResults has run over several builds, the Analytics view gives you, per test:

  • pass rate over time
  • duration trend
  • flakiness classification
  • failure frequency by branch

The two reports that change behaviour are "top failing tests" and "longest running tests". Both are the input to a sensible weekly tidy-up, and neither requires any instrumentation beyond publishing results.

#Service containers

yaml
resources:
  containers:
    - container: postgres
      image: postgres:16-alpine
      ports: ['5432:5432']
      env:
        POSTGRES_PASSWORD: test
        POSTGRES_DB: test

jobs:
  - job: Integration
    services:
      postgres: postgres
    steps:
      - script: dotnet test
        env:
          ConnectionStrings__Default: 'Host=localhost;Database=test;Username=postgres;Password=test'

As elsewhere, Testcontainers is usually preferable — the dependency is declared in the test code and behaves identically on a developer machine.

#Templates

The feature that keeps a large organisation's pipelines consistent:

yaml
# templates/dotnet-test.yml
parameters:
  - name: filter
    type: string
    default: ''
  - name: title
    type: string

steps:
  - script: dotnet test --filter "${{ parameters.filter }}" --logger "trx;LogFileName=${{ parameters.title }}.trx"
  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: VSTest
      testResultsFiles: '**/${{ parameters.title }}.trx'
      testRunTitle: ${{ parameters.title }}
yaml
# azure-pipelines.yml
- template: templates/dotnet-test.yml
  parameters: { filter: 'Category!=Integration', title: 'Unit' }

#Compared with GitHub Actions

Azure DevOps has better test reporting and analytics, first-class environments with approvals, and templates that scale across many repositories. GitHub Actions has a far larger action ecosystem, simpler YAML, and tighter integration with pull requests.

For a .NET shop already inside Azure DevOps, the reporting alone is a good reason to stay.

Common questions

What does Azure DevOps do better than other CI platforms for testing?
Test reporting. Results published with PublishTestResults feed Test Analytics, which tracks pass rate, duration and flakiness per test over time and surfaces the worst offenders automatically. No other platform here gives you that without extra tooling.
How do I run tests in parallel in Azure Pipelines?
parallel in the job strategy gives you N agents running the same job with System.JobPositionInPhase and System.TotalJobsInPhase set, and you slice the suite yourself. For .NET, VSTest can slice automatically with distributionBatchType.
How do I detect flaky tests in Azure DevOps?
Test Analytics flags them automatically once results are published over several runs, and there is a built-in flaky test detection setting that reruns failures and marks tests that pass on retry.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?