I maintain a repository of PowerShell Copilot instructions, and the testing section is the largest thing in it: one core instruction file plus thirteen supporting guides covering assertions, mocking, configuration, CI integration, and templates. Until this week every one of them said Pester 6.0+.

Pester 6.1.0 shipped on 11 August 2026. The cheap way to absorb that is a find and replace. 6.0.0 becomes 6.1.0, the README gets a new number, done in five minutes. I did not do that. These files are not documentation a human skims and applies judgement to. They are instructions an LLM follows literally. A stale claim in a README is mildly annoying. A stale claim in pester.instructions.md becomes generated code that throws on first run, and the person who gets to work out why is whoever asked Copilot for a test file.

I installed 6.1.0 and checked the guidance against it, claim by claim. Four claims no longer held. Two of them do not appear in the release announcement at all.

New-ShouldAssertion opens the Should family

In 6.0 the Should-* set was closed, and adding to it meant dropping back to the v5 Add-ShouldOperator mechanism, a different shape that caps out at 32 operators per runspace. Now you declare an ordinary function, call New-ShouldAssertion inside it, and the result behaves like a built-in:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function Assert-BeValidSemVer {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, ValueFromPipeline)] $Actual,
        [string] $Because
    )

    $assert = New-ShouldAssertion -Caller $PSCmdlet -Actual $Actual -Buffer $Input
    $Actual = $assert.Actual()

    $parsed = $null
    if (-not [version]::TryParse($Actual, [ref] $parsed) -or $parsed.Build -lt 0) {
        $assert.Fail(
            'Expected a Major.Minor.Patch version,<because> but got <actual>.',
            @{ Because = $Because })
    }
}

Set-Alias -Name Should-BeValidSemVer -Value Assert-BeValidSemVer

You get pipeline input collection, Pester’s value formatting, the diagnostic hint that fires when someone pipes a collection into a value assertion, soft assertions, and use inside a mock -ParameterFilter, all for free, because Fail() goes through the same path a built-in uses.

Alongside that, 6.1 adds two experimental options (Mock.Global and Run.Shuffle with Run.ShuffleSeed), two output options (Output.ShowTags and Output.CIDebugOutput), code coverage under the parallel runner, and a consistency pass across the new assertions. I documented the experimental pair without a recommendation either way, since both may still change before they are declared stable.

Run.BeforeContainer is gone from 6.1

The release notes do not mention it.

Pester 6.0 let you share setup across test files through the Run.BeforeContainer configuration option, which took scriptblocks, or through a Pester.BeforeContainer.ps1 convention file at the repository root. The option had no file to anchor relative paths against, so it was dropped and the convention file kept. My guidance had described the option as the primary mechanism and the file as the fallback, which is now exactly backwards.

Assigning it throws:

1
The property 'BeforeContainer' cannot be found on this object

That is the good case. The bad case is a hashtable-driven configuration, which is how a lot of CI jobs are wired. New-PesterConfiguration -Hashtable ignores unknown keys, so a Run.BeforeContainer entry in a PesterConfiguration.psd1 does not throw. The bootstrap simply stops running, and nothing in the output says so. The migration guide now tells you to grep for the name.

Run.RepoRoot decides whether the bootstrap fires at all

With the option gone, the convention file carries every shared setup in a suite, and the default search path for it is easy to get wrong.

Run.RepoRoot defaults to the nearest ancestor directory containing .git, searched upward from [System.IO.Directory]::GetCurrentDirectory(), the .NET process working directory. It resolves once, when New-PesterConfiguration is called. That is not PowerShell’s $PWD, and Set-Location does not update it:

1
2
Set-Location $repo
(New-PesterConfiguration).Run.RepoRoot.Value   # still the directory the process started in

It is not derived from Run.Path either, so pointing Pester at a test directory in another repository does not move it. When the two diverge the bootstrap does not run, and every test that depended on it fails with CommandNotFoundException naming a command several steps from the cause. The guidance now says to be explicit any time the run does not start at the repository root:

1
$config.Run.RepoRoot = $PSScriptRoot

Coverage under parallel: three guides were wrong

In 6.0, enabling code coverage forced a sequential run. Three of my guides said so, and the runner script had a branch that warned and dropped -Parallel when coverage was on.

6.1 collects coverage across parallel workers. Each worker measures the same locations, and the parent merges the per-location hits, including the coverage of any #pester:no-parallel files it ran in-session, into a single report.

The release notes leave out the catch. A parallel run is forced onto breakpoint-based coverage, since the profiler tracer keeps its state in a process-global static and is not concurrency-safe. CodeCoverage.UseBreakpoints = $false is ignored on that path, and breakpoint coverage is substantially slower per file. Parallel plus coverage can easily be slower overall than sequential plus the profiler.

So the two-job split is still what I recommend, a fast parallel job without coverage for feedback and a sequential job with the profiler for the gate, but it went from a hard requirement to a performance choice you can measure. The runner branch changed from refusing to warning:

1
2
3
4
5
# Pester 6.1 collects coverage under parallel, but forces breakpoint mode
# to do it - which is far slower per file than the profiler tracer.
if ($CodeCoverage) {
    Write-Warning "Coverage under -Parallel uses breakpoints, not the profiler. Expect it to be slower than a sequential coverage run."
}

The release notes example that fails in both directions

Mock.Global is the more interesting of the two experimental options. Normally a mock applies to calls from the scope that declared it, or from the single module named by -ModuleName. Anything else reaches the real command. Turn on Mock.Global and a mock reaches the command from any module or script in the runspace, with -ModuleName demoted to a resolution hint.

The release notes suggest combining it with -ParameterFilter so that unmatched calls fall through to the real command, and show this:

1
Mock Remove-Item { throw 'blocked' } -ParameterFilter { $Path -notlike "$TestDrive*" }

Run that against a call originating inside another module, the case Mock.Global exists for, and it fails whichever way the option is set. It just fails differently each way.

With the option off, the mock never reaches the module, so the guard does not fire. A mock written to block a destructive command lets it run for real, and the test still passes. Turning the option on fixes that.

With the option on the guard fires correctly, but the calls it permits stop reaching Remove-Item. Pester 6 removed mock fall-through and Mock.Global does not reinstate it, so a call matching no filter raises an unmatched-mock error. The guard therefore needs a default mock alongside it:

1
2
3
4
5
Mock Remove-Item { throw "blocked: Remove-Item outside TestDrive ($Path)" } `
    -ParameterFilter { $Path -notlike "$TestDrive*" }

# Required: the calls the guard permits still need a mock to land on
Mock Remove-Item { }

The apparent fall-through in the first case reads as the feature working. It is the mock failing to reach that caller. Nothing restores Pester 5’s fall-through behavior; Global is the only setting in the Mock configuration section.

If those permitted calls have to run for real, the default mock has to invoke the original command, forwarding the automatic $PesterBoundParameters hashtable that Pester exposes inside a -MockWith body:

1
2
3
4
5
BeforeAll {
    $originalRemoveItem = Get-Command Remove-Item -CommandType Cmdlet
}

Mock Remove-Item { & $originalRemoveItem @PesterBoundParameters }

Capture the command before the mock is defined, or Get-Command resolves to the mock. And $PSBoundParameters is not the equivalent inside a mock body; it does not carry the caller’s arguments.

Positional changes that can break a 6.0 suite

6.1 made -Actual bind consistently: Position = 0 on a single-subject assertion, Position = 1 when a positional -Expected already occupies position 0. Named arguments and piped input are unaffected, so only positional -Actual calls can break. Should-BeHashtable is most likely to bite, since its -Actual moved from 1 to 0.

-Expected also became mandatory on Should-NotBeString, Should-BeFasterThan, and Should-BeSlowerThan. A call omitting it was not asserting anything meaningful, so that surfaces a latent bug. Should-Throw -Because is now named-only. Should-BeEquivalent -StrictOrder was removed, on the grounds that it never worked; if a call passes it, the switch was not doing what its name implied.

The migration guide says to confirm against what is installed, including against this post:

1
Get-Command -Syntax Should-BeHashtable

Writing your own assertion

New-ShouldAssertion got its own file, mostly because the interesting parts are not in the announcement. The -As collection modes, the reserved and custom message tokens, and one trap: do not add a process block. It changes the assertion’s semantics so the body runs once per piped item instead of once for the pipeline, and the diagnostic hint starts describing a collection the assertion never saw.

Custom tokens are also case-sensitive, and a mismatched one ships the raw <Token> text in the failure message, so the guide insists on testing the message itself alongside the pass and fail paths. The bar for writing one of these at all is deliberately high: if a custom assertion does not produce a better failure message than Should-BeTrue plus -Because, it is not worth the indirection.

Why the floor moved to 6.1

Nothing in 6.1 breaks a 6.0 test file. The assertion and mocking APIs a test file uses did not lose anything, and the breakages are all in configuration and a few positional parameters. I could have left the floor at 6.0+ and marked the new features as conditional.

I raised it instead:

1
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' }

Conditional guidance is precisely where generated code goes wrong. An instruction that says “use this if available” produces code that uses it, without the check. A hard floor makes the generated code correct by construction. It costs almost nothing here, which is the line I put in the core instruction file: 6.1 is additive over 6.0, and no test file needs to change to move between them.

Release notes are marketing

They are reliable about what was added, quiet about what was removed, and their examples are illustrative more often than executed. Two of the four corrections here came from things the announcement never mentioned, and a third came from an example in it that fails whichever way you set the option it demonstrates.

If you keep instructions that a model reads and obeys, the version bump takes five minutes and catches none of that. Installing the release and checking your own claims against it is the pass that turned up all four.

The changes described here live in the PowerShell Copilot Standards repository, principally in pester.instructions.md, the new custom assertion guide, and the 6.0 to 6.1 section of the migration guide.