PowerShell Pester v5: Writing Unit Tests for Real Scripts

Pester v5 landed with a breaking change warning and meant it. Variables defined in BeforeEach scopes behave differently, Mock visibility rules tightened considerably, and the old positional parameter shorthand stopped working. If you have a test suite written for Pester v4, some of it will silently pass when it should fail — which is worse than an outright error. This guide covers the v5 structure from the ground up and walks through testing a real administrative function so you understand not just syntax but intent.
Pester v5 Structure: Describe, Context, It, and BeforeAll
The fundamental change in v5 is strict scoping. Variables assigned in a BeforeAll block must be declared with $script: scope to be visible inside It blocks. BeforeEach runs before every It in its container but its variables still require $script:. The container hierarchy is Describe (feature) → Context (scenario) → It (assertion).
BeforeAll {
# Import the module under test — runs once per Describe
Import-Module "$PSScriptRoot\..\src\UserManagement.psm1" -Force
$script:TestDomain = 'lab.contoso.com'
}
Describe 'New-LabUser' {
Context 'When input is valid' {
BeforeEach {
$script:params = @{
SamAccountName = 'jdoe'
GivenName = 'Jane'
Surname = 'Doe'
Domain = $script:TestDomain
}
}
It 'should call New-ADUser exactly once' {
Mock New-ADUser {}
New-LabUser @script:params
Should -Invoke New-ADUser -Exactly 1
}
}
}
Mocking Cmdlets and External Dependencies with Mock
Mocking in Pester v5 replaces a cmdlet or function with a scriptblock for the duration of the enclosing container. The Mock call must be in the same Describe or Context scope as the It block that exercises it — not in BeforeAll at a higher level than the test that needs it. Use -ParameterFilter to return different values for different inputs.
Describe 'Get-DiskStatus' {
Context 'When a drive is below threshold' {
Mock Get-CimInstance {
[PSCustomObject]@{
DeviceID = 'C:'
Size = 100GB
FreeSpace = 5GB
}
} -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' }
It 'should return status Critical' {
$result = Get-DiskStatus -DriveLetter 'C:'
$result.Status | Should -Be 'Critical'
}
}
Context 'When a drive has adequate space' {
Mock Get-CimInstance {
[PSCustomObject]@{
DeviceID = 'D:'
Size = 100GB
FreeSpace = 60GB
}
} -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' }
It 'should return status OK' {
$result = Get-DiskStatus -DriveLetter 'D:'
$result.Status | Should -Be 'OK'
}
}
}
Asserting with Should -Be, -Throw, -Invoke, -HaveCount
Pester v5 ships with an expanded set of assertion operators. Should -Be checks value equality; Should -BeExactly is case-sensitive. Should -Throw asserts an exception is raised and optionally matches the message. Should -Invoke replaces the old Assert-MockCalled and must appear after the code under test, not after Mock. Should -HaveCount validates collection size.
It 'should throw when SamAccountName is empty' {
{ New-LabUser -SamAccountName '' -GivenName 'Jane' -Surname 'Doe' } |
Should -Throw -ExceptionType ([System.ArgumentException])
}
It 'should return three user objects for three CSV rows' {
Mock Import-Csv { @(
[PSCustomObject]@{ Sam = 'u1'; First = 'A'; Last = 'B' }
[PSCustomObject]@{ Sam = 'u2'; First = 'C'; Last = 'D' }
[PSCustomObject]@{ Sam = 'u3'; First = 'E'; Last = 'F' }
)}
Mock New-ADUser {}
$result = Import-UsersFromCsv -Path 'dummy.csv'
$result | Should -HaveCount 3
Should -Invoke New-ADUser -Exactly 3
}
Using -PassThru and Should -BeOfType
-PassThru on the Invoke-Pester call returns a Pester.Run result object rather than writing directly to the console. This is essential for CI pipelines. Pair it with Should -BeOfType in meta-tests, or use the result object to drive a build failure condition:
$config = New-PesterConfiguration
$config.Run.Path = '.\tests'
$config.Output.Verbosity = 'Detailed'
$result = Invoke-Pester -Configuration $config -PassThru
# Fail the build if any tests failed
if ($result.FailedCount -gt 0) {
throw "Pester: $($result.FailedCount) test(s) failed."
}
# Type assertion example inside a test
It 'New-LabUser should return a PSCustomObject' {
Mock New-ADUser {}
$out = New-LabUser -SamAccountName 'jtest' -GivenName 'J' -Surname 'T'
$out | Should -BeOfType [PSCustomObject]
}
Code Coverage Reports with -CodeCoverage
Code coverage in Pester v5 is configured through PesterConfiguration. Set $config.CodeCoverage.Enabled = $true and supply the source paths. Coverage output can target JaCoCo XML for Azure DevOps or Cobertura for other CI tools. Always specify paths explicitly — relying on current working directory causes path mismatch failures in CI agents that check out to non-standard locations.
Running Pester in Azure DevOps with NUnit XML Output
Azure DevOps consumes NUnit-format XML for test result publishing. Set $config.TestResult.Enabled = $true, $config.TestResult.OutputFormat = 'NUnit2.5', and point OutputPath to a known location. Follow the PublishTestResults task with a check on $LASTEXITCODE to fail the pipeline if tests failed. Never swallow the exit code by calling Invoke-Pester without -PassThru in a pipeline — a failed run will silently continue.
Common Errors
- Mock defined in BeforeEach scope not visible inside It block in v5. This is the most common v4-to-v5 migration trap.
Mockin Pester v5 must live in aDescribeorContextblock, not insideBeforeEach. Move theMockcall to theContextlevel and use-ParameterFilterfor any per-test variation. - Code coverage path mismatch when running from a different working directory. The
CodeCoverage.Pathproperty expects absolute paths or paths relative to the Pester invocation directory. In Azure DevOps pipelines the working directory may differ from the repository root. Always use$PSScriptRootor$env:BUILD_SOURCESDIRECTORYto construct absolute paths.
Related Cmdlets / See Also
- Getting Started with Pester Testing in PowerShell
- PowerShell Comment-Based Help
- How to Sign PowerShell Scripts
Wrapping Up
Pester v5’s stricter scoping pays dividends in test reliability once you understand the rules. Use $script: for shared variables, place Mock in container blocks not setup blocks, and always run with -PassThru in CI so exit codes are meaningful. A well-structured Pester suite is the safety net that makes refactoring production scripts a confidence game rather than a gamble.


