Files
awesome-copilot/skills/pester-migration/references/v4-to-v5.md
T
Jakub Jareš fb0fae173f Add pester-migration skill (experimental) (#2163)
* 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>
2026-07-02 10:29:37 +10:00

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.ps1 file top to bottom but only to find tests. It invokes the Describe/Context script blocks to collect the tree of Its, evaluates It -Name strings and -TestCases/-ForEach data, and records BeforeAll/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:

  1. Put all test code inside It, BeforeAll, BeforeEach, AfterAll, or AfterEach.
  2. Put no test code directly in Describe/Context bodies or at the top of the file — unless it is meant to build tests, in which case it goes in BeforeDiscovery.

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.Path is 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 the Mock in the same BeforeAll/It scope where it applies, and assert counts in that scope.
  • Assert-VerifiableMocks was removed. Use Should -InvokeVerifiable. (Assert-MockCalled and Assert-VerifiableMock still exist but are deprecated in v5 and removed in v6 — prefer Should -Invoke / Should -InvokeVerifiable now 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 -MockWith and inside -ParameterFilter.
  • Avoid InModuleScope around Describe/It. It loads the module during Discovery (slowing it down) and lets you test internals instead of the published surface. Prefer -ModuleName on Mock and on Should -Invoke; if you must use InModuleScope, keep it inside It. 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/DetailedDetailed; Fails/NormalNormal; DiagnosticDiagnostic; MinimalMinimal; NoneNone.

# 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 to Should -Be (v3-to-v4.md).
  • Gherkin was removed — stay on Pester v4 if you need it.
  • -Output/-Show reduced to None, Normal, Detailed, Diagnostic.
  • -TestName-FullNameFilter; -Script-Path (paths only); -PesterOption removed.

v4 → v5 checklist

  • Suite runs green on v4 first (baseline).
  • File import moved into BeforeAll; $MyInvocation.MyCommand.Path replaced with $PSScriptRoot/$PSCommandPath.
  • No loose code in Describe/Context bodies or at file top-level; test-generating code moved to BeforeDiscovery.
  • foreach-generated tests converted to -ForEach; per-item data passed via -ForEach/-TestCases.
  • -Skip: conditions don't depend on BeforeAll variables.
  • Should -Throw messages use -like wildcards (*...*).
  • Mocks defined in the right scope; Assert-VerifiableMocksShould -InvokeVerifiable; InModuleScope removed from around Describe/It in favor of -ModuleName.
  • Invoke-Pester call converted to Simple params or New-PesterConfiguration.
  • Suite green on v5 with -Output Detailed; diff reviewed; committed.