Description
Problem Description
I would like to be able to test code from within a Invoke-Command
that has $using:
statements like so:
$A = 1
$B = 1
Invoke-Command -ScriptBlock {
$using:A + $using:B
}
Deep Dive
If you have used Pester for awhile you have probably encountered the need to mock Invoke-Command
using this pattern, as described on this StackOverflow Post: https://stackoverflow.com/a/48197133/433069. This pattern is common in our code base today:
$A = 1
$B = 1
Invoke-Command -ScriptBlock {
Param($A,$B)
$A + $B
} -ArgumentList @($A,$B)
The mock would then look like this:
Mock Invoke-Command {
$ScriptBlock.Invoke($ArgumentList)
}
This works as far as behavior testing and code coverage.
All throughout this post I will call this the "classic" pattern.
The bummer is that you need to change all of your Invoke-Command
usages to avoid $using:
which is unfortunate.
Attempts at a work around
Consider the following:
InvokeCommand.ps1
function Test-InvokeCommand {
$A = 1
$B = 2
Invoke-Command -ComputerName 'blah' -ScriptBlock {
$using:A + $using:B
Write-Host 'I was called after I threw'
}
}
_InvokeCommand.Tests.ps1
Import-Module 'Pester'
. $PSScriptRoot\InvokeCommand.ps1
Describe 'Invoke-Command Mocking $using' {
Context 'Ast Replacement Pattern' {
# Arrange
Mock Invoke-Command {
# Grab the contents of the ScriptBlock
[string]$scriptBlockValue = $ScriptBlock.Ast.Extent.Text
# If the script block has a $using: directive in it, we need to
# massage the script block to no longer contain this as we are going
# to 'unwrap' it for code coverage.
if ($scriptBlockValue.Contains('$using:')) {
# However because of the way Pester's code coverage works we
# have to invoke the script block at least once to get coverage
# and 'trick' Pester; this block is expected to throw.
try {
$ScriptBlock.Invoke($ArgumentList)
}
catch {
[string]$scriptBlockValue = $scriptBlockValue.Replace('$using:', '$')
$ModifiedScriptBlock = [ScriptBlock]::Create($scriptBlockValue)
# Because we took a ScriptBlock above and created a new
# script block we have "double wrapped" this script block
# hence we need to unwrap it twice here.
$($ModifiedScriptBlock.Invoke()).Invoke($ArgumentList)
}
}
else {
$ScriptBlock.Invoke($ArgumentList)
}
} -Verifiable
# Act
$result = Test-InvokeCommand
# Assert
It 'Should return the result' {
$result | Should -Be 3
}
}
}
The above uses the PowerShell Ast to grab the text and then does a blind replacement of $using:
and then simply invokes the command as the "classic" pattern does. This actually works for getting the testing behavior! However the problem becomes when we go to perform Code Coverage.
As you can see from the comments above we first make a blind attempt to run the code using the "classic" pattern, which will work, and give you coverage, at least until you run into the first using block. If your usage has that $using
as the last statement this will work for you, but its unlikely that your usage is that simple.
If you run the following:
Invoke-Pester "$PSScriptRoot\InvokeCommand.Tests.ps1" -CodeCoverage "$PSScriptRoot\InvokeCommand.ps1"
You get:
Pester v4.10.1
Executing all tests in 'S:\Git\SimpleExamples\Pester\Mocking\Invoke-Command\InvokeCommand.Tests.ps1'
Executing script S:\Git\SimpleExamples\Pester\Mocking\Invoke-Command\InvokeCommand.Tests.ps1
Describing Invoke-Command Mocking $using
Context Ast Replacement Pattern
I was called after I threw
[+] Should return the result 165ms
Tests completed in 1.45s
Tests Passed: 1, Failed: 0, Skipped: 0, Pending: 0, Inconclusive: 0
Code coverage report:
Covered 80.00% of 5 analyzed Commands in 1 File.
Missed command:
File Class Function Line Command
---- ----- -------- ---- -------
InvokeCommand.ps1 Test-InvokeCommand 6 Write-Host 'I was called after I threw'
Obviously because the exception occurred on the line before the debugger figures that the remainder of the logic was not invoked.
That is a real bummer if you are trying to get 100% Code Coverage.
Attempts to Fake Out Code Coverage
If you dig down into how Pester implements CodeCoverage you will eventually arrive to this high level understanding:
- At the start of a test Pester scans the
-CodeCoverage
file for "interesting" lines. Basically lines we want to cover, attempting to account for some behaviors of PowerShell/DSC and limitations in the debugger. - Each of these lines is then assigned a PowerShell Breakpoint (https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.breakpoint?view=powershellsdk-7.0.0) and stored in the Pester State
$pester.CommandCoverage[xxx].Breakpoint
Property. - The Unit Tests are ran in their own context; this is important because it means we do not have (obvious) access to the Pester State Object
- After the tests are finished, Pester will enumerate the Pester State
CommandCoverage
objects looking for whereBreakpoint.HitCount
is equal to0
, indicating that a line was missed for code coverage.
I believe the "classic" pattern works around this because $ScriptBlock
preserves all of the StartLine
/EndLine
/etc information in its AST; such that when this code is ran the PowerShell debugger can figure out that these breakpoints are being hit and increment the HitCount
.
In the above pattern this information is lost, because we more or less have created a new ScriptBlock that does not contain any of this information. To further complicate matters it does not appear that there is a way to (easily) add this information into the generated ScriptBlock.
Alternative thoughts are to perhaps lie to the Pester State object and claim that the breakpoints have been hit, making a pinky promise that this code is indeed called. However because it is ran in a different context it is unclear how you would even get access to that object.
Conclusion
I am by no means a PowerShell Guru, but as far as barking up this tree I have gone as far as I can.
Any thoughts on if there is a more optimal way around this?