Running Pester in CI/CD (GitHub Actions & Azure Pipelines)
- PowerShell
- Pester
- Testing
- CI/CD
- GitHubActions
- AzurePipelines
- Automation
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 for free, 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
TestResultXML 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.
Invoke-Pester sets a failing exit code when run as the top-level command, but the robust, explicit pattern is the configuration object's Run.Exit property, which guarantees a non-zero exit on failure. 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 $configRun.Exit = $true turns "tests ran" into "the build fails when tests fail." Forget it and a broken test sails to production with a green check.
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
$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 $configTestResult.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 pipeline publishes both.
Pin the Pester version
The clean CI machine has whatever Pester the OS shipped, which on Windows is the legacy 3.4 that shadows v5. Always install a pinned version so today's green build means the same thing next year.
Install-Module Pester -RequiredVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -RequiredVersion 5.5.0-RequiredVersion nails an exact build; -SkipPublisherCheck avoids the signing prompt that otherwise stalls a non-interactive runner. 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, 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.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -RequiredVersion 5.5.0
- 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.xmlThe 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.
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.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -RequiredVersion 5.5.0
- 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: trueThe 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. Fast pipelines get used; slow ones get skipped.
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 and block the merge. That red X is your safety net working.
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 checkFailedCountandthrow) so a failure actually fails the job.Not pinning the Pester version. A CI machine may have the legacy v3.4, or a future v5 release may change behavior. Always
Install-Module Pester -RequiredVersion <x> -SkipPublisherCheckso the build is reproducible.Never publishing the results. Without the
TestResultXML and a publish step (if: always()/condition: always()), you get a red X and no clue 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, then call the script from GitHub Actions or Azure Pipelines and publish results with an always() condition. Pin the version, fail on red, publish every time.
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.