Skip to content
Updated 8 min read

Running Pester in CI/CD (GitHub Actions & Azure Pipelines)

  • PowerShell
  • Pester
  • Testing
  • CI/CD
  • GitHubActions
  • AzurePipelines
  • Automation

📦 Run it yourself: download the companion code pack; this part's examples are in the 06-quality-and-automation/ folder (Pester 5-verified).

A test suite that runs only when you remember to type Invoke-Pester protects nothing. The point of automated tests is to catch a regression before it ships, which means running them on every push and pull request. Continuous integration does exactly that, on a clean machine with none of your local quirks. Here we take the configuration object from Part 23 and wire it into two common pipelines so a red test stops the build cold.

What you'll learn

  • Why CI depends on the process exit code
  • Emitting NUnit TestResult XML so the pipeline can publish results
  • A complete GitHub Actions workflow that pins Pester and fails on red
  • An Azure Pipelines equivalent doing the same job
  • A caching and performance note for faster runs

CI lives and dies by the exit code

A CI runner does not read your test summary. It runs a command and checks one thing: did the process exit with code 0 (success) or non-zero (failure)? The green check or the red X hangs on that number. So the most important job of your test script is to exit non-zero when a test fails.

A plain Invoke-Pester doesn't guarantee that. It sets $LASTEXITCODE inside the session, but when tests fail, the PowerShell process itself still exits with code 0. GitHub Actions and Azure Pipelines pass $LASTEXITCODE on for their built-in PowerShell steps, so there a failure does turn the build red, but a runner that starts powershell -File on its own sees 0 and stays green. The fix that works everywhere is the configuration object's Run.Exit property: when a test fails, Pester ends the process with a non-zero exit code. We'll use that.

$config = New-PesterConfiguration
$config.Run.Path = './Tests'
$config.Run.Exit = $true          # exit non-zero if any test fails
Invoke-Pester -Configuration $config

Run.Exit = $true turns "tests ran" into "the build fails when tests fail." Run it from a script, not by pasting it into your own console: when a test fails, Pester ends the PowerShell session it runs in, so an interactive window closes. Leave it out and the script can finish with exit code 0 even when tests fail, and CI reads that as a pass.

A reusable CI test script

Rather than scatter logic across YAML, put the run in a script the pipeline calls. This keeps the pipeline thin and lets you run the same thing locally. Save this as build/run-ci-tests.ps1.

# build/run-ci-tests.ps1
# What CI runs. It pins Pester in the same process that runs the tests.
Import-Module Pester -RequiredVersion 5.8.0 -ErrorAction Stop

$config = New-PesterConfiguration

$config.Run.Path = "$PSScriptRoot/../Tests"
$config.Run.Exit = $true                     # non-zero exit on failure

$config.TestResult.Enabled      = $true
$config.TestResult.OutputFormat = 'NUnitXml'
$config.TestResult.OutputPath   = "$PSScriptRoot/../testResults.xml"

$config.CodeCoverage.Enabled    = $true
$config.CodeCoverage.Path       = "$PSScriptRoot/../src"
$config.CodeCoverage.OutputPath = "$PSScriptRoot/../coverage.xml"

$config.Output.Verbosity = 'Detailed'

Invoke-Pester -Configuration $config

TestResult.Enabled writes testResults.xml in NUnit format, the standard file CI systems parse for a per-test report. CodeCoverage produces the JaCoCo coverage.xml from Part 22. The GitHub workflow below uploads both files; the Azure pipeline publishes the test results only.

Pin the Pester version

The clean CI machine has whatever Pester its image ships, and that can change when the image is updated. Always install a pinned version so today's green build means the same thing next year.

Install-Module Pester -RequiredVersion 5.8.0 -Force -SkipPublisherCheck -Scope CurrentUser

-RequiredVersion nails an exact build; -SkipPublisherCheck gets past the publisher-mismatch error that otherwise stops the install on a machine whose only Pester is the built-in 3.4.

Installing isn't enough on its own. Each pipeline step starts a new PowerShell process, and a new process loads the newest Pester it can find, which may be Pester 6 or whatever the runner image ships. That's why run-ci-tests.ps1 starts with Import-Module Pester -RequiredVersion 5.8.0 -ErrorAction Stop: the import happens in the same process that runs the tests, before New-PesterConfiguration can load another version, and if 5.8.0 is missing the script stops instead of testing with some other version. Pinning means a new Pester release can never silently change your build's behavior.

GitHub Actions workflow

Save this as .github/workflows/pester.yml. It runs on every push and pull request, installs the pinned Pester, runs the script (which imports that pinned version), and publishes results. Note shell: pwsh, which uses cross-platform PowerShell 7.

name: Pester

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Pester (pinned)
        shell: pwsh
        run: Install-Module Pester -RequiredVersion 5.8.0 -Force -SkipPublisherCheck -Scope CurrentUser

      - name: Run Pester
        shell: pwsh
        run: ./build/run-ci-tests.ps1

      - name: Publish test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pester-results
          path: |
            testResults.xml
            coverage.xml

The Run Pester step fails the job automatically: the script's Run.Exit = $true makes pwsh exit non-zero, and GitHub Actions fails any step whose command exits non-zero. The publish step uses if: always() so you still get the artifact when tests fail, which is exactly when you want to inspect it.

Azure Pipelines equivalent

The same idea in Azure DevOps. Save this as azure-pipelines.yml. The PublishTestResults@2 task understands NUnit XML natively and renders a per-test report. It differs from the GitHub workflow in two ways. First, its trigger lists only main, and a listed trigger runs the pipeline only for pushes to the branches in that list, so add your other branches there if you want them built. Second, it publishes testResults.xml but not coverage.xml.

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: PowerShell@2
    displayName: 'Install Pester (pinned)'
    inputs:
      targetType: inline
      pwsh: true
      script: |
        Install-Module Pester -RequiredVersion 5.8.0 -Force -SkipPublisherCheck -Scope CurrentUser

  - task: PowerShell@2
    displayName: 'Run Pester'
    inputs:
      targetType: filePath
      filePath: 'build/run-ci-tests.ps1'
      pwsh: true

  - task: PublishTestResults@2
    displayName: 'Publish test results'
    condition: always()
    inputs:
      testResultsFormat: 'NUnit'
      testResultsFiles: 'testResults.xml'
      failTaskOnFailedTests: true

The Run Pester task fails the build on a non-zero exit, just like the GitHub job, and condition: always() plus failTaskOnFailedTests: true ensures the results are published and a failing test is unmistakable in the report.

A note on caching and speed

Installing a module from the gallery on every run adds seconds. For larger pipelines, cache the PowerShell module path keyed on the Pester version so the install step is skipped on a cache hit. Pair that with the Filter.Tag work from Part 16: run only Unit-tagged tests on every push for fast feedback, and reserve slower Integration tests for a nightly schedule.

Try it yourself

Add a CI workflow to one of your own repositories. Create build/run-ci-tests.ps1 with Run.Exit = $true and TestResult.Enabled = $true, then drop in the GitHub Actions YAML above (adjusting paths). Push a branch, open a pull request, and confirm the check goes green. Now deliberately break one assertion, push again, and watch the build go red. That red X is your safety net working. It blocks the merge only if the branch requires that check: mark the workflow's test job as a required status check in the branch's protection rules.

Common mistakes

Not failing the build on a failed test. If you run Pester in a way that swallows the exit code, the build stays green while tests are red. Set Run.Exit = $true (or check FailedCount and throw) so a failure fails the job.

Not pinning the Pester version. A CI image may carry the legacy 3.4, a different 5.x, or a newer release that changes behavior. Always Install-Module Pester -RequiredVersion <x> -SkipPublisherCheck, then Import-Module Pester -RequiredVersion <x> inside the script that runs the tests, so the build is reproducible.

Never publishing the results. Without the TestResult XML and a publish step (if: always() / condition: always()), you get a red X and have to dig through the job log to find which test broke. Emit and publish the NUnit file every time.

Recap

CI runs your suite on every push and decides pass or fail from the exit code, so make Pester exit non-zero on red with Run.Exit = $true. Put the run in a small script that emits NUnit TestResult XML and JaCoCo coverage, install a pinned Pester version on the clean runner, import that version inside the script, then call the script from GitHub Actions or Azure Pipelines and publish results with an always() condition.

Next up: Part 25, Putting It Together: A TDD Workflow + Common Pitfalls (the capstone), where we fold every technique from the series into a single red-green-refactor habit and build a function entirely test-first.

Posts here are written with AI assistance and tested against real code before publishing.

Comments