05 · Testing at Scale & CI¶
A handful of Pester tests running with Invoke-Pester -Output Detailed
is fine at your desk. In a CI pipeline running hundreds of tests across
many files, you need three more things: selective execution (tags), a
machine-readable result format CI systems can parse, and code coverage —
a number telling you how much of the code your tests actually exercise.
Tagging tests for selective runs¶
Describe "Get-Factorial" -Tag "Unit" {
It "computes <N>! = <Expected>" -ForEach @(
@{ N = 0; Expected = 1 }
@{ N = 1; Expected = 1 }
@{ N = 5; Expected = 120 }
@{ N = 10; Expected = 3628800 }
) {
Get-Factorial -N $N | Should -Be $Expected
}
It "throws for negative input" -Tag "EdgeCase" {
{ Get-Factorial -N -1 } | Should -Throw
}
}
Describe "Get-Factorial performance" -Tag "Slow" {
It "completes 15! in reasonable time" {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Get-Factorial -N 15 | Should -Be 1307674368000
$sw.Stop()
$sw.ElapsedMilliseconds | Should -BeLessThan 500
}
}
-Tag on Describe or It lets a CI pipeline run subsets deliberately —
fast unit tests on every push, slower integration/performance tests only
on a nightly schedule or before a release, rather than paying the full
suite's runtime on every single commit.
Filtering by tag and generating machine-readable output¶
$config = New-PesterConfiguration
$config.Run.Path = './Math.Tests.ps1'
$config.Filter.Tag = 'Unit'
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './testresults.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './Math.psm1'
$config.CodeCoverage.OutputPath = './coverage.xml'
Invoke-Pester -Configuration $config
Pester v6.0.1
Filter 'Tag' set to ('Unit').
Filters selected 5 tests to run.
Starting code coverage.
Describing Get-Factorial
[+] computes 0! = 1 67ms
[+] computes 1! = 1 6ms
[+] computes 5! = 120 8ms
[+] computes 10! = 3628800 6ms
[+] throws for negative input 33ms
Tests Passed: 5, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 1
Processing code coverage result.
Covered 100% / 75%. 9 analyzed Commands in 1 File.
Filters selected 5 tests to run confirms the -Tag Unit filter worked —
the Slow-tagged performance test was skipped entirely (it shows as
NotRun: 1), exactly the selective-execution behavior CI needs.
OutputFormat 'NUnitXml' writes results in a format essentially every CI
system (GitHub Actions, Azure DevOps, Jenkins) knows how to parse into a
readable test report with pass/fail annotations directly on the pipeline
run — far more useful for a team than scrolling raw console output.
Reading a code coverage result¶
Pester reports coverage as command coverage (which individual
PowerShell statements/commands actually executed during the test run),
not line coverage — the "100%" here is the analyzed-file's overall figure
in this specific example, and the "75%" is Pester's coverage threshold
(configurable via $config.CodeCoverage.CoveragePercentTarget) it's
comparing against; a run falling under the target can be made to fail the
build the same way a failed assertion does.
The trap: high coverage numbers hide untested behavior¶
A recursive function like Get-Factorial can show 100% command coverage
from tests that only ever call it with small, well-behaved inputs — every
line executes, but nothing tests what happens with -N 0 as a boundary,
deeply negative input, or a value large enough to overflow [int].
Coverage percentage measures which lines ran, not which scenarios
were verified — a genuinely well-tested function needs both a coverage
number and deliberate boundary/edge-case It blocks like the
"negative input" test above, not just enough calls to touch every line
once.
Structuring a large suite for speed¶
For a real module with dozens of test files, two habits keep CI fast as the suite grows:
- Split by tag, not by guessing —
Unittests (no I/O, no sleep, milliseconds each) run on every push;Integration/Slowtests (hitting real services, deliberately timed) run on a schedule or before merge tomainonly. - Parallelize test files, not individual
Itblocks — Pester itself runs sequentially within one invocation; splitting test files across multipleForEach-Object -Parallel(module 01) or across separate CI matrix jobs is how a large suite's wall-clock time stays reasonable as it grows past a few hundred tests.
Cheat sheet¶
| Concept | Purpose |
|---|---|
-Tag "Name" on Describe/It |
group tests for selective execution |
$config.Filter.Tag = 'Unit' |
run only tests matching a tag |
$config.TestResult.OutputFormat = 'NUnitXml' |
CI-parseable results |
$config.CodeCoverage.Enabled = $true |
measure which commands executed |
CoveragePercentTarget |
fail the build if coverage drops below a threshold |
| Command coverage ≠ scenario coverage | 100% coverage can still miss edge cases |
Split suites by tag (Unit vs Slow/Integration) |
keep fast feedback fast |
How It Actually Works¶
Pester's two-pass discovery/run model (from Module 06's mechanics) is
exactly what makes large-suite performance tuning possible in the first
place: because discovery runs every Describe/Context container body
before any It executes, a suite with hundreds of test files pays a
discovery-time cost proportional to total container script complexity
before a single assertion runs — this is why heavy setup logic
accidentally placed at container level (outside BeforeAll) silently
multiplies: it re-runs once per discovery pass across the whole suite
scan, not once per actual test execution.
-Parallel/tag-based suite splitting for CI relies on Pester's tests
being genuinely independent processes or runspaces (via Start-Job/
ForEach-Object -Parallel, covered in Module 01) rather than a built-in
parallel test runner sharing one session — each split invokes its own
Invoke-Pester inside its own runspace/process, meaning global state
($env:, a shared temp file, a module already imported with side
effects) genuinely isn't shared between splits, which is both why
parallelizing Pester is safe against most global-state leakage and why a
suite that secretly depends on tests running in a specific order will
break the moment you split and parallelize it.
-Tag/-ExcludeTag filtering happens at the discovery pass, not the run
pass — Pester builds the complete test tree first, then filters which
discovered It blocks actually execute, which is why tag filtering can't
skip expensive discovery-time work (heavy Describe-level setup still
runs for excluded tests) but can skip the potentially much larger cost of
actually running slow integration-tagged tests during a fast
unit-test-only CI stage.
Exercise¶
Take the AdminToolkit module from Level 3's project, tag its existing
tests Unit, add at least two new Integration-tagged tests that
actually hit real Get-PSDrive/Get-Process (no mocking) and assert
only on shape (correct properties present, no exceptions) rather than
exact values, then run the suite twice with New-PesterConfiguration —
once filtered to Unit only, once unfiltered — and generate a coverage
report for each, comparing the coverage percentage between the two runs.