Chapter 18Lesson 04~230 minutes

Code Coverage, CI Test Reports, and Quality Gates

Turn Pester tests, PSScriptAnalyzer diagnostics, code coverage, XML reports, exit status, and retained artifacts into a reproducible local quality gate that mirrors CI.

CoverageCI reportsQuality gatesArtifacts

Learning objectives

  • Explain code coverage as execution evidence rather than a correctness score.
  • Configure Pester 6 code coverage and machine-readable test-result files.
  • Fail a CI runner intentionally when tests or quality policy fail.
  • Combine tests, static analysis, and a documented coverage threshold.
  • Retain portable diagnostic artifacts without requiring a particular CI vendor.
  • Build a local quality script that mirrors a later CI runner command.

1. Coverage answers “what executed?”, not “was it correct?”

Code coverage measures which commands or code locations were exercised while tests ran. A line can be covered by a test with no meaningful assertion, and an uncovered line can be important even if the overall percentage looks high. Treat coverage as evidence that guides investigation—not as a quality score that replaces test design.

SignalWhat it tells youWhat it cannot prove
Pester pass/failObserved behaviors matched test assertions.Untested behavior is correct.
CoverageCode was exercised during the run.The executed code produced the right result.
PSScriptAnalyzerConfigured static rules found or did not find patterns.Runtime/environment behavior is correct.
Quality gateA chosen policy threshold was met.The policy itself is complete or perfect.

2. Pester 6 uses configuration objects for coverage and reports

Pester 6 removed the old Pester 4-style report/coverage invocation parameters. Use New-PesterConfiguration for advanced settings. Current Pester 6 uses a profiler-based coverage tracer by default; the older breakpoint collector is available only when explicitly requested.

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true
$config.Output.Verbosity = 'Detailed'

$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
$config.CodeCoverage.OutputPath = './artifacts/coverage.xml'
$config.CodeCoverage.OutputFormat = 'JaCoCo'

$result = Invoke-Pester -Configuration $config
$result | Select-Object Result,TotalCount,PassedCount,FailedCount,Duration

Pester 6 can also emit Cobertura coverage when your downstream tool expects it. Do not use the removed v5-era CoverageGutters format in new Pester 6 configuration.

3. Generate machine-readable test results for the CI system

Console output is for humans. CI systems need a stable machine-readable artifact so they can display failed tests, durations, and history. Pester supports NUnit and JUnit-family XML formats through the TestResult configuration.

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true

$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = 'JUnitXml'
$config.TestResult.OutputPath = './artifacts/test-results.xml'

$result = Invoke-Pester -Configuration $config
$result.Result
Get-Item ./artifacts/test-results.xml | Select-Object Name,Length,LastWriteTime

Choose the format your CI system natively understands. The lesson does not require GitHub Actions, Azure DevOps, GitLab, Jenkins, or any paid provider; the XML file is portable.

4. A CI runner must receive a failing process status

A pipeline that prints red test output but exits successfully is operationally broken. Pester's simple -CI switch enables test-result output and exits after the run with failure status when tests fail. For more control, a build script can use Run.PassThru, inspect the result object, write all artifacts, and then throw or exit nonzero.

# Simple CI mode in a dedicated runner process:
# Invoke-Pester -Path ./tests -CI

# Controlled build-script mode:
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './artifacts/test-results.xml'

$result = Invoke-Pester -Configuration $config
if ($result.FailedCount -gt 0 -or $result.FailedContainersCount -gt 0) {
    throw "Pester quality gate failed: $($result.FailedCount) failed test(s)."
}

Throwing in a dedicated build script gives the parent CI process a nonzero exit code while still allowing the script to generate reports before it fails.

5. Coverage thresholds need explicit policy and arithmetic

Pester can display a coverage target, but your organization decides whether and how a percentage becomes a blocking gate. A transparent approach is to compute the exercised-command ratio from the returned coverage object and compare it with a documented threshold.

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
$config.CodeCoverage.CoveragePercentTarget = 80

$result = Invoke-Pester -Configuration $config
$cc = $result.CodeCoverage

$coveragePercent = if ($cc.CommandsAnalyzedCount -gt 0) {
    100.0 * $cc.CommandsExecutedCount / $cc.CommandsAnalyzedCount
} else {
    0.0
}

[pscustomobject]@{
    CoveragePercent = [math]::Round($coveragePercent,2)
    TargetPercent   = 80
    MeetsTarget     = $coveragePercent -ge 80
}

Do not increase a threshold by adding low-value tests that execute lines without asserting behavior. A useful gate creates pressure to investigate important untested paths.

6. A quality gate combines independent signals

A practical gate can require: no blocking analyzer diagnostics, no failed tests, and an agreed coverage threshold. These signals catch different classes of problems. Keep the logic explicit so developers know why the build failed.

$analysis = @(Invoke-ScriptAnalyzer -Path ./src -Recurse -Severity Error,Warning)

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = './artifacts/test-results.xml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
$config.CodeCoverage.OutputPath = './artifacts/coverage.xml'

$test = Invoke-Pester -Configuration $config
$cc = $test.CodeCoverage
$coverage = if ($cc.CommandsAnalyzedCount) {
    100.0 * $cc.CommandsExecutedCount / $cc.CommandsAnalyzedCount
} else { 0 }

$gate = [pscustomobject]@{
    AnalyzerDiagnostics = $analysis.Count
    FailedTests         = $test.FailedCount
    FailedContainers    = $test.FailedContainersCount
    CoveragePercent     = [math]::Round($coverage,2)
    Passed              = ($analysis.Count -eq 0 -and $test.FailedCount -eq 0 -and $test.FailedContainersCount -eq 0 -and $coverage -ge 80)
}
$gate

if (-not $gate.Passed) { throw 'Quality gate failed.' }

7. Reports are build artifacts and need retention policy

A build artifact is an output retained from the automation run for later inspection. Test XML, coverage XML, analyzer JSON, and logs are useful only if the runner preserves them long enough for troubleshooting and protects them appropriately.

New-Item -ItemType Directory -Path ./artifacts -Force | Out-Null

$analysis = Invoke-ScriptAnalyzer -Path ./src -Recurse
$analysis |
    Select-Object RuleName,Severity,ScriptName,Line,Column,Message |
    ConvertTo-Json -Depth 4 |
    Set-Content -LiteralPath ./artifacts/script-analyzer.json

Get-ChildItem ./artifacts |
    Select-Object Name,Length,LastWriteTime

Do not place secrets in test output. Apply retention and access controls appropriate to the repository and incident/debugging needs.

8. Lab — mirror a CI runner locally

The best CI debugging experience is a command you can run locally. Put the quality logic in a repository script instead of hiding all behavior inside a vendor-specific YAML file.

# ./build/Test-Quality.ps1
[CmdletBinding()]
param([int]$CoverageTarget = 80)

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Path ./artifacts -Force | Out-Null

$analysis = @(Invoke-ScriptAnalyzer -Path ./src -Recurse -Severity Error,Warning)

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.PassThru = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = 'JUnitXml'
$config.TestResult.OutputPath = './artifacts/test-results.xml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = './artifacts/coverage.xml'

$result = Invoke-Pester -Configuration $config
$cc = $result.CodeCoverage
$coverage = if ($cc.CommandsAnalyzedCount) {
    100.0 * $cc.CommandsExecutedCount / $cc.CommandsAnalyzedCount
} else { 0 }

if ($analysis.Count -gt 0) { throw "Analyzer produced $($analysis.Count) blocking diagnostic(s)." }
if ($result.FailedCount -gt 0 -or $result.FailedContainersCount -gt 0) { throw 'Pester tests failed.' }
if ($coverage -lt $CoverageTarget) { throw "Coverage $([math]::Round($coverage,2))% is below $CoverageTarget%." }

[pscustomobject]@{Tests=$result.TotalCount;Coverage=[math]::Round($coverage,2);Gate='Passed'}
# Local execution mirrors the CI runner:
pwsh -NoLogo -NoProfile -File ./build/Test-Quality.ps1 -CoverageTarget 80
$LASTEXITCODE

# A CI platform later needs only to execute the same file and retain ./artifacts/.

9. Quality gates should be risk-based, not vanity metrics

A gate is valuable when it blocks a known unacceptable risk: failing behavior tests, syntax/parser defects, secret-handling violations, or a meaningful drop in exercised critical logic. Avoid arbitrary rules such as “100% coverage or fail” when they encourage testing trivial getters instead of error handling and rollback paths.

GateGood useBad use
TestsBlock known behavioral regressions.Count tests without considering their assertions.
Static analysisBlock configured high-risk patterns.Treat every informational style rule as equally critical.
CoverageDetect unexpectedly untested logic and regressions in exercised scope.Equate a percentage with correctness.
ArtifactsPreserve evidence for debugging/audit.Retain sensitive logs forever without access control.

10. Common CI-quality mistakes

MistakeFailure modeBetter pattern
Pipeline ignores process exit codeFailed tests appear green.Throw/exit nonzero from the quality script.
Old Pester report parametersPester 6 parameter binding fails.Use PesterConfiguration or current -CI mode.
Coverage-only quality targetHigh percentage with weak assertions.Combine behavior, analysis, and targeted coverage.
CI-only build logicLocal reproduction is difficult.Keep quality logic in a local repository script.
Discard reports on failureHardest failures lose evidence.Retain test/coverage/analyzer artifacts even for failed runs when the CI system permits.

11. Verification checklist

  • You can explain why coverage is evidence of execution, not proof of correctness.
  • You can configure Pester 6 coverage and test-result XML through PesterConfiguration.
  • You know -CI produces a failure status for build systems.
  • You can compute and enforce an explicit coverage gate from the returned result object.
  • You can combine Pester and PSScriptAnalyzer into one local quality script.
  • You can retain machine-readable reports independently of a paid CI provider.

12. Knowledge check

Question 1. What does code coverage prove?

Question 2. What current Pester mechanism configures advanced coverage/report settings?

Question 3. What does Invoke-Pester -CI do for build systems?

Question 4. Why keep quality logic in a repository script?

Question 5. What three independent signals can a simple gate combine?

13. Summary and next bridge

CI quality is not one number. Pester results, analyzer diagnostics, coverage evidence, process exit status, and retained reports each serve a distinct purpose. The final lesson moves one level deeper: architecture. Instead of struggling to test a monolithic script, design the automation so pure logic and side effects are separated from the beginning.

14. Authoritative references

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.