Skip to content
6 min read

Measuring Code Coverage with Pester

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

"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 is the tool that tells you the truth. 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 actually 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.

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:

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.

$config = New-PesterConfiguration
$config.Run.Path = "$PSScriptRoot/Get-Discount.Tests.ps1"
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = "$PSScriptRoot/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, which measures coverage of the tests themselves, a meaningless number.

Reading the result

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

Covered 75% / 0%. 1 analyzed Command in 1 File.
Missed commands:

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

That report is doing exactly its job: it tells you line 8, the non-member branch, never executed. You did not test what happens for a non-member. Adding that case is the fix:

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 = "$PSScriptRoot/Get-Discount.Tests.ps1"
$config.Run.PassThru = $true
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = "$PSScriptRoot/Get-Discount.ps1"

$result = Invoke-Pester -Configuration $config

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

CoveragePercent, CommandsExecutedCount, and CommandsMissedCount give you everything you need to enforce a threshold later (we wire that into CI in Part 24).

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 = "$PSScriptRoot/Get-Discount.Tests.ps1"
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = "$PSScriptRoot/Get-Discount.ps1"
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = "$PSScriptRoot/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, that is gold. When it tempts you toward 100% for its own sake, ignore it. Eighty percent of meaningful coverage on your risky logic beats 100% of "ran but unchecked" every time.

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 you measure your tests, which tells you nothing.

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.