Migrating from Pester v5 to v6
Pester v6 builds on v5 and behaves mostly the same, which keeps it backwards-compatible. Most v5 suites run on v6 without changes. This guide lists the breaking changes and how to fix each one, so you can get your existing suite green on v6.
Your existing Should -Be assertions keep working, so you don't have to rewrite them to upgrade.
Quick upgrade
For most suites upgrading is low risk. Run through this list, then read the details below for anything that bites:
- Make sure you run Windows PowerShell 5.1 or PowerShell 7.4+.
- Check any
-ForEach/-TestCasesthat can be empty — it now throws unless you add-AllowNullOrEmptyForEach. - Remove duplicate
BeforeAll/BeforeEach/AfterAll/AfterEachin the same block. - Replace
Assert-MockCalled/Assert-VerifiableMockwithShould -Invoke/Should -InvokeVerifiable. - Add a default
Mockfor commands where some calls don't match a-ParameterFilter— mocks no longer fall through to the real command. - If you script
Invoke-Pesterwith v4-style parameters (-Script,-OutputFile, ...), switch toNew-PesterConfiguration.
Migrate with an AI assistant
If you use an AI coding agent that supports the Agent Skills spec (such as GitHub Copilot or Claude), two community skills in github/awesome-copilot teach the agent to do these migrations the way this guide describes. The agent loads a skill on demand when you ask it to upgrade a suite, so instead of guessing it follows the documented symptom → fix steps, runs your tests, and fixes them file by file.
These skills teach the agent the documented migration steps, but they don't run or vet your suite for you. Always review the diff and re-run your suite afterward — treat the agent as an assistant, not an unchecked bulk-editor.
| Skill | What it does |
|---|---|
pester-migration | The main upgrade skill. Takes a suite across major versions — v3→v4, v4→v5, and v5→v6 — one major at a time: the Discovery/Run model, moving setup into BeforeAll, the mock changes, Invoke-Pester → New-PesterConfiguration, -ForEach/-TestCases, and every v6 breaking change on this page. |
pester-should-migration | The optional assertion move: rewrites classic Should -Be (v5) to the new Should-* (v6) syntax and handles the behavioral gotchas (truthy vs. true, BeNullOrEmpty, collection semantics). |
Install the skills
Install with the GitHub CLI (v2.90.0 or newer):
# The main migration skill (any version -> v6)
gh skills install github/awesome-copilot pester-migration
# Optional: migrate Should -Be to the new Should-Be syntax
gh skills install github/awesome-copilot pester-should-migration
You can also copy the skill folder from the repo into your agent's skills directory by hand. The full catalog is in the awesome-copilot skills list.
Use them
Once a skill is installed, ask your agent in plain language and it picks up the matching skill automatically:
- "Upgrade this Pester test suite to v6."
- "My
*.Tests.ps1files broke after I bumped Pester — fix them." - "Convert the
Should -Beassertions in./teststo the newShould-*syntax."
The agent records a baseline run, applies the changes, and re-runs until the suite is green — the same loop this guide describes. Review the diff before you commit.
Breaking changes
PowerShell 5.1 and 7.4+ only
Support for PowerShell 3, 4, 6 and early/unsupported 7 was removed — they are all out of support from Microsoft. Dropping them let us move the C# to .NET 8 (net462 for Windows PowerShell 5.1) and modernize the runtime. Pester 6 targets Windows PowerShell 5.1 and PowerShell 7.4+.
Symptom. Pester does not import on an older PowerShell.
Fix. Update your machine and CI agents to a supported version, see Installation.
Discovery and run now happen per file
In v5 a run had two global phases: Pester discovered every file first, building the whole tree of Describe/Context/It, and only then ran them. In v6 the unit of work is a single file — Pester discovers a file and runs it before moving on to the next. This is what makes the experimental parallel runner possible, and serial runs follow the same model so the two behave the same.
Symptom. A test file that relied on something another file set up at discovery time fails — for example a module imported at the top of one file, or -ForEach data that a different file defined. Under parallel each file is discovered in its own runspace, so it definitely won't see the other file's state.
Fix. Make each test file self-contained: do its own discovery-time setup in BeforeDiscovery, and import the modules it needs. When you need shared bootstrap for every file, use Run.BeforeContainer or a Pester.BeforeContainer.ps1 file.
# Each file does its own discovery-time setup instead of
# relying on another file having run first.
BeforeDiscovery {
$cases = Get-Content "$PSScriptRoot/cases.json" | ConvertFrom-Json
}
Describe 'MyModule' {
BeforeAll {
Import-Module "$PSScriptRoot/MyModule.psm1"
}
It 'handles <Name>' -ForEach $cases {
Invoke-Thing $Name | Should -Be 'ok'
}
}
The on-screen output changes too: a run prints one Running tests from N files. banner, then per-file results, then a single grand total. The old Starting discovery in N files. / Discovery found X tests framing is gone for a normal run.
The per-file model also powers the experimental parallel runner (Run.Parallel). It keeps the same result object, with a few differences worth knowing - see The Result Object.
Hidden files and folders are now discovered
In v5, discovery followed the PowerShell default and ignored hidden items, so files and folders whose names start with a dot on macOS/Linux (or carry the Hidden attribute on Windows) — such as tests/.github — were silently skipped by a recursive run. v6 discovers them by default; version-control directories (.git, .svn and .hg) are still skipped. See File placement and naming.
Symptom. Test files that never ran under v5 — typically under a dot-prefixed folder like tests/.github — now run on v6 and can surface failures that were previously hidden.
Fix. Usually none; this is the intended behavior, so fix or remove the newly discovered tests. To keep a path out of a run, exclude it with Run.ExcludePath (or -ExcludePath).
Empty or $null -ForEach throws
Data-driven tests now throw when -ForEach (or -TestCases) gets $null or an empty array, instead of silently skipping the block or test. This catches the common mistake of pointing -ForEach at a variable that wasn't defined in BeforeDiscovery, or external data that didn't load.
Symptom. Discovery fails with:
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')
Fix. When the data really can be empty, allow it on that specific block or test:
Describe 'Optional cases' -ForEach $cases -AllowNullOrEmptyForEach {
It 'runs only when there is data' { }
}
You can also turn the check off for the whole run with Run.FailOnNullOrEmptyForEach = $false, but that is not a good long-term fix — it brings back the silent skipping and hides the same mistakes the check is there to catch. Prefer fixing the data, or using -AllowNullOrEmptyForEach where empty is expected.
Duplicate setup and teardown blocks throw
A block can only have one of each setup and teardown. Two BeforeAll (or BeforeEach/AfterAll/AfterEach) in the same block was silently allowed in v5 and is a common copy-paste bug. v6 throws.
Symptom.
BeforeAll is already defined in this block. Each block can only have one BeforeAll.
Combine the code into a single BeforeAll block.
Fix. Combine them into one block:
Describe 'd' {
BeforeAll {
$a = 1
$b = 2 # was a second BeforeAll
}
}
Test names evaluate <...> templates as expressions
In v6 the content of every <...> token in a Describe/Context/It name is evaluated as a PowerShell expression in the test's run scope. You can reference the current -ForEach/-TestCases item and its properties, any in-scope variable, and full expressions such as arithmetic or method calls. Everything outside <...> (including $, $(...), quotes, and backticks) is escaped and kept literal, so it can't break the name or run code.
In v5 only simple data, variable, and property references inside <...> were substituted; anything more complex was left verbatim. As a result a name that happens to contain expression-like content inside <...> now renders differently.
Symptom. A name with an expression inside <...> that used to render literally is now evaluated.
# v5 renders literally: adds up to <($a + $b)>
# v6 evaluates: adds up to 3
It 'adds up to <($a + $b)>' -ForEach @(@{ a = 1; b = 2 }) { }
Fix (if you want the literal text). Escape the leading < so it isn't treated as a template:
It 'adds up to `<($a + $b)`>' -ForEach @(@{ a = 1; b = 2 }) { }
Assert-MockCalled and Assert-VerifiableMock removed
Both were deprecated back in v5 and are now fully removed.
Symptom.
The term 'Assert-MockCalled' is not recognized as a name of a cmdlet, function, script file, or operable program.
Fix. Use the Should mock assertions, see Mocking:
# v5
Assert-MockCalled Get-Thing -Times 1 -Exactly
Assert-VerifiableMock
# v6
Should -Invoke Get-Thing -Times 1 -Exactly
Should -InvokeVerifiable
Mocks no longer fall through to the real command
In v5, a call to a mocked command that matched none of your -ParameterFilter mocks quietly executed the real command. v6 removes that implicit fall-through, so an unmatched call fails instead of doing something unexpected.
Symptom.
No mock for command 'Get-Thing' 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-Thing { ... }`)
or adjust an existing -ParameterFilter.
Fix. Add a default mock (no -ParameterFilter) for the calls you don't filter on, or widen the filter:
Mock Get-Thing -MockWith { 'default' } # handles everything else
Mock Get-Thing -ParameterFilter { $Name -eq 'a' } -MockWith { 'a' } # special-cases Name 'a'
Set-ItResult -Pending removed
The Pending result was never fully implemented in v5, so it is gone in v6.
Symptom. The test fails during run with:
Parameter set cannot be resolved using the specified named parameters.
Fix. Use -Inconclusive or -Skipped instead, or mark the test with It .. -Skip:
Set-ItResult -Inconclusive -Because 'not implemented yet'
Code coverage uses the Profiler tracer by default
Code coverage used to set a breakpoint on every command. It now uses the same tracer the Profiler uses, which is much faster on large code bases. CodeCoverage.UseBreakpoints is no longer experimental and defaults to $false.
Symptom. Coverage runs differently than in v5 and you want the old breakpoint behavior back.
Fix. Switch back to breakpoints if you need the old numbers:
$config.CodeCoverage.UseBreakpoints = $true
CodeCoverage.OutputFormat = 'CoverageGutters' removed
CoverageGutters only existed to produce repo-root-relative paths. In v6 all coverage output is already relative to the repo root (Run.RepoRoot, found from the .git directory), so plain JaCoCo works with the Coverage Gutters extension and similar tools.
Symptom. Setting OutputFormat to 'CoverageGutters' throws an invalid-value error.
Fix. Use JaCoCo (the default) or Cobertura:
$config.CodeCoverage.OutputFormat = 'JaCoCo' # or 'Cobertura'
Invoke-Pester legacy (v4) parameters removed
The long-deprecated v4-style parameter set was removed from Invoke-Pester. Only the Simple set (-Path, -Output, -Container, -Tag, ...) and the Advanced set (-Configuration) remain.
Symptom. Calls like Invoke-Pester -Script ... -OutputFile ... -OutputFormat ... -EnableExit -CodeCoverage ... fail with a parameter-binding error.
Fix. Use a configuration object:
# v5 (legacy v4 parameters)
Invoke-Pester -Script ./tests -CodeCoverage ./src/*.ps1 -OutputFile result.xml -OutputFormat NUnitXml -EnableExit
# v6
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.Exit = $true
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = 'result.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
Invoke-Pester -Configuration $config
None is now a reserved tag-filter value
None (case-insensitive) is now a special value for -TagFilter / Filter.Tag and -ExcludeTagFilter / Filter.ExcludeTag that means "tests that have no tags" — no tag on the test itself or on any of its parent blocks. Previously None was an ordinary string that only matched a literal tag named None.
Symptom. If you used None as a real tag, -TagFilter "None" now also selects every untagged test, and -ExcludeTagFilter "None" now also skips every untagged test, instead of matching only the tests you tagged None.
Fix. Rename the tag to something other than None if you relied on the old literal match. A test that is still literally tagged None keeps being selected — or, with the exclude filter, skipped — together with the untagged tests, so nothing falls through silently. See Tags.
New in v6 (optional)
None of these are required to upgrade — your v5 suite keeps working without them. They are the main things v6 adds that you may want to adopt once your suite is green.
Run test files in parallel
The per-file model lets v6 run whole test files concurrently, each in its own runspace, which can cut the wall-clock time of a large suite. It is opt-in and still experimental — enable it with Run.Parallel = $true. Files that need shared bootstrap can use Run.BeforeContainer (or a Pester.BeforeContainer.ps1 in the repository root) so each worker starts from a known state. See Parallel execution for the requirements, sequential fallbacks, and the #pester:no-parallel opt-out.
Show which test is currently running
In a long or stuck suite it can be hard to tell which test is executing. Set Debug.ShowStartMarkers = $true to print a marker as each test starts, before its result line is written. See Showing when each test starts.
New Should-* assertions
Pester 6 adds a new family of Should-* assertions — Should-Be, Should-Throw, Should-Invoke and around 40 more — with clearer failure messages. They are not part of upgrading: your existing Should -Be assertions keep working, and there is no need to rewrite them. If you want to try them, see the Should assertions overview and the per-command reference. You can adopt them incrementally, one file at a time — or let the pester-should-migration skill convert a suite for you.
Piping vs. -Actual
The new assertions take the actual value from the pipeline or from -Actual. The pipeline unwraps its input, so a value assertion sees @(1) as 1 and @() as $null, and a collection sent through the pipeline is re-collected as [object[]] — its original type (for example [int[]]) is lost. Use -Actual when you need the exact value or the concrete collection type:
@(1) | Should-Be 1 # pipeline unwraps @(1) to 1
Should-HaveType -Actual ([int[]](1, 2)) -Expected ([int[]]) # -Actual keeps the [int[]]
If a value or type assertion fails because the pipeline unwrapped a collection this way, the failure message adds a hint that explains what happened and points you back to -Actual:
[int[]](1, 2) | Should-HaveType ([int[]])
# 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.
The hint is best-effort. PowerShell can't reliably tell a single-item collection from a scalar, and a collection's original type isn't visible on the right-hand side of the pipeline. Pester recovers the piped value through pipeline tricks that work for common cases but not every one, so the hint won't appear in every situation. When you need the exact value or the concrete collection type, pass it with -Actual rather than relying on the hint.