pester-cli-testing

Configures Pester v5 for testing PowerShell CLIs, scripts, and cmdlets - Describe/Context/It blocks, Should assertions, Mock for isolating external dependencies, BeforeAll/BeforeEach setup hooks, Invo

SKILL.md

GitHub

pester-cli-testing

Overview

Per pester-install, Pester is the standard PowerShell test framework for unit, integration, and acceptance testing of scripts, functions, modules, and CLI tools invoked from pwsh. It supports Windows PowerShell 3.0 - 5.1 and PowerShell 6.0.4+ across Windows, Linux, and macOS. When the unit-under-test is a shell script or binary on Linux/macOS, use bats-testing instead; Pester is the correct choice whenever the subject or test is PowerShell.

Step 1 - Install

Per pester-install, Windows 10 / Server 2016+ ship with Pester 3.4.0 built-in. The bundled version cannot be updated via Update-Module alone because its publisher certificate differs from the community-signed Gallery version. Install Pester v5 side-by-side:

# Windows (5.1 or pwsh 7+): -Force enables side-by-side; -SkipPublisherCheck
# accepts the newer certificate
Install-Module -Name Pester -Force -SkipPublisherCheck

# Linux / macOS (pwsh 7+): no bundled version to conflict with
Install-Module -Name Pester

# Verify
Import-Module Pester -PassThru

Step 2 - First test file

Per pester-quick-start:

Test files must follow the *.Tests.ps1 naming convention. The outer BeforeAll block dot-sources the script under test so its functions are available to every nested block:

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

Describe 'Get-Greeting' {
    It 'returns a greeting for the given name' {
        Get-Greeting -Name 'Alice' | Should -Be 'Hello, Alice!'
    }
}

Run with:

Invoke-Pester -Output Detailed .\Get-Greeting.Tests.ps1

Step 3 - Describe / Context / It

Per pqs, Context and Describe are nearly interchangeable. Use Describe at the top level (function or CLI command name) and Context to group scenarios:

Describe 'Invoke-Deploy' {
    Context 'when the target environment is valid' {
        It 'exits 0 and logs a success message' {
            # ...
        }
    }

    Context 'when credentials are missing' {
        It 'throws a terminating error' {
            # ...
        }
    }
}

Step 4 - Should assertions

Per pqs, Should is the assertion command. Common matchers:

$result | Should -Be 'expected'           # strict equality
$result | Should -BeExactly 'Expected'    # case-sensitive equality
$result | Should -BeLike '*partial*'      # wildcard match
$result | Should -Match 'regex\d+'        # regex match
$result | Should -BeNullOrEmpty           # null or empty string/array
$result | Should -BeGreaterThan 0
{ risky-call } | Should -Throw            # expects a terminating error
{ risky-call } | Should -Throw '*message*' # error message wildcard
$result | Should -Not -Be $null           # negated form

Step 5 - BeforeAll / BeforeEach

Per pqs, lifecycle hooks load fixtures and reset state. BeforeAll runs once per block; BeforeEach runs before every It:

Describe 'Get-Report' {
    BeforeAll {
        . $PSScriptRoot/Get-Report.ps1
        $script:TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
        New-Item -ItemType Directory -Path $script:TempDir | Out-Null
    }

    AfterAll {
        Remove-Item -Recurse -Force $script:TempDir
    }

    BeforeEach {
        # reset any per-test state here
    }

    It 'writes a report file to the output directory' {
        Get-Report -OutputPath $script:TempDir
        Test-Path (Join-Path $script:TempDir 'report.csv') | Should -BeTrue
    }
}

Use $script: scope when a variable set in BeforeAll must be read inside It blocks. Variables declared with plain $var in BeforeAll are not visible inside It due to PowerShell scoping rules.

Step 6 - Mock

Per pester-mock:

"Mock mocks the behavior of an existing command with an alternate implementation."

Describe 'Send-Notification' {
    BeforeAll {
        . $PSScriptRoot/Send-Notification.ps1

        Mock Invoke-RestMethod {
            return @{ status = 'ok' }
        }

        Mock Write-Error {}    # suppress error output in test runs
    }

    It 'calls Invoke-RestMethod once with the correct URI' {
        Send-Notification -Message 'deploy done'
        Should -Invoke Invoke-RestMethod -Times 1 -Exactly
    }

    It 'passes a ParameterFilter to scope the mock to specific arguments' {
        Mock Get-Date { return [datetime]'2024-01-01' } -ParameterFilter {
            $Format -eq 'yyyy-MM-dd'
        }
        $result = Get-FormattedDate
        $result | Should -Be '2024-01-01'
    }
}

Per pm, mocks placed in BeforeAll apply to all It blocks in the enclosing Describe/Context; a mock inside an It block scopes only to that test. Use -Scope to override. Should -Invoke verifies call count:

Should -Invoke -CommandName Invoke-RestMethod -Times 1 `
    -ParameterFilter { $Uri -like '*api.example.com*' }

Per pm, $PesterBoundParameters (available since Pester 5.2.0) replaces $PSBoundParameters inside mock script blocks.

Step 7 - Tags

Per pester-tags:

Tags can be applied to Describe, Context, and It blocks. Run a subset by filtering on tags:

Describe 'Invoke-Deploy' -Tag 'Integration' {
    Context 'slow path' -Tag 'Slow' {
        It 'completes a full deployment cycle' -Tag 'E2E' {
            # ...
        }
    }
}
# Run only Integration tests, excluding slow ones
Invoke-Pester $path -TagFilter 'Integration' -ExcludeTagFilter 'Slow', 'WindowsOnly'

Per pt, tag matching uses -like comparison, so wildcards work:

Invoke-Pester $path -ExcludeTagFilter 'Slow*', '*Only'

Step 8 - Invoke-Pester with PesterConfiguration

Per pester-config:

New-PesterConfiguration returns a typed configuration object. Cast from a hashtable for concise setup:

$config = [PesterConfiguration]@{
    Run    = @{
        Path = '.\tests'
        Exit = $true          # non-zero exit code on failure (required for CI)
    }
    Filter = @{
        Tag        = 'Unit'
        ExcludeTag = 'Slow', 'WindowsOnly'
    }
    Output = @{
        Verbosity = 'Detailed'
    }
}
Invoke-Pester -Configuration $config

Key Run properties and their defaults (Run.Path, Run.ExcludePath, Run.Exit, Run.TestExtension) are tabulated in references/ci-and-config.md.

Step 9 - Code coverage

Per pester-coverage:

$config = New-PesterConfiguration
$config.Run.Path                      = '.\tests'
$config.CodeCoverage.Enabled          = $true
$config.CodeCoverage.Path             = '.\src'
$config.CodeCoverage.CoveragePercentTarget = 75   # fail if below 75%
$config.CodeCoverage.OutputFormat     = 'JaCoCo'  # or 'CoverageGutters'
$config.CodeCoverage.OutputPath       = 'coverage.xml'
Invoke-Pester -Configuration $config

Per pcov, Pester does not traverse directories automatically for coverage; set CodeCoverage.Path explicitly when source files are not co-located with tests.

Step 10 - Test result XML and CI

Per pester-results:

$config = New-PesterConfiguration
$config.Run.Path               = '.\tests'
$config.Run.Exit               = $true
$config.TestResult.Enabled     = $true
$config.TestResult.OutputFormat = 'NUnitXml'   # or 'JUnitXml'
$config.TestResult.OutputPath  = 'testResults.xml'
Invoke-Pester -Configuration $config

Full CI pipeline definitions - a single cross-platform GitHub Actions matrix (with code coverage and artifact upload) and the Azure DevOps Publish Test Results task - are in references/ci-and-config.md.

Anti-patterns

Anti-pattern Why it fails Fix
Dot-sourcing the script under test inside It Re-executes on every test; slow and leaks state Dot-source once in BeforeAll
Using plain $var in BeforeAll, reading in It PowerShell scope: It does not inherit BeforeAll locals Use $script:var
Mocking in It when needed across multiple tests Mock scopes to that single test only Move mock to BeforeAll or BeforeEach
Omitting Run.Exit = $true in CI config Pester exits 0 even on failures; CI pipeline passes on broken tests Set Run.Exit = $true
Setting CodeCoverage.Path = '.' without explicit paths Pester may miss source files not co-located with tests Set CodeCoverage.Path to the source directory explicitly
Using legacy Invoke-Pester -Script syntax Removed in Pester v5; breaks silently on older runners Use PesterConfiguration with Run.Path

Limitations

  • Mocking scope is version-sensitive. Pester v5 scopes mocks to their placement block (not the entire Describe). Code written for v4 may need mock placement adjusted.
  • No built-in parallel execution. Pester v5 runs tests serially within a session. Parallelism requires splitting test files across separate pwsh processes or using a CI matrix.
  • Non-PowerShell binaries. Pester can invoke any executable, but assertion helpers are PowerShell-centric. For deep exit-code / stdout testing of Unix binaries, bats-testing is more idiomatic.
  • Windows PowerShell 5.1 vs pwsh 7+. Some Pester 5 features (e.g. $PesterBoundParameters) require a minimum Pester patch level; pin the version in CI with Install-Module -Name Pester -RequiredVersion 5.x.y.

References

  • pester-install - compatibility, Windows side-by-side install, cross-platform pwsh install.
  • pqs - quick-start, BeforeAll, Describe/Context/It, Should.
  • pm - Mock, ParameterFilter, Should -Invoke, scope rules, $PesterBoundParameters.
  • pc - New-PesterConfiguration, Run, Filter, Output, Invoke-Pester -Configuration.
  • pcov - CodeCoverage.Enabled, CoveragePercentTarget, formats, path scoping.
  • pr - TestResult.Enabled, OutputFormat, OutputPath, Azure DevOps / AppVeyor CI notes.
  • references/ci-and-config.md - cross-platform GitHub Actions matrix, Azure DevOps publish task, Run property table.
  • bats-testing - use for shell script / Unix binary testing; Pester is the correct choice when the subject or test is PowerShell.
  • cli-output-conventions - what to assert on (stable formats, exit codes, stderr vs stdout contracts).