Pester
Pester copied to clipboard
Cannot mock functions captured by <scriptBlock>.GetNewClosure()
General summary of the issue
This is somewhere in between "problem" and "feature request" I think. The problem is that if I have a ScriptBlock that I got from .GetNewClosure()
, and the ScriptBlock calls function "Whatever", I cannot mock "Whatever".
Describe your environment
Pester version : 5.3.1 C:\Users\danthom\Documents\PowerShell\Modules\Pester\5.3.1\Pester.psm1
PowerShell version : 7.2.0
OS version : Microsoft Windows NT 10.0.22518.0
Steps to reproduce
Script under test:
function Foo
{
$a = 'a'
$sb = {
Write-Host "hello, the value of a is: $a" -Fore Green
}.GetNewClosure() # removing ".GetNewClosure()" will make everything work
. $sb
}
Test:
BeforeAll {
Mock Write-Host { 'mocked' }
. $PSCommandPath.Replace('.Tests.ps1', '.ps1')
}
Describe 'Foo' {
It 'does something' {
Foo | Should -Be 'mocked'
}
}
Expected^H^H^H Hoped-For Behavior
I would really like to be able to mock Write-Host
in this example. (So the test would pass.)
Current Behavior
Write-Host
is not mocked (or rather, the mock is not picked up inside the ScriptBlock $sb
), so the Foo
function calls the real Write-Host
, and the test fails.
Possible Solution? (optional)
I was really hoping that doing the mocking before running the SUT would mean that the closure()-ed ScriptBlock would "close over" the mock, but I think closures are implemented with some sort of module scoping trick, and the module used links directly to the global scope (and thus does not pick up the mock), or something like that.
I could pass in the "write-host" command as a parameter to the ScriptBlock... but that starts to get annoying, even for a small number of things. So I'm interested to hear if there are any other techniques I could use, or ideas to get the mock to actually work.
Another crazy idea I thought of is to take advantage of the closure's captured variables, by capturing a "test hook" variable, like so:
SUT:
function Foo
{
begin
{
function Get-TestHook { return { } }
$TestHook = Get-TestHook
}
process
{
$a = 'a'
$sb = {
. $TestHook
Write-Host "hello, the value of a is: $a" -Fore Green
}.GetNewClosure()
. $sb
}
}
Test:
BeforeAll {
. $PSCommandPath.Replace('.Tests.ps1', '.ps1')
function Get-TestHook { throw 'will be mocked' }
Mock Get-TestHook {
return {
[console]::writeline( 'whaoooooooo!' )
Mock Write-Host { 'mocked' }
}
}
}
Describe 'Foo' {
It 'does something' {
Foo | Should -Be 'mocked'
}
}
But sadly, despite the mocked Get-TestHook
being used ("whaoooooooo!" is printed to the console), it still doesn't mock Write-Host
. :'(
Further shenanigans trying to create the mock in the scope of the closure()-ed ScriptBlock have not yielded results, because the Module of the closure()-ed ScriptBlock is a "dynamic" module (with a name like "__DynamicModule_096db59c-aebe-4b04-90b9-719c13ee9487"), and Mock
does not take the module directly; it takes the -ModuleName
, and the dynamic module cannot be looked up by its name (No modules named '__DynamicModule_096db59c-aebe-4b04-90b9-719c13ee9487' are currently loaded.
) :(
The issue is that since the dynamic module doesn't exist prior to execution then we can't inject the required alias to enable mocks.
When you call Mock ...
, Pester hijacks the original command name using an alias in the script scope or scope of module XYZ (if Mock -ModuleName XYZ ...
). This alias points you to Pester's hook-function which finds the correct mock the execute. Unfortunately in this scenario aliases aren't global-scoped by default so inside the DynamicModule it's not visible -> you end up calling the original cmdlet.
Using helper function in module
Other modules are globally imported and available inside other modules, so if you use a helper-function from a module (or convert your SUT to a psm1), you could call that helper-function in the dynamic module. That function would then call Write-Host
and this invocation would be predictable as it's always from the module your helper exist in. This call we can hijack with Mock -ModuleName ...
to call the mock.
demo.psm1
function Foo {
$a = 'a'
$sb = {
Write-FooHostMessage "hello, the value of a is: $a" -Fore Green
}.GetNewClosure()
. $sb
}
function Write-FooHostMessage ($Message, $foregroundColor, $backgroundColor, [switch]$NoNewLine) {
Write-Host @PSBoundParameters
}
demo.tests.ps1
BeforeAll {
Import-Module $PSCommandPath.Replace('.tests.ps1', '.psm1') -Force
Mock Write-Host { 'mocked' } -ModuleName demo
}
Describe 'Foo' {
It 'does something' {
Foo | Should -Be 'mocked'
}
}
Edit: removed alternative using global variable due to being hacky and unsupported
Closing as known limitation with workaround above. Sometimes we need to adjust the code itself to be testable, just like interfaces etc. in other languages. 🙂