When I wrote the OrphanedSid tests in July 2025, I wrote them for Pester 3.4. That’s the version Windows still ships in C:\Program Files\WindowsPowerShell\Modules, and my Pester post from that summer made a case for sticking with it because so many enterprise machines have nothing newer.

That case doesn’t hold up for a module on the PowerShell Gallery. My other published modules all run Pester 6 in CI, and the quality gates I copy between repos expect it:

1
2
3
Import-Module Pester -MinimumVersion 6.1.0 -ErrorAction Stop
$cfg = New-PesterConfiguration
$cfg.Run.Path = './Tests'

It’s bad enough so many AD things require 5.1 compatibility. I hated 3.4 but had challenged myself to write within the limitations. The second I could ditch it I did.

The suite going in was 94 test files. Under Pester 3.4 on Windows PowerShell 5.1, it passed 1,938 unit tests out of 1,940, plus 216 integration and 170 performance tests. The count after the move is 2,414, which is where the 2,400 in the title comes from. The previous post covers the rest of what changed that day.

Assertions were the easy part

Pester 5 dropped the old bare-word assertion syntax. Should Be became Should -Be, Should Throw became Should -Throw, and Assert-MockCalled became Should -Invoke. Across the suite that was 4,965 lines of Pester 3 assertions and 218 Assert-MockCalled calls. It’s tedious, but it’s mechanical. If you’re moving a 3.4 suite of your own, a careful search and replace handles nearly all of it.

What a search and replace can’t handle is where the code sits.

Top-level setup runs too early

Pester 3 ran a test file top to bottom, like a script. Anything before the first Describe just executed, so files set themselves up there. This is the top of Get-ADObjectsSequential.Tests.ps1 as it was:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.'

# Import the function under test
$functionPath = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $here)))) "Private\ActiveDirectory\Get-ADObjectsSequential.ps1"
if (Test-Path $functionPath) {
    . $functionPath
}

# Set up global test data at file level (outside Describe block)
$Global:TestConfig = @{

Pester 5 and later split a run into two phases. Discovery walks the file to find the Describe and It blocks, and the run phase executes them. Code at the top level, or loose inside a Describe, runs during discovery. If you set something up there, it’s gone by the time your tests run. Setup has to live in BeforeAll.

I wrote a small file to watch it happen on Pester 6.2.0:

1
2
3
4
Describe 'top level' {
    $topLevel = 'set at file level'
    It 'sees discovery-time variable' { $topLevel | Should -Be 'set at file level' }
}
1
2
[-] sees discovery-time variable 122ms
 Expected 'set at file level', but got $null.

No error, no warning. The variable is just gone. With 94 files setting themselves up that way, I didn’t want to move every block by hand.

Rewriting with the parser

The move was done by Convert-PesterTests.ps1, a 166-line converter that read each file with PowerShell’s own parser and edited it by position in the syntax tree. Working from the tree means a multi-line hashtable or a nested if moves as one piece, where a regex would’ve torn it apart.

It sorted every top-level statement by its command name. Pester’s block commands stay where they are, and everything else is setup:

1
$blockCommands = 'Describe', 'Context', 'It', 'BeforeAll', 'BeforeEach', 'AfterAll', 'AfterEach', 'BeforeDiscovery'
1
2
3
$top = @($topStatements | Where-Object { (Get-CommandName -Statement $_) -notin $blockCommands })
$dropped = @($top | Where-Object { $_.Extent.Text -match '^\s*Import-Module\s+Pester\b' })
$moved = @($top | Where-Object { $_ -notin $dropped })

Import-Module Pester -RequiredVersion 3.4.0 lines get dropped outright, since they’d pin the old version back in. The rest are cut from where they were and pasted, in order, into one BeforeAll just above the first Describe. Inside each Describe and Context, Visit-Body does the same thing at that level, wrapping each run of loose statements in a BeforeAll of its own, in place.

Every cut and paste is a start and end offset into the original text. Applying them back to front keeps the offsets in front of each edit valid:

1
2
3
4
$sb = [System.Text.StringBuilder]::new($text)
foreach ($e in ($edits | Sort-Object { $_.Start } -Descending)) {
    $null = $sb.Remove($e.Start, $e.End - $e.Start).Insert($e.Start, $e.Text)
}

The assertion rewrites come after the structural ones, as plain regex replacements. One of them changes meaning. Pester 3’s Should Throw "text" matched any message containing the text, and Pester 5 and later compare with -like, so a literal with no wildcards became "*text*". Without that, every Should -Throw with a partial message would’ve started failing for a reason that has nothing to do with the code under test.

When the file is written, the converter parses its own output again and warns on any error. The same file after conversion:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Import the function under test
# Set up global test data at file level (outside Describe block)
# Initialize call tracking arrays
# Create mock functions at file level
# Create script scope mock function as backup
# Mock logging functions with call tracking
# Additional mock functions for completeness
BeforeAll {
    $here = $PSScriptRoot

    $sut = (Split-Path -Leaf $PSCommandPath) -replace '\.Tests\.', '.'

    $functionPath = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $here)))) "Private\ActiveDirectory\Get-ADObjectsSequential.ps1"

The code moved and the comments didn’t. A comment isn’t part of any statement’s extent, so when the converter cut each statement out, the comments between them stayed put and the BeforeAll landed underneath. Seven comments in a row, none next to the code they describe. They’re harmless, and they’re still there.

The header two lines above them also still says Pester 3.4.x Compatible. I’ll go back and clean the header up when I push 1.1.0 to address issue #9 later this week.

Where am I

The first two lines of that block didn’t come from the converter. It never touches $MyInvocation, and most of the suite didn’t need it to: 82 of the 94 files already found themselves with $PSScriptRoot. The other 12, this one included, used the older form:

1
$here = Split-Path -Parent $MyInvocation.MyCommand.Path

That works at the top of a script, where $MyInvocation describes the script. Inside a BeforeAll it describes the scriptblock Pester invoked, and there’s no path:

1
2
3
4
BeforeAll {
    $script:fromMyInvocation = $MyInvocation.MyCommand.Path
    $script:fromRoot = $PSScriptRoot
}
1
MyInvocation=[] PSScriptRoot=[C:\Users\fadwe\AppData\Local\Temp\pestermyinv]

With $here empty, that four-level Split-Path chain resolves to nothing, Test-Path returns false, and the if skips the import. The tests then fail later with a command they can’t find, which is a lot harder to trace back to line one. Those 12 files got $PSScriptRoot and $PSCommandPath by hand in the cleanup pass after the conversion.

Mocks that stay in their test

Nine mocks in the old suite used -Scope It. Pester 6 doesn’t have the parameter:

1
2
[-] old syntax 625ms
 ParameterBindingException: A parameter cannot be found that matches parameter name 'Scope'.

Deleting the parameter fixes the syntax, because a mock defined in an It is already scoped to that It in Pester 5 and later. The behavior change took more work. Pester 3.4 let a mock made in one It carry into the next, and some tests had come to depend on that. In Restore-ACLOperation.Tests.ps1, the default success path only passed because an earlier test had mocked Set-Acl. The fix is an explicit mock, with a comment so nobody removes it:

1
2
3
4
# Set-ObjectACL writes to the AD: drive, which does not exist on a test machine. Pester 3
# leaked the per-test Set-Acl mocks to the whole Describe; Pester 6 scopes them to their
# It, so the default success path needs its own mock here.
Mock Set-Acl { }

Stubs need real parameter names

OrphanedSid calls Get-ADObject, Get-ADDomain and friends from the RSAT ActiveDirectory module. CI runners don’t have RSAT, and Pester can only mock a command that exists. The fix is a test helper, ADCmdletStubs.ps1, that defines a placeholder for each cmdlet when the real one is missing.

The first version came in that morning, and every stub got the same catch-all body:

1
2
3
4
5
6
$body = {
    param([Parameter(ValueFromRemainingArguments)]$Arguments)
    $name = $MyInvocation.MyCommand.Name
    $count = @($Arguments).Count
    throw "$name stub: the ActiveDirectory module is not installed and this call ($count argument(s)) was not mocked"
}

It looks reasonable. Your test mocks Get-ADObject anyway, so why would the stub’s parameters matter?

Pester builds the mock from the command’s parameter metadata. Inside a mock body or a -ParameterFilter, each bound parameter shows up as a variable. If the only parameter is $Arguments, then -SearchBase and -Filter bind into it, and $SearchBase is empty. I tested both on Pester 6.2.0 with a catch-all stub:

1
2
3
4
It 'sees -SearchBase' {
    Mock Get-ADObject { "base=[$SearchBase] args=[$($Arguments -join ' ')]" }
    Get-ADObject -SearchBase 'OU=Users,DC=contoso,DC=com' -Filter '*' | Should -Be 'base=[OU=Users,DC=contoso,DC=com] args=[]'
}
1
2
Expected: 'base=[OU=Users,DC=contoso,DC=com] args=[]'
But was:  'base=[] args=[-SearchBase OU=Users,DC=contoso,DC=com -Filter *]'

A parameter filter has the same problem, and Pester 6 is nice enough to show what it bound:

1
2
3
RuntimeException: No mock for command 'Get-ADObject' matched the call: none of the parameter filters matched, and there is no default mock to fall back to.
The following parameter filters were evaluated and did not match:
    { $SearchBase -eq 'OU=Users,DC=contoso,DC=com' }  bound parameters: Arguments = -SearchBase OU=Users,DC=contoso,DC=com -Filter *

I expected Pester 3.4 to be more forgiving here. It isn’t. The same two tests on 3.4.0 under Windows PowerShell 5.1:

1
2
3
4
5
6
[-] sees -SearchBase 1.14s
  Expected: {base=[OU=Users,DC=contoso,DC=com]}
  But was:  {base=[]}
[-] filters on -SearchBase 221ms
  Expected: {matched}
  But was:  {base=[]}

That second failure is the leak from the last section. The filtered mock never matched, and 3.4 fell back to the mock from the previous It.

RSAT wasn’t installed on the machine that ran the 3.4 suite, so those catch-all stubs were in use when it passed 1,938 unit tests that morning. My guess at how it got away with it is that second failure: on 3.4, a parameter filter that never matches falls back to whatever default mock is in reach, including one left over from an earlier test. I haven’t gone back to find which tests were leaning on that.

The stubs in the migration commit declare the parameters the module and tests use, spliced into each function’s text:

1
2
3
4
5
6
7
# Parameter lists are the subset of each cmdlet's parameters that the module or a test passes.
# Add to a list when a new call site uses a parameter that is not here.
$adCmdletStubs = @{
    'Get-ADDomain'           = '$Identity, $Current, $Server, $Credential'
    'Get-ADObject'           = '$Identity, $Filter, $LDAPFilter, $SearchBase, $SearchScope, $Properties, $ResultPageSize, $ResultSetSize, $IncludeDeletedObjects, $Server, $Credential'
    # ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$definition = @"
[CmdletBinding()]
param(
    $($adCmdletStubs[$cmdletName]),
    [Parameter(ValueFromRemainingArguments)]`$RemainingArguments
)
`$name = `$MyInvocation.MyCommand.Name
`$count = @(`$RemainingArguments).Count
throw "`$name stub: the ActiveDirectory module is not installed and this call (`$count unlisted argument(s)) was not mocked"
"@

Set-Item -Path "function:global:$cmdletName" -Value ([scriptblock]::Create($definition))

The catch-all is still at the end, so a parameter nobody listed doesn’t turn into a binding error. With named parameters in front of it, both repro tests pass:

1
2
3
Describing named stub
  [+] sees -SearchBase 195ms
  [+] filters on -SearchBase 91ms

The stubs also check Get-Command first and do nothing when RSAT is present. On your machine, if you have the real module, the tests mock the real cmdlets.

What the suite looked like after

The migration commit touched 81 files. Tests for the removed class-hash layer and the script’s content-analysis checks went away, a test file that had been 0 bytes since 2025 got real tests, and the three public commands got new test files with the engine mocked. That left 79 test files, with 4,661 Should - assertions and 211 Should -Invoke calls.

I ran the full suite on PowerShell 7 two minutes before the commit, and again on Windows PowerShell 5.1 five minutes after it, both with Pester 6.2.0. Both runs came out the same: 2,412 passed of 2,414, none failed, 1 skipped. An earlier 5.1 run in between had 13 failures, fixed before the commit went in.

It all passed locally. The first CI run failed all three jobs anyway.