Skip to content

Attempts to use $using within Invoke-Command #2015

Open
@aolszowka

Description

@aolszowka

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:

  1. 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.
  2. 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.
  3. 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
  4. After the tests are finished, Pester will enumerate the Pester State CommandCoverage objects looking for where Breakpoint.HitCount is equal to 0, 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions