Skip to content
Updated 7 min read

Measuring Code Coverage with Pester

  • PowerShell
  • Pester
  • Testing
  • CodeCoverage
  • Beginners
  • Automation

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

"I wrote tests" and "I tested the risky code" are two very different claims, and most of us quietly hope they're the same thing. Code coverage tells you which of the two you've done. It watches your code while the tests run and reports exactly which lines were touched and which never executed at all. Used well, it points you straight at the branch you forgot. Used badly, it becomes a vanity number you chase to 100% while testing nothing meaningful.

What you'll learn

  • What code coverage measures (and what it does not)
  • How to enable coverage with the Pester 5 configuration object
  • How to read the percentage and the missed-lines report
  • How to emit JaCoCo XML for other tools and dashboards
  • Why coverage is a guide, not a goal

What coverage measures

Coverage measures which lines of your source ran while your tests executed. That is all. It does not know whether you asserted anything useful about those lines, and it certainly does not know whether your code is correct. A line can be "covered" by a test that never checks its result.

So read coverage as a question, not a grade: "Which parts of my code did no test even touch?" Untouched lines are guaranteed untested. Touched lines are merely possibly tested. That asymmetry is the whole value: coverage is great at finding gaps and useless at certifying quality.

A function with an untested branch

Save this as Get-Discount.ps1. It has two branches, and our first test will only exercise one of them.

# Get-Discount.ps1
function Get-Discount {
    param(
        [Parameter(Mandatory)][decimal]$Total,
        [switch]$IsMember
    )

    if ($IsMember) {
        return [math]::Round($Total * 0.90, 2)
    }
    else {
        return $Total
    }
}

Now a test file, Get-Discount.Tests.ps1, that only ever passes -IsMember:

# Get-Discount.Tests.ps1
BeforeAll {
    . $PSScriptRoot/Get-Discount.ps1
}

Describe 'Get-Discount' {
    It 'applies a 10% member discount' {
        Get-Discount -Total 100 -IsMember | Should -Be 90.00
    }
}

The test passes, and you might feel done. But the non-member branch (return $Total) never ran. Coverage will catch that.

Enabling coverage with New-PesterConfiguration

In Pester 5, you turn coverage on through the configuration object rather than loose parameters. Build a config, point CodeCoverage.Path at the source file (not the test file), enable it, and invoke. Run these lines from the folder that holds both files:

$config = New-PesterConfiguration
$config.Run.Path = './Get-Discount.Tests.ps1'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './Get-Discount.ps1'
$config.Output.Verbosity = 'Detailed'

Invoke-Pester -Configuration $config

Pointing CodeCoverage.Path at the source you want to measure is the key step. A common beginner error is aiming it at the .Tests.ps1 files. Pester leaves test files out of coverage by default (CodeCoverage.ExcludeTests is $true), so that measures nothing at all: zero commands analyzed and no coverage line in the output.

Reading the result

After the run, Pester 5.8.0 prints a coverage summary near the bottom:

Covered 66.67% / 75%. 3 analyzed Commands in 1 File.
Missed command:

File             Class Function     Line Command
----             ----- --------     ---- -------
Get-Discount.ps1       Get-Discount   12 return $Total

The 75% after the slash is Pester's default coverage target (CodeCoverage.CoveragePercentTarget), not a second measurement. The report is doing its job: it tells you line 12, the non-member branch, never executed. You did not test what happens for a non-member. Adding that case is the fix:

# Get-Discount.Tests.ps1
# Now with the non-member case added
BeforeAll {
    . $PSScriptRoot/Get-Discount.ps1
}

Describe 'Get-Discount' {
    It 'applies a 10% member discount' {
        Get-Discount -Total 100 -IsMember | Should -Be 90.00
    }

    It 'returns the full total for non-members' {
        Get-Discount -Total 100 | Should -Be 100
    }
}

Re-run, and coverage climbs to 100% because both branches now execute and we assert on each.

Getting the result as an object

For scripting and CI gates, capture the result instead of just reading the console. Add PassThru and inspect the CodeCoverage property:

$config = New-PesterConfiguration
$config.Run.Path = './Get-Discount.Tests.ps1'
$config.Run.PassThru = $true
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './Get-Discount.ps1'

$result = Invoke-Pester -Configuration $config

$cov = $result.CodeCoverage
$percent = [math]::Round($cov.CoveragePercent, 1)
"Coverage: $percent%"
"Missed commands: $($cov.CommandsMissedCount)"

CoveragePercent, CommandsExecutedCount, and CommandsMissedCount give you everything you need to enforce a threshold of your own.

JaCoCo XML output for tools

Most reporting dashboards and CI systems understand the JaCoCo XML format. Pester emits it for you when you set an output path:

$config = New-PesterConfiguration
$config.Run.Path = './Get-Discount.Tests.ps1'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './Get-Discount.ps1'
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = './coverage.xml'

Invoke-Pester -Configuration $config

You now have a coverage.xml a pipeline can publish and trend over time. The default format is already JaCoCo, but setting it explicitly documents intent and makes the output path obvious to the next person.

Coverage as a guide, not a goal

Here is the trap. Once coverage becomes a target, people write tests that execute lines without asserting anything, just to make the number go up. The number rises; the safety net does not. A function called once with its result thrown away shows as covered and protects nothing.

That pattern has a name: Goodhart's Law, "when a measure becomes a target, it ceases to be a good measure." The crisp wording we usually quote is Marilyn Strathern's (1997), distilling an idea from the economist Charles Goodhart. It turns up everywhere once you start noticing it, and a coverage percentage is a textbook case.

Treat coverage as a flashlight for finding dark corners. When it reveals an untouched error path or an unhandled branch, write a test for it. When it tempts you toward 100% for its own sake, ignore it. Meaningful coverage of your risky logic is worth more than a high number made of lines that ran but were never checked.

Try it yourself

Take one of your own functions that has an if/else or a try/catch. Write a single test that exercises only the happy path, then run coverage with New-PesterConfiguration pointed at the source file. Read the missed-lines report, find the branch you skipped, and write the test that lights it up. Notice how the report told you precisely where to look.

Common mistakes

Chasing 100% for its own sake. A number is not a safety net. Aim coverage at your risky logic, not at a percentage on a badge.

Covering lines without asserting. Running a line is not testing it. If you call code and never check the result with Should, coverage lies to you. Pair every covered branch with a real assertion.

Pointing coverage at test files. CodeCoverage.Path must target your source .ps1/module files. Aim it at *.Tests.ps1 and Pester skips those files, so you get no coverage at all.

Recap

Code coverage reports which source lines your tests executed, no more and no less. Enable it with New-PesterConfiguration by setting CodeCoverage.Enabled and pointing CodeCoverage.Path at your source, then read the missed-lines report to find untested branches. Emit JaCoCo XML for dashboards and CI, capture the result object to enforce thresholds, and remember that coverage finds gaps but never certifies correctness. Use it as a guide, never as the goal.

Next up: Part 23, Configuring Pester with New-PesterConfiguration, where we go beyond coverage and learn the full configuration object that drives every grown-up Pester run.

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

Comments