* Add pester-migration skill A self-contained, experimental skill that helps upgrade PowerShell Pester test suites across major versions (v3->v4, v4->v5, v5->v6). One router SKILL.md plus per-jump references. The v5->v6 guidance tracks Pester 6, which is still a release candidate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate skills index for pester-migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on pester-migration skill - Pin the stable-v5 install to -MaximumVersion 5.99.99 so it keeps installing v5 after Pester 6 goes GA (SKILL.md). - Make the baseline run command version-agnostic (bare Invoke-Pester) and note that parameters differ across majors (SKILL.md). - Replace the '->' mapping arrows inside powershell fences with comment + valid replacement lines so snippets are copy/paste-safe (SKILL.md v5->v6 cheat sheet, v5-to-v6.md, and v3-to-v4.md). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: scope -SkipPublisherCheck, trim status callout, order references - Drop -SkipPublisherCheck from the default install commands; scope it to Windows PowerShell 5.1 (installing over the OS's Microsoft-signed built-in Pester 3) per pester.dev install docs, instead of an unconditional default. - Trim the experimental status callout: keep the preview marker, drop the date-based, human-oriented wording. - Reorder the References table into version progression order (v3->v4, v4->v5, v5->v6). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: nohwnd <jakub@jares.cz> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Aaron Powell <me@aaron-powell.com>
11 KiB
Migrating Pester v4 → v5
This is the hard jump. v5 introduced a new runtime that splits a test run into two phases — Discovery and Run — and that changes how you must structure tests. It is not a pure find-and-replace. Read this whole file before editing a suite.
Official guide: https://pester.dev/docs/migrations/v4-to-v5 · Breaking changes: https://pester.dev/docs/migrations/breaking-changes-in-v5
The one concept that explains everything: Discovery and Run
A v5+ run happens in two passes:
- Discovery — Pester executes each
*.Tests.ps1file top to bottom but only to find tests. It invokes theDescribe/Contextscript blocks to collect the tree ofIts, evaluatesIt-Namestrings and-TestCases/-ForEachdata, and recordsBeforeAll/It/etc. script blocks without running them. - Run — Pester then executes the recorded setups, tests, and teardowns with correct scoping.
The two rules that make a suite v5-correct:
- Put all test code inside
It,BeforeAll,BeforeEach,AfterAll, orAfterEach. - Put no test code directly in
Describe/Contextbodies or at the top of the file — unless it is meant to build tests, in which case it goes inBeforeDiscovery.
Code that sits loose in a Describe body or at file top-level runs during Discovery, and its
results are usually not available during Run. This is the root cause of most "it worked in
v4, it's $null in v5" bugs.
Fix 1 — Move file setup into BeforeAll and use $PSScriptRoot
The classic v4 header dot-sources the system-under-test at file scope using
$MyInvocation.MyCommand.Path. Both the placement and that variable break in v5.
# BEFORE (v4)
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace('.Tests.', '.')
. "$here\$sut"
Describe 'Get-Cactus' {
It 'Returns 🌵' { Get-Cactus | Should -Be '🌵' }
}
# AFTER (v5+)
BeforeAll {
# Do NOT use $MyInvocation.MyCommand.Path here.
. $PSScriptRoot/Get-Cactus.ps1
# or, by convention from the test file name:
# . $PSCommandPath.Replace('.Tests.ps1', '.ps1')
}
Describe 'Get-Cactus' {
It 'Returns 🌵' { Get-Cactus | Should -Be '🌵' }
}
Why $MyInvocation.MyCommand.Path fails: it returns the path only when evaluated directly in the
script body. Inside any function or script block (and BeforeAll is a script block) Path is
empty. Use $PSScriptRoot (the test file's directory) or $PSCommandPath (the test file's full
path) instead. string.Replace('.Tests.ps1','.ps1') is case-sensitive — keep the .Tests.ps1
casing exact.
$MyInvocation.MyCommand.Pathis fine inside your module/product code — the change only affects the test-file header pattern. See https://pester.dev/docs/usage/importing-tested-functions#migrating-from-pester-v4.
There is a community migration script that does the BeforeAll wrap for you (review its output): https://gist.github.com/nohwnd/d488bd14ab4572f92ae77e208f476ada
Fix 2 — Generate tests with BeforeDiscovery + -ForEach, not loose foreach
A very common v4 pattern builds tests from data with a top-level foreach. In v5 the data often
isn't defined at Discovery time, so no tests get generated, or the per-item variable is missing
inside It.
# BROKEN in v5: $files is set in BeforeAll (Run phase), but the foreach runs in Discovery
BeforeAll { $files = Get-ChildItem *.ps1 }
foreach ($file in $files) {
Describe "$file is correct" {
It 'has empty line at end' { }
}
}
Two things must change: build the data in BeforeDiscovery (so it exists during Discovery), and
pass per-item data into the test with -ForEach/-TestCases (so It can see it during Run):
BeforeDiscovery {
$files = Get-ChildItem *.ps1 # runs during Discovery
}
Describe 'script <_> is correct' -ForEach $files {
It 'has an empty line at the end' {
# $_ is the current file here
}
}
Prefer -ForEach on the block/It over a hand-written foreach; it both creates the copies and
makes the current item available. Use <_> (or <Name> for hashtable items) in the name to
template per-item titles. Reference: https://pester.dev/docs/usage/data-driven-tests.
Fix 3 — -Skip and -TestCases are evaluated during Discovery
Because filters and data are resolved during Discovery, conditions computed in BeforeAll are not
available yet.
# DOES NOT skip: $isSkipped is set in BeforeAll (Run), but -Skip is read in Discovery
Describe 'd' {
BeforeAll { $isSkipped = Get-IsSkipped }
It 'i' -Skip:$isSkipped { }
}
Move cheap skip logic to file scope (it runs on every Discovery) or, better, base it on a static
global like $IsWindows:
$isSkipped = -not $IsWindows
Describe 'd' {
It 'i' -Skip:$isSkipped { }
}
Keep Discovery-time code cheap — it runs every time the file is discovered, which can be often.
Fix 4 — Variables don't leak from Discovery into the test
Variables defined during Discovery are not visible in BeforeAll/-Each, AfterAll/-Each, or
It. If you compute something while generating tests and need it at run time, attach it to the
test via -ForEach/-TestCases. (TestDrive is Run-only and likewise can't be used in
-ForEach.)
Fix 5 — Should -Throw matches with -like wildcards
In v5, Should -Throw <message> matches the exception message with -like, not .Contains(). A
substring that used to match now needs wildcards.
# v4: matched a substring
{ throw 'connection failed: timeout' } | Should -Throw 'timeout'
# v5+: use wildcards to match part of the message
{ throw 'connection failed: timeout' } | Should -Throw '*timeout*'
Fix 6 — Mocks: scoping, debugging, and InModuleScope
- Scope follows placement. In v5, mocks (and their call counts) are scoped to where you put
them — the current block/test — not the entire
Describe/Context. Define theMockin the sameBeforeAll/Itscope where it applies, and assert counts in that scope. Assert-VerifiableMockswas removed. UseShould -InvokeVerifiable. (Assert-MockCalledandAssert-VerifiableMockstill exist but are deprecated in v5 and removed in v6 — preferShould -Invoke/Should -InvokeVerifiablenow to save a second migration. See v5-to-v6.md.)- Mocks are debuggable. v5 no longer rewrites your mock script block, so you can set
breakpoints inside
-MockWithand inside-ParameterFilter. - Avoid
InModuleScopearoundDescribe/It. It loads the module during Discovery (slowing it down) and lets you test internals instead of the published surface. Prefer-ModuleNameonMockand onShould -Invoke; if you must useInModuleScope, keep it insideIt. See https://pester.dev/docs/usage/mocking.
# Prefer this over wrapping the whole Describe in InModuleScope
Mock Get-Internal -ModuleName MyModule { 'mocked' }
Should -Invoke Get-Internal -ModuleName MyModule -Times 1 -Exactly
Fix 7 — Invoke-Pester parameters → New-PesterConfiguration
Invoke-Pester's interface was overhauled. v5 kept a deprecated compatibility set so v4 calls
mostly still run (with a warning), but you should move to either the Simple parameters or the
Advanced -Configuration object. (v6 removes the legacy set entirely — migrate now.)
Simple interface (parameter → config property):
| Simple parameter | Configuration property |
|---|---|
-Path |
Run.Path |
-ExcludePath |
Run.ExcludePath |
-Tag |
Filter.Tag |
-ExcludeTag |
Filter.ExcludeTag |
-FullNameFilter |
Filter.FullName |
-Output |
Output.Verbosity |
-CI |
TestResult.Enabled + Run.Exit (both $true) |
-PassThru |
Run.PassThru |
Legacy (v4) parameters → config:
| v4 parameter | Configuration property |
|---|---|
-Script |
Run.Path (paths only — no hashtables) |
-EnableExit |
Run.Exit |
-TestName |
replaced by -FullNameFilter / Filter.FullName |
-CodeCoverage |
CodeCoverage.Path (+ CodeCoverage.Enabled = $true) |
-CodeCoverageOutputFile |
CodeCoverage.OutputPath |
-CodeCoverageOutputFileEncoding |
CodeCoverage.OutputEncoding |
-CodeCoverageOutputFileFormat |
CodeCoverage.OutputFormat |
-OutputFile |
TestResult.OutputPath (+ TestResult.Enabled = $true) |
-OutputFormat |
TestResult.OutputFormat |
-Show / -Output |
Output.Verbosity (see mapping below) |
-PesterOption, -Strict |
ignored / not available |
-Show value → Output.Verbosity: All/Default/Detailed → Detailed; Fails/Normal →
Normal; Diagnostic → Diagnostic; Minimal → Minimal; None → None.
# BEFORE (v4 legacy)
Invoke-Pester -Script ./tests -CodeCoverage ./src/*.ps1 `
-OutputFile result.xml -OutputFormat NUnitXml -EnableExit
# AFTER (v5 Advanced)
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.Exit = $true
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = './src'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = 'result.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
Invoke-Pester -Configuration $config
-Output Diagnostic is your best friend while migrating — it shows Discovery/Skip/Mock decisions.
The new result object
The v5 result object is much richer and is what Pester uses internally. To keep a v4-era CI
pipeline working, convert it with ConvertTo-Pester4Result. For NUnit output use
ConvertTo-NUnitReport, or pass -CI to enable NUnit output, code coverage, and a failing exit
code in one switch. Each test's -TestCases/-ForEach item is available on the test object's
Data property.
Other removed / changed things in v5
- PowerShell 2 is no longer supported.
- Legacy
Should Be(no dash) is removed — convert toShould -Be(v3-to-v4.md). - Gherkin was removed — stay on Pester v4 if you need it.
-Output/-Showreduced toNone,Normal,Detailed,Diagnostic.-TestName→-FullNameFilter;-Script→-Path(paths only);-PesterOptionremoved.
v4 → v5 checklist
- Suite runs green on v4 first (baseline).
- File import moved into
BeforeAll;$MyInvocation.MyCommand.Pathreplaced with$PSScriptRoot/$PSCommandPath. - No loose code in
Describe/Contextbodies or at file top-level; test-generating code moved toBeforeDiscovery. foreach-generated tests converted to-ForEach; per-item data passed via-ForEach/-TestCases.-Skip:conditions don't depend onBeforeAllvariables.Should -Throwmessages use-likewildcards (*...*).- Mocks defined in the right scope;
Assert-VerifiableMocks→Should -InvokeVerifiable;InModuleScoperemoved from aroundDescribe/Itin favor of-ModuleName. Invoke-Pestercall converted to Simple params orNew-PesterConfiguration.- Suite green on v5 with
-Output Detailed; diff reviewed; committed.