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, and the reason is specific to what these files are for. They 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.
So I installed 6.1.0 and checked the guidance against it, claim by claim, rather than against the release notes. Four claims no longer held. Two of them do not appear in the release announcement at all.
What 6.1 actually adds
The headline is that the Should-* family is open for extension. In 6.0 it was a closed set, 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:
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.
The option that was removed without an announcement
Run.BeforeContainer is gone in 6.1. It is not in the release notes.
Pester 6.0 shipped two mechanisms for setup shared across test files: the Run.BeforeContainer configuration option, which took scriptblocks, and 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:
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. Because of that, the migration guide now tells you to grep for the name rather than wait for a run to report it.
Run.RepoRoot decides whether the bootstrap fires at all
Once the convention file is the only mechanism, where Pester looks for it matters a great deal more than it used to, and the default 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:
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 silently does not run, and every test that depended on it fails with CommandNotFoundException, naming a command rather than anything resembling the real cause. The guidance now says to be explicit any time the run does not start at the repository root:
$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 quietly 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.
There is a catch worth stating plainly, because the release notes do not. 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:
# 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, and nothing announces it. 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:
Mock Remove-Item { throw 'blocked' } -ParameterFilter { $Path -notlike "$TestDrive*" }
Run that against a call originating inside another module, which is the case Mock.Global exists for rather than a function defined in the test file, 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. That is the reason to turn the option on.
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 rather than running the real command. That is the reason the guard still needs a default mock alongside it:
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 is the part worth naming, because it looks like the feature working. It is not fall-through, it is the mock failing to reach that caller. Nothing restores Pester 5's behaviour here; Global is the only setting in the Mock configuration section.
If those permitted calls need to genuinely run, the default mock has to invoke the original command, forwarding the automatic $PesterBoundParameters hashtable that Pester exposes inside a -MockWith body:
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 the one 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 rather than creating one. 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 advice in the migration guide is to confirm against what is installed rather than trust any document, including mine:
Get-Command -Syntax Should-BeHashtable
The new guide
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 silent trap: do not add a process block. It does not error. 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 fail silently, shipping the raw <Token> text in the message, which is why the guide insists on testing the message itself and not just 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:
#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, and the cost of the floor is close to nothing, 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.
The part worth generalising
Release notes are written to sell a release. They are reliable about what was added and quiet about what was removed, and their examples are illustrative rather 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 is demonstrating.
If you keep instructions that a model reads and obeys, the version bump is not the work. Installing the thing and checking your own claims against it is the work, and it is the only part that catches the guidance that has quietly become wrong.
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.