Pester 6 Is Here

Pester 6.0.0 was released on July 7, roughly two years after the first 6.0 alpha. If you followed my getting started with Pester post last year, everything you wrote then still runs — but there’s a new way of writing assertions, a few behavior changes that can bite, and some genuinely useful additions.
Update (September 2026): two point releases followed — see what Pester 6.1 brought and what Pester 6.2 brought. Note that 6.2 reverses one of the breaking changes below. And if you use the experimental parallel runs, 6.0.1 (July 18) fixes an intermittent crash in them.
A long road to 6.0
The first 6.0.0-alpha1 was published in June 2024, and alpha5 followed in October that year. Then things went quiet for about twenty months, until five release candidates were published in a single week at the end of June 2026, and GA followed on July 7. Jakub Jareš (nohwnd) published every one of them, with Frode Flaten (fflaten) as co-maintainer.
The same day, Pester 5.9.0 moved v5 into maintenance mode: “critical bug fixes and security updates only, no new features.”
Supported versions: Pester 6 runs on Windows PowerShell 5.1 and PowerShell 7.4 and later. Windows PowerShell is not dropped — what’s gone is PowerShell 3, 4, 6 and the early, unsupported 7.x versions.
Installing it next to the inbox version
Windows 10 / Server 2016 and later ship Pester 3.4.0 inbox, and Update-Module can’t update it, so you install the new version side by side.
On Windows PowerShell 5.1 that still trips over the publisher check. The inbox 3.4.0 is signed by Microsoft, newer Pester versions are signed with a different certificate, and the built-in PowerShellGet refuses:
Install-Module Pester -RequiredVersion 6.0.0 -Scope CurrentUser -ForcePackageManagement\Install-Package : A Microsoft-signed module named 'Pester' with version '3.4.0' that was previously installed conflicts with the new module 'Pester' from publisher 'CN=DigiCert Trusted Root G4,
OU=www.digicert.com, O=DigiCert Inc, C=US' with version '6.0.0'. Installing the new module may result in system instability. If you still want to install or update, use -SkipPublisherCheck parameter.
At C:\Program Files\WindowsPowerShell\Modules\PowerShellGet\1.0.0.1\PSModule.psm1:1809 char:21
+ ... $null = PackageManagement\Install-Package @PSBoundParameters
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (Microsoft.Power....InstallPackage:InstallPackage) [Install-Package], Exception
+ FullyQualifiedErrorId : PublishersMismatch,Validate-ModuleAuthenticodeSignature,Microsoft.PowerShell.PackageManagement.Cmdlets.InstallPackageSo on 5.1, add -SkipPublisherCheck, as the install docs tell you to:
Install-Module Pester -RequiredVersion 6.0.0 -Scope CurrentUser -Force -SkipPublisherCheckOn PowerShell 7 you can leave it off: PowerShell 7.6 installed Pester 6 without it for me, with 3.4.0 still in its inbox location.
All the examples below come from a small demo folder with a run.ps1 helper that imports one exact Pester version and runs a test file with detailed output — that way you see what that version does, not whatever is newest on your machine.
The new Should-* assertions
The headline feature: the Assert project has been merged into Pester. Next to the classic Should -Be (with a space) you now get Should-Be (with a dash) and around 40 siblings: Should-BeString, Should-BeCollection, Should-BeEquivalent, Should-HaveType, Should-Throw, Should-HaveParameter, and so on.
The classic syntax keeps working. Nothing forces you to rewrite your tests. You adopt the new assertions where they help, and if you want to enforce the new style later, Should.DisableV5 = $true in your configuration makes the old syntax throw.
Here are the Format-PersonName tests from the getting started post, rewritten with the new assertions:
Describe 'Format-PersonName' {
It 'Should format names in standard order by default' {
Format-PersonName -FirstName 'john' -LastName 'doe' | Should-BeString 'John Doe' -CaseSensitive
}
It 'Should format names with last name first when switch is used' {
Format-PersonName -FirstName 'jane' -LastName 'smith' -LastNameFirst | Should-BeString 'Smith, Jane' -CaseSensitive
}
It 'Should handle extra spaces correctly' {
Format-PersonName -FirstName ' bob ' -LastName ' johnson ' | Should-BeString 'Bob Johnson'
}
It 'Should have a mandatory FirstName parameter' {
Get-Command Format-PersonName | Should-HaveParameter -ParameterName FirstName -Type ([string]) -Mandatory
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/Format-PersonName.Tests.ps1Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\Format-PersonName.Tests.ps1'
Describing Format-PersonName
[+] Should format names in standard order by default 370ms
[+] Should format names with last name first when switch is used 31ms
[+] Should handle extra spaces correctly 33ms
[+] Should have a mandatory FirstName parameter 142ms
Tests completed in 3.17s
Tests Passed: 4, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0Two things worth knowing about the new family:
- They’re type-specific.
Should-BeStringhas string options (-CaseSensitive,-TrimWhitespace), collection assertions are separate from value assertions, andShould-Berefuses a collection on-Expectedinstead of guessing what you meant. - The failure messages are much better. That’s really the reason to switch. Here are three tests that fail on purpose:
Describe 'Failure messages' {
It 'compares strings case-sensitively' {
'Hello' | Should-BeString 'hello' -CaseSensitive
}
It 'checks the type of a piped array' {
[int[]](1, 2) | Should-HaveType ([int[]])
}
It 'compares nested objects' {
$actual = [pscustomobject]@{
Name = 'Jane'
Address = [pscustomobject]@{ City = 'Springfield' }
}
$actual | Should-BeEquivalent ([pscustomobject]@{
Name = 'Jane'
Address = [pscustomobject]@{ City = 'Shelbyville' }
})
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/Messages.Tests.ps1Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\Messages.Tests.ps1'
Describing Failure messages
[-] compares strings case-sensitively 223ms
Expected strings to be the same, but they were different.
String lengths are both 5.
Strings differ at index 0.
Expected: 'hello'
But was: 'Hello'
^
at 'Hello' | Should-BeString 'hello' -CaseSensitive, C:\code\pester-v6-demo\v6.0\Messages.Tests.ps1:4
[-] checks the type of a piped array 153ms
Expected value to have type [int[]], but got [Object[]] @(1, 2).
Hint: You piped a [int[]] into a type assertion, but the pipeline streams a multi-item collection and re-collects it as [Object[]], so the assertion saw [Object[]], not the [int[]] you piped. To assert the type of a collection, pass it as the -Actual argument instead of piping it, e.g. -Actual $value.
at [int[]](1, 2) | Should-HaveType ([int[]]), C:\code\pester-v6-demo\v6.0\Messages.Tests.ps1:8
[-] compares nested objects 207ms
Expected and actual are not equivalent!
Expected:
PSObject{
Address=PSObject{
City='Shelbyville';
};
Name='Jane';
}
Actual:
PSObject{
Address=PSObject{
City='Springfield';
};
Name='Jane';
}
Summary:
Expected property .Address.City with value 'Shelbyville' to be equivalent to the actual value, but got 'Springfield'.
at $actual | Should-BeEquivalent ([pscustomobject]@{, C:\code\pester-v6-demo\v6.0\Messages.Tests.ps1:16
Tests completed in 1.68s
Tests Passed: 0, Failed: 3, Skipped: 0, Inconclusive: 0, NotRun: 0What stands out is the level of detail. The string failure doesn’t just tell you the values differ: it gives you the lengths, the index and a caret under the first difference, so it’s immediately obvious why it failed. And it doesn’t stop at one level — the nested object comparison points straight at .Address.City. The hint on the piped array is very handy too; that one is a classic PowerShell pipeline gotcha.
Should-BeEquivalent
Should-BeEquivalent deserves its own section. To be honest, it’s a great reason to switch to v6 on its own — it’s very, very powerful.
It compares two objects deeply: every property, nested objects and collections included, and tells you exactly which path differs. Two parameters make it practical for real objects:
-ExcludePathsNotOnExpected— only compare the properties you put in the expected object, and ignore everything else on the actual one-ExcludePath— skip specific properties, like an ID or a timestamp that’s different on every run
That replaces a lot of hand-written property-by-property comparisons. A user object with fixed values, and four tests against it:
Describe 'Should-BeEquivalent' {
BeforeAll {
# Fixed values, so the output is the same on every run
$user = [pscustomobject]@{
Id = '5f0c3b1e-2a4d-4c8e-9b7a-1d2e3f4a5b6c'
Name = 'Jane'
Roles = @('admin', 'user')
Address = [pscustomobject]@{ City = 'Springfield'; Country = 'US' }
Created = [datetime]'2026-07-01T09:00:00'
}
}
It 'compares the whole object, nested properties included' {
$user | Should-BeEquivalent ([pscustomobject]@{
Id = '5f0c3b1e-2a4d-4c8e-9b7a-1d2e3f4a5b6c'
Name = 'Jane'
Roles = @('admin', 'user')
Address = [pscustomobject]@{ City = 'Springfield'; Country = 'US' }
Created = [datetime]'2026-07-01T09:00:00'
})
}
It 'only checks the properties you list' {
$user | Should-BeEquivalent ([pscustomobject]@{
Name = 'Jane'
Address = [pscustomobject]@{ City = 'Springfield' }
}) -ExcludePathsNotOnExpected
}
It 'skips properties that differ on every run' {
$user | Should-BeEquivalent ([pscustomobject]@{
Id = 'something else'
Name = 'Jane'
Roles = @('admin', 'user')
Address = [pscustomobject]@{ City = 'Springfield'; Country = 'US' }
Created = [datetime]'2000-01-01'
}) -ExcludePath 'Id', 'Created'
}
It 'reports every difference at once (fails on purpose)' {
$user | Should-BeEquivalent ([pscustomobject]@{
Name = 'John'
Roles = @('admin')
Address = [pscustomobject]@{ City = 'Springfield'; Country = 'NL' }
}) -ExcludePathsNotOnExpected
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/Equivalent.Tests.ps1Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\Equivalent.Tests.ps1'
Describing Should-BeEquivalent
[+] compares the whole object, nested properties included 273ms
[+] only checks the properties you list 18ms
[+] skips properties that differ on every run 31ms
[-] reports every difference at once (fails on purpose) 101ms
Expected and actual are not equivalent!
Expected:
PSObject{
Address=PSObject{
City='Springfield';
Country='NL';
};
Name='John';
Roles=@('admin');
}
Actual:
PSObject{
Address=PSObject{
City='Springfield';
Country='US';
};
Created=07/01/2026 09:00:00;
Id='5f0c3b1e-2a4d-4c8e-9b7a-1d2e3f4a5b6c';
Name='Jane';
Roles=@('admin', 'user');
}
Summary:
Expected property .Name with value 'John' to be equivalent to the actual value, but got 'Jane'.
Expected property .Roles with value 'admin' to be equivalent to the actual value, but got @('admin', 'user').
Expected property .Address.Country with value 'NL' to be equivalent to the actual value, but got 'US'.
Used options:
Excluding all paths not found on Expected.
at $user | Should-BeEquivalent ([pscustomobject]@{, C:\code\pester-v6-demo\v6.0\Equivalent.Tests.ps1:41
Tests completed in 1.36s
Tests Passed: 3, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0Three differences, three lines in the summary, each with the full path to the property — including the nested .Address.Country. No more fixing one property, rerunning, and finding the next one. The “Used options” line at the end is a nice touch too: it tells you which exclusions were active, so you know what wasn’t compared.
Soft assertions
By default a test stops at the first failed assertion. Set Should.ErrorAction = 'Continue' and the new assertions keep going, reporting every failure in the test:
Describe 'User object' {
It 'has the expected shape' {
$user = [pscustomobject]@{ Name = 'Jane'; Age = 30; City = 'Springfield' }
$user.Name | Should-Be 'John'
$user.Age | Should-Be 31
$user.City | Should-Be 'Springfield'
}
}Without and with soft assertions:
pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/SoftAssertions.Tests.ps1
pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/SoftAssertions.Tests.ps1 -SoftAssertPester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\SoftAssertions.Tests.ps1'
Describing User object
[-] has the expected shape 193ms
Expected [string] 'John', but got [string] 'Jane'.
at $user.Name | Should-Be 'John', C:\code\pester-v6-demo\v6.0\SoftAssertions.Tests.ps1:6
Tests completed in 1.18s
Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0
Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\SoftAssertions.Tests.ps1'
Describing User object
[-] has the expected shape 199ms
[0] Expected [string] 'John', but got [string] 'Jane'.
at $user.Name | Should-Be 'John', C:\code\pester-v6-demo\v6.0\SoftAssertions.Tests.ps1:6
[1] Expected [int] 31, but got [int] 30.
at $user.Age | Should-Be 31, C:\code\pester-v6-demo\v6.0\SoftAssertions.Tests.ps1:7
Tests completed in 986ms
Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0Handy when you’re checking the shape of an object and want to fix everything in one go instead of one property per run.
Mock call history
When Should -Invoke fails, Pester 6 now shows you the calls that were made, and which of them matched your filter:
BeforeAll {
function Send-Email ($To, $Subject) { }
}
Describe 'Order processing' {
It 'emails alice exactly twice' {
Mock Send-Email { }
Send-Email -To 'alice@example.com' -Subject 'Welcome'
Send-Email -To 'bob@example.com' -Subject 'Receipt'
Should -Invoke Send-Email -Times 2 -Exactly -ParameterFilter { $To -eq 'alice@example.com' }
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/MockHistory.Tests.ps1Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\MockHistory.Tests.ps1'
Describing Order processing
[-] emails alice exactly twice 871ms
Expected Send-Email to be called 2 times exactly, but was called 1 times
Performed invocations:
[*] Send-Email -Subject 'Welcome' -To 'alice@example.com' from C:\code\pester-v6-demo\v6.0\MockHistory.Tests.ps1:7
[ ] Send-Email -Subject 'Receipt' -To 'bob@example.com' from C:\code\pester-v6-demo\v6.0\MockHistory.Tests.ps1:7
at Should -Invoke Send-Email -Times 2 -Exactly -ParameterFilter { $To -eq 'alice@example.com' }, C:\code\pester-v6-demo\v6.0\MockHistory.Tests.ps1:12
Tests completed in 2.04s
Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0If you’ve ever debugged a ParameterFilter by sprinkling Write-Host into a mock, you’ll appreciate this one.
Update (October 2026): notice that both calls say
MockHistory.Tests.ps1:7— that’s the line withMock Send-Email, not the lines whereSend-Emailis actually called (9 and 10). That’s a known bug, reported as #3052.
Under the hood: discovery per file
The biggest change you won’t see in your test code: Pester now discovers and runs one file at a time, instead of discovering everything first and then running everything. Mostly that’s invisible, but if a test file relied on something another file set up during discovery (a variable, an imported module, a function defined outside BeforeAll), that no longer carries over. If your suite only passes when run as a whole and fails when you run a single file, that was already fragile — Pester 6 just makes it visible.
It’s also what enables the next feature.
Experimental: parallel runs
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.Parallel = $true
Invoke-Pester -Configuration $configTest files run in parallel, Run.ParallelThrottleLimit caps how many at once, and a #pester:no-parallel comment at the top of a file opts it out. In 6.0 it’s PowerShell 7 only, and it falls back to sequential runs (with a warning) on Windows PowerShell 5.1 or when code coverage is enabled. It’s marked experimental, so try it on a branch before you flip it on in your main pipeline.
Code coverage changes
Coverage now uses the profiler-based tracer by default (faster than the old breakpoint approach — set CodeCoverage.UseBreakpoints = $true if you need the old numbers), and there’s a new Cobertura output format next to JaCoCo. Cobertura is one of the formats the Azure DevOps Publish Code Coverage Results task reads, so that may save you a conversion step.
The breaking changes
The full list is in the release notes, and the v5 to v6 migration guide has a symptom/fix entry for each. The ones most likely to bite:
Assert-MockCalledandAssert-VerifiableMockare gone — useShould -InvokeandShould -InvokeVerifiable- The legacy v4
Invoke-Pesterparameters are gone (-Script,-OutputFile,-EnableExit, …) — switch toNew-PesterConfiguration. Check your pipelines and build scripts for this one first Pendingand-Focusare removed- Duplicate setup blocks throw — two
BeforeAllblocks in the sameDescribeis now an error (but see the update at the top: 6.2 allows them again) - Hidden folders are searched for tests —
.gitis still skipped, but other dot-folders are not Noneis a reserved tag filter — it now means “tests without tags”
Three behavior changes are easier to show than to describe. First, mocks no longer fall through to the real command when none of the parameter filters match, and <...> in a test name is now evaluated as a full expression:
Describe 'Behavior changes in Pester 6' {
# Pester 5 called the real Get-Date when no parameter filter matched; Pester 6 fails instead.
It 'does not fall through to the real command' {
Mock Get-Date { [datetime]'2000-01-01' } -ParameterFilter { $Year -eq 2000 }
Get-Date -Year 2026
}
# Pester 5 printed the template literally; Pester 6 evaluates the expression.
It 'adds up to <($a + $b)>' -ForEach @(@{ a = 1; b = 2 }) {
$a + $b | Should-Be 3
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/Changes.Tests.ps1Pester v6.0.0
Running tests from 1 files.
Running tests from 'C:\code\pester-v6-demo\v6.0\Changes.Tests.ps1'
Describing Behavior changes in Pester 6
[-] does not fall through to the real command 601ms
RuntimeException: No mock for command 'Get-Date' matched the call: none of the parameter filters matched, and there is no default mock to fall back to. Add a default mock (e.g. `Mock Get-Date { ... }`) or adjust an existing -ParameterFilter.
The following parameter filters were evaluated and did not match:
{ $Year -eq 2000 } bound parameters: Year = 2026
[+] adds up to 3 97ms
Tests completed in 1.78s
Tests Passed: 1, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0The fix for the first one is to add a default Mock without a filter, so every call has somewhere to go.
Second, an empty -ForEach now fails discovery instead of silently running zero tests:
$servers = @()
Describe 'Server <_>' -ForEach $servers {
It 'responds to ping' {
$true | Should-BeTrue
}
}pwsh -NoProfile -File ./run.ps1 -Version 6.0.0 -Path ./v6.0/EmptyForEach.Tests.ps1Pester v6.0.0
Running tests from 1 files.
[-] Discovery in C:\code\pester-v6-demo\v6.0\EmptyForEach.Tests.ps1 failed with:
System.ArgumentException: Value can not be null or empty array. If this is expected, use -AllowNullOrEmptyForEach on this Describe, or set the Run.FailOnNullOrEmptyForEach configuration option to $false to allow it for the whole run. (Parameter 'ForEach')
at Describe, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 15552
at <ScriptBlock>, C:\code\pester-v6-demo\v6.0\EmptyForEach.Tests.ps1: line 4
at <ScriptBlock>, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 3274
at Invoke-File, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 3283
at Invoke-BlockContainer, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 3187
at Invoke-ContainerDiscovery, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 1568
at Invoke-Test, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 2678
at Invoke-Pester<End>, C:\Users\RobertPrüst\AppData\Local\powershell\Modules\Pester\6.0.0\Pester.psm1: line 5347
at <ScriptBlock>, C:\code\pester-v6-demo\run.ps1: line 24
Tests completed in 315ms
Tests Passed: 0, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0
Container failed: 1
- C:\code\pester-v6-demo\v6.0\EmptyForEach.Tests.ps1A data-driven test that quietly runs nothing because the data source came back empty is a green build that lies to you. If empty really is fine, add -AllowNullOrEmptyForEach to that block, or set Run.FailOnNullOrEmptyForEach = $false for the whole run.
Update (October 2026): the error itself is clear, but it comes with a long stack trace through Pester’s own internals — reported as #3057. Also note the summary line:
Tests Passed: 0is shown in green, and the failure only shows up in theContainer failedline below it.
The migration guide also points to two community agent skills in awesome-copilot (pester-migration and pester-should-migration) if you want an AI assistant to do the mechanical part. As always with that kind of thing: review the diff.
Should you move yet?
My rule of thumb: if you’re learning Pester or moving to a new version anyway, go for the latest. The move from 4.x to 5.x was the big syntax change. From 5.x to 6.x should be a lot easier: the classic Should syntax keeps working, so the work is mostly in the breaking changes listed above — v4-style Invoke-Pester calls, Assert-MockCalled, empty -ForEach blocks and mocks that relied on falling through. And with v5 now in maintenance mode, there’s not much reason to wait.
Wrap
There’s a lot in Pester 6, but most of it is additive. Run your suite on it once, fix what the migration guide’s symptom list tells you to fix, and adopt the new assertions at your own pace.
Happy testing 😊