Generating Pester mocks


Hi there! While writing this many of you were attending PSConfEU in Prague. Unfortunately I couldn’t join this year so instead I decided to write my first PowerShell-related post. Luckily we just got a question in the Pester-repo from @tahasi that I found interesting.

quote

Is there a recommended way of creating a Pester mock that has a different result for sequential calls?

The user provided a working mock but felt it could be improved.

$script:InvokeCommandCount = 0
Mock Invoke-Command {
$script:InvokeCommandCount++
if ($script:InvokeCommandCount -eq 1) {
return @{
Count = 4;
Minimum = Get-Date -Date "2023-06-06 12:00:00Z"
}
}
if ($script:InvokeCommandCount -eq 2) {
return @{
Count = 6;
Minimum = Get-Date -Date "2023-06-06 12:01:00Z"
}
}
throw "Unexpected Invoke-Command call count"
}

While this works just fine it has some obvious drawbacks:

This is too specific to be a parameter in Mock itself. I suggested writing a helper-function to generate these types of mocks and got inspired to try it out myself. Let’s go through the process together.

First we need to define some requirements based on the question and provided code:

Let’s begin by creating a function which will generate the scriptblock used by our mock. The list of results are turned into a mandatory array-parameter and the if-statements are replaced by an array lookup to make it short. Now we got this:

function New-SequentialResultsMockBehavior {
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object[]] $SequentialResults
)
# Storing variables at script-scope so they're accessible when the scriptblock (mock) is invoked
$script:mockCallCounter = 0
$script:sequentialResults = $SequentialResults
$mockScriptBlock = {
# Array index is zero-based so we save the current value before incrementing
$currentIndex = $script:mockCallCounter++
if ($script:mockCallCounter -gt $script:sequentialResults.Count) {
throw 'Unexpected call. All out of results.'
}
return $SequentialResults[$currentIndex]
}
return $mockScriptBlock
}

The code above meets the first two requirements, but it only works for a single mock. Do you see why?

Multiple mocks in a file would all use the same hard-coded variables to store both the results and call counter.

Section named Avoiding conflicts using closures

Avoiding conflicts using closures

To fix this we could generate random variable-names for each mock, or use a shared hashtable where each mock would have its own unique key. This time we’re skipping both and will instead use the opportunity to look at closures. Here is an updated function:

function New-SequentialResultsMockBehavior {
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object[]] $SequentialResults
)
$mockCallCounter = 0
$mockScriptBlock = {
# Array index is zero-based so we save the current value before incrementing
$currentIndex = $script:mockCallCounter++
if ($mockCallCounter -gt $SequentialResults.Count) {
throw 'Unexpected call. All out of results.'
}
return $SequentialResults[$currentIndex]
}.GetNewClosure()
return $mockScriptBlock
}

Notice that most variable references no longer specify the script-scope. The variables are still hard-coded but that’s no longer a problem. How?

Closures are created by invoking the .GetNewClosure()-method on a scriptblock. It will basically create a copy of the scriptblock and all local variables you’ve referenced inside it and place them in a new dynamic (in-memory) module. In return you get the new scriptblock which is bound to the new module.

The dynamic module looks something like this:

MyModule.psm1
# Module variables
$script:mockCallCounter = 0
$script:SequentialResults = @(1,2,3,4) # Value provided to the function
$mockScriptBlock = {
# Array index is zero-based so we save the current value before incrementing
# Remember to use script: modifier when modifying the module variable so it persists between calls
$currentIndex = $script:mockCallCounter++
if ($mockCallCounter -gt $SequentialResults.Count) {
throw 'Unexpected call. All out of results.'
}
return $SequentialResults[$currentIndex]
}
Export-ModuleMember -Variable mockScriptBlock

If you import the module above you’d get the same result as one of the closures. The scriptblock will be available in your session using $mockScriptBlock. Because the module only exports the scriptblock, all the variables are only visible inside the module where only the scriptblock is executed.

So by mainly adding .GetNewClosure() in our function, each generated mock will get their own private variables. No more conflicts!

With the updated function we can create mocks like this:

$sb = New-SequentialResultsMockBehavior -SequentialResults @(
@{ Count = 4; Minimum = Get-Date -Date '2023-06-06 12:00:00Z' },
@{ Count = 6; Minimum = Get-Date -Date '2023-06-06 12:01:00Z' }
)
Mock -CommandName Invoke-Command -MockWith $sb

This is great, but maybe we could wrap Mock to make it a single statement?

To fully combine both commands we need to extend our function with the parameters supported by Mock so we can pass them along. It also makes sense to rename the function.

function New-SequentialResultsMockBehavior {
function New-SequentialResultsMock {
param(
[Parameter(Mandatory)]
[string] $CommandName,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object[]] $SequentialResults,
[switch] $Verifiable,
[ScriptBlock] $ParameterFilter,
[string] $ModuleName,
[string[]] $RemoveParameterType,
[string[]] $RemoveParameterValidation
)
$mockCallCounter = 0
$mockScriptBlock = {
$currentIndex = $script:mockCallCounter++
if ($mockCallCounter -gt $SequentialResults.Count) {
throw 'Unexpected call. All out of results.'
}
return $SequentialResults[$currentIndex]
}.GetNewClosure()
return $mockScriptBlock
# Remove extra parameters incompatible with Pester\Mock
$PSBoundParameters.Remove('SequentialResults')
# Invoke Mock using the provided parameters and our isolated scriptblock
Pester\Mock @PSBoundParameters -MockWith $mockScriptBlock
}

Now we’re ready to try this out.

NewSequentialResultsMock.tests.ps1
Describe 'Sequential Results Mock Helper' {
BeforeAll {
# inline this function (like this) or dot-source it from a script
# requires modifications to support being part of a module
function New-SequentialResultsMock {
param(
[Parameter(Mandatory)]
[string] $CommandName,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object[]] $SequentialResults,
[switch] $Verifiable,
[ScriptBlock] $ParameterFilter,
[string] $ModuleName,
[string[]] $RemoveParameterType,
[string[]] $RemoveParameterValidation
)
$mockCallCounter = 0
$mockScriptBlock = {
$currentIndex = $script:mockCallCounter++
if ($mockCallCounter -gt $SequentialResults.Count) {
throw 'Unexpected call. All out of results.'
}
return $SequentialResults[$currentIndex]
}.GetNewClosure()
return $mockScriptBlock
# Remove extra parameters incompatible with Pester\Mock
$PSBoundParameters.Remove('SequentialResults')
# Call Mock using the provided parameters and our isolated scriptblock
Pester\Mock @PSBoundParameters -MockWith $mockScriptBlock
}
}
It 'Demo single mock' {
New-SequentialResultsMock -CommandName Invoke-Command -SequentialResults @(
@{ Count = 4; Minimum = Get-Date -Date '2023-06-06 12:00:00Z' },
@{ Count = 6; Minimum = Get-Date -Date '2023-06-06 12:01:00Z' }
)
(Invoke-Command {}).Count | Should -Be 4
(Invoke-Command {}).Count | Should -Be 6
{ Invoke-Command {} } | Should -Throw 'Unexpected call. All out of results.'
}
It 'Demo multiple mocks' {
New-SequentialResultsMock -CommandName Invoke-Command -SequentialResults @(
@{ Count = 4; Minimum = Get-Date -Date '2023-06-06 12:00:00Z' }
)
New-SequentialResultsMock -CommandName Get-ChildItem -SequentialResults @(
@{ Count = 4; Minimum = Get-Date -Date '2023-06-06 12:00:00Z' }
)
(Invoke-Command {}).Count | Should -Be 4
{ Invoke-Command {} } | Should -Throw 'Unexpected call. All out of results.'
(Get-ChildItem).Count | Should -Be 4
{ Get-ChildItem } | Should -Throw 'Unexpected call. All out of results.'
}
}
PowerShell
> Invoke-Pester NewSequentialResultsMock.tests.ps1
Starting discovery in 1 files.
Discovery found 2 tests in 31ms.
Running tests.
[+] /workspaces/Pester/Samples/NewSequentialResultsMock.tests.ps1 193ms (104ms|59ms)
Tests completed in 194ms
Tests Passed: 2, Failed: 0, Skipped: 0 NotRun: 0

Success! 🎉

If you need this in multiple files you can place the function in a ps1-file and just dot-source it in each test-file like BeforeAll { . "$PSScriptRoot/TestHelpers.ps1" }.

In this post we made a function to make it easier to create Pester mocks that share the same type of behavior. We’ve also shown one scenario when using closures, a hidden gem in PowerShell, can be really useful.

Mocking could get complicated so in general I’d avoid helpers like this, but sometimes they do make sense and could save you a lot of time and duplicate code.

That’s it for now. Thank you for reading!