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.
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.
| Signal | What it tells you | What it cannot prove |
|---|---|---|
| Pester pass/fail | Observed behaviors matched test assertions. | Untested behavior is correct. |
| Coverage | Code was exercised during the run. | The executed code produced the right result. |
| PSScriptAnalyzer | Configured static rules found or did not find patterns. | Runtime/environment behavior is correct. |
| Quality gate | A 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,DurationPester 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,LastWriteTimeChoose 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,LastWriteTimeDo 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.
| Gate | Good use | Bad use |
|---|---|---|
| Tests | Block known behavioral regressions. | Count tests without considering their assertions. |
| Static analysis | Block configured high-risk patterns. | Treat every informational style rule as equally critical. |
| Coverage | Detect unexpectedly untested logic and regressions in exercised scope. | Equate a percentage with correctness. |
| Artifacts | Preserve evidence for debugging/audit. | Retain sensitive logs forever without access control. |
10. Common CI-quality mistakes
| Mistake | Failure mode | Better pattern |
|---|---|---|
| Pipeline ignores process exit code | Failed tests appear green. | Throw/exit nonzero from the quality script. |
| Old Pester report parameters | Pester 6 parameter binding fails. | Use PesterConfiguration or current -CI mode. |
| Coverage-only quality target | High percentage with weak assertions. | Combine behavior, analysis, and targeted coverage. |
| CI-only build logic | Local reproduction is difficult. | Keep quality logic in a local repository script. |
| Discard reports on failure | Hardest 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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.