Contents

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.

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.

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 -Force
PackageManagement\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.InstallPackage

So on 5.1, add -SkipPublisherCheck, as the install docs tell you to:

Install-Module Pester -RequiredVersion 6.0.0 -Scope CurrentUser -Force -SkipPublisherCheck

On 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 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.ps1
Pester 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: 0

Two things worth knowing about the new family:

  • They’re type-specific. Should-BeString has string options (-CaseSensitive, -TrimWhitespace), collection assertions are separate from value assertions, and Should-Be refuses a collection on -Expected instead 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.ps1
Pester 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: 0

What 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 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.ps1
Pester 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: 0

Three 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.

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 -SoftAssert
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 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: 0

Handy when you’re checking the shape of an object and want to fix everything in one go instead of one property per run.

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.ps1
Pester 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: 0

If 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 with Mock Send-Email, not the lines where Send-Email is actually called (9 and 10). That’s a known bug, reported as #3052.

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.

$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.Parallel = $true
Invoke-Pester -Configuration $config

Test 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.

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 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-MockCalled and Assert-VerifiableMock are gone — use Should -Invoke and Should -InvokeVerifiable
  • The legacy v4 Invoke-Pester parameters are gone (-Script, -OutputFile, -EnableExit, …) — switch to New-PesterConfiguration. Check your pipelines and build scripts for this one first
  • Pending and -Focus are removed
  • Duplicate setup blocks throw — two BeforeAll blocks in the same Describe is now an error (but see the update at the top: 6.2 allows them again)
  • Hidden folders are searched for tests — .git is still skipped, but other dot-folders are not
  • None is 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.ps1
Pester 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: 0

The 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.ps1
Pester 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.ps1

A 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: 0 is shown in green, and the failure only shows up in the Container failed line 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.

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.

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 😊