Pester Unit Testing for PowerShell
Pester is PowerShell's ubiquitous test and mock framework. Pester 5+ uses a two-phase execution model (Discovery → Run) that requires specific patterns for reliable tests.
TDD Cycle
-
Red – Write a failing test describing expected behavior
-
Green – Implement minimal code to pass
-
Refactor – Clean up while keeping tests green
Test File Structure
Test files use *.Tests.ps1 naming convention. Place alongside source files:
src/ ├── Get-Widget.ps1 └── Get-Widget.Tests.ps1
Basic Template
BeforeAll { . $PSCommandPath.Replace('.Tests.ps1', '.ps1') }
Describe 'Get-Widget' { Context 'when called with valid ID' { It 'returns widget object' { $result = Get-Widget -Id 42 $result.Id | Should -Be 42 } }
Context 'when widget does not exist' {
It 'throws not found error' {
{ Get-Widget -Id 9999 } | Should -Throw -ErrorId 'WidgetNotFound'
}
}
}
Block Hierarchy
Block Purpose Scope
Describe
Top-level grouping (1 per function/feature) Container
Context
Scenario grouping ("when X", "with Y") Sub-container
It
Single test case with assertions Test
BeforeAll
Run once before all tests in block Setup
BeforeEach
Run before each It
Per-test setup
AfterEach
Run after each It (guaranteed) Per-test cleanup
AfterAll
Run once after all tests (guaranteed) Final cleanup
Discovery vs Run Phase (Critical)
Pester 5 executes in two phases:
-
Discovery – Scans to find all tests (does NOT run It blocks)
-
Run – Executes tests with setup/teardown
Rule: Put ALL code inside It , BeforeAll , BeforeEach , AfterEach , AfterAll , or BeforeDiscovery .
❌ WRONG - runs during Discovery, $data is null in Run phase
$data = Get-ExpensiveData Describe 'Tests' { It 'works' { $data | Should -Not -BeNull } # FAILS! }
✅ CORRECT - use BeforeAll
Describe 'Tests' { BeforeAll { $script:data = Get-ExpensiveData } It 'works' { $script:data | Should -Not -BeNull } }
For dynamic test generation, use BeforeDiscovery :
BeforeDiscovery { $testCases = @('file1.ps1', 'file2.ps1') }
Describe 'Validate <>' -ForEach $testCases { BeforeAll { $file = $ } It 'has valid syntax' { ... } }
Mocking
Mock any PowerShell command within test scope:
Describe 'Send-Report' { BeforeAll { Mock Send-MailMessage {} Mock Get-Date { return [DateTime]'2024-01-15' } }
It 'sends email with correct subject' {
Send-Report -Title 'Summary'
Should -Invoke Send-MailMessage -Times 1 -ParameterFilter {
$Subject -like '*Summary*'
}
}
}
Parameter Filters
Create conditional mocks for different inputs:
Mock Get-Service { @{ Status = 'Running' } } -ParameterFilter { $Name -eq 'BITS' } Mock Get-Service { @{ Status = 'Stopped' } } -ParameterFilter { $Name -eq 'Spooler' } Mock Get-Service { @{ Status = 'Unknown' } } # Default fallback
Mocking Native Commands (bash, git, curl)
Native commands work via $args :
Describe 'Git Operations' { BeforeAll { Mock git { 'mocked-output' } }
It 'calls git with correct args' {
Invoke-GitPush -Branch 'main'
Should -Invoke git -ParameterFilter {
$args[0] -eq 'push' -and $args[1] -eq 'origin'
}
}
}
Module Internals
Use -ModuleName for functions inside modules:
Mock Get-InternalData { 'mocked' } -ModuleName MyModule
Use InModuleScope for private/non-exported functions:
InModuleScope MyModule { Mock Write-Log {} Invoke-PrivateFunction Should -Invoke Write-Log }
Test Isolation
TestDrive (Filesystem)
Temporary PSDrive auto-cleaned per block:
Describe 'File Processing' { BeforeAll { Set-Content 'TestDrive:\config.json' -Value '{"key":"value"}' }
It 'reads config' {
$cfg = Get-Content 'TestDrive:\config.json' | ConvertFrom-Json
$cfg.key | Should -Be 'value'
}
}
Use $TestDrive for .NET APIs requiring full paths:
$path = Join-Path $TestDrive 'file.txt' [System.IO.File]::WriteAllText($path, 'content')
TestRegistry (Windows)
Temporary registry hive:
BeforeAll { New-Item -Path 'TestRegistry:\MyApp' New-ItemProperty -Path 'TestRegistry:\MyApp' -Name 'Setting' -Value 'Test' }
Environment Variables
Save and restore manually:
BeforeEach { $script:oldEnv = $env:MY_VAR $env:MY_VAR = 'test-value' }
AfterEach { $env:MY_VAR = $script:oldEnv }
Output Capture
Stream Redirection
Stream Command Capture
1 (Success) Write-Output Direct assignment
2 (Error) Write-Error 2>&1 or -ErrorVariable
3 (Warning) Write-Warning 3>&1
4 (Verbose) Write-Verbose 4>&1 with -Verbose
6 (Information) Write-Host 6>&1
It 'captures Write-Host' { $result = MyFunction 6>&1 $result | Should -Contain 'expected message' }
ANSI Color Stripping
function Remove-AnsiCodes { param([string]$Text) $Text -replace '\x1b[[0-9;]*[a-zA-Z]', '' }
$clean = Remove-AnsiCodes $coloredOutput
Or configure Pester: $config.Output.RenderMode = 'Plaintext'
Parameterized Tests
Use -ForEach or -TestCases :
Describe 'Add-Numbers' { It 'adds <a> + <b> = <expected>' -TestCases @( @{ a = 2; b = 3; expected = 5 } @{ a = -1; b = 1; expected = 0 } ) { Add-Numbers $a $b | Should -Be $expected } }
Running Specific Tests
Tags
It 'slow test' -Tag 'Integration', 'Slow' { ... }
Run only tagged tests
Invoke-Pester -TagFilter 'Unit' -ExcludeTagFilter 'Slow'
Name Filters
Invoke-Pester -FullNameFilter 'Get-Widgetreturns*'
Skip
It 'admin only' -Skip:(-not (Test-IsAdmin)) { ... }
Code Coverage
$config = New-PesterConfiguration $config.CodeCoverage.Enabled = $true $config.CodeCoverage.Path = './src' $config.CodeCoverage.OutputFormat = 'JaCoCo' $config.CodeCoverage.OutputPath = 'coverage.xml' $config.CodeCoverage.CoveragePercentTarget = 80
Invoke-Pester -Configuration $config
CI Reports (JUnit/NUnit)
$config = New-PesterConfiguration $config.TestResult.Enabled = $true $config.TestResult.OutputFormat = 'JUnitXml' # or NUnitXml $config.TestResult.OutputPath = 'test-results.xml' $config.Run.Exit = $true # Exit code for CI
Invoke-Pester -Configuration $config
Additional Resources
-
references/anti-patterns.md - Common mistakes and pitfalls with solutions
-
references/mocking-patterns.md - Advanced mocking scenarios (APIs, databases, native commands)
-
references/ci-integration.md - GitHub Actions, Azure DevOps, GitLab CI, Jenkins examples
Common Anti-Patterns
See references/anti-patterns.md for detailed examples.
Quick checklist:
-
❌ Code outside Pester blocks
-
❌ Tests depending on each other
-
❌ Using foreach instead of -ForEach
-
❌ Mocking the function under test
-
❌ Over-specifying mock interactions
-
❌ Global variables in tests
Assertion Quick Reference
Assertion Description
Should -Be
Case-insensitive equality
Should -BeExactly
Case-sensitive equality
Should -BeTrue / -BeFalse
Boolean
Should -BeNullOrEmpty
Null/empty check
Should -BeOfType
Type checking
Should -Contain
Collection contains
Should -Match
Regex (case-insensitive)
Should -BeLike
Wildcard match
Should -Throw
Exception expected
Should -Exist
Path exists
Should -HaveCount
Collection count
Should -Invoke
Mock was called
Full assertion list: Get-ShouldOperator