1

Consider a .ps1 script that takes parameters and makes state changes. There are two aspects of the script I'd like to test:

  • Parameters are received from caller, typed, and sorted into parameter sets according to expectations
  • Key internal function calls are made correctly

This in mind, consider this script, StateChangingDemo.ps1:

    param([object[]]$stateChangeRequests)


    function ChangeSystemStateSomehow($changeRequestParameter)
    {
        # ... apply $changeRequestParameter to system state
    }

    $transformedRequests = $stateChangeRequests | % {
        # do some processing of requests
    }

    $transformedRequests | % {
        ChangeSystemStateSomehow $_
    }

Is it possible to use Pester and mocking to:

  1. Intercept the value of $stateChangeRequests, to validate that it has the value and shape expected based on how it is called?
  2. Intercept calls to ChangeSystemStateSomehow to make sure a) the function doesn't do damage during the test process (especially because of defects), and b) the function gets called with the expected values?

Note: I can see how moving pieces of the script into a module could help with testing, but for this question let's assume that everything in the script needs to remain in the script.

John Doggett
  • 137
  • 2
  • 7

1 Answers1

1

After some experimentation, I was able to come up with some satisfying answers. For the most part, the answer is yes, with a bit of trickery.

Test examples will follow, but here are the contents of StateChangingDemo.ps1 that I developed tests against:

    [CmdletBinding(DefaultParameterSetName='SetA')]
    param(
        [Parameter(ParameterSetName='SetA')]
        [object[]]$TypeA_Requests,

        [Parameter(ParameterSetName='SetB')]
        [switch]$TypeB_All
    )

    function ChangeSystemStateSomehow($changeRequestParameter)
    {
        # ... apply $changeRequestParameter to system state
        "system state was modified"
    }

    $transformedRequests = $TypeA_Requests | % {
        # do some processing of requests
        $_
    }

    $transformedRequests | % {
        ChangeSystemStateSomehow $_
    }

Mocking a function internal to a .ps1

If the .ps1 calls a function that needs to be mocked (such as ChangeSystemStateSomehow), a version of the function needs to exist and be mocked before calling the .ps1.

    Describe 'test StateChangingDemo.ps1' {
        Context 'no mock' {
            It 'needs to be mocked' {
                & .\StateChangingDemo.ps1 | Should -Be "system state was modified"
            }
        }

        Context 'no mock, but placeholder function defined' {
            function ChangeSystemStateSomehow {
                "placeholder function"
            }

            # Show that a function in current scope won't displace the one in the script
            It 'still needs to be mocked' {
                & .\StateChangingDemo.ps1 | Should -Be "system state was modified"
            }

        }

        Context 'function called by script is mocked' {
            # This is required because Mock can't work before a function by the same name exists.
            # However, the mock persists even though the function is later replaced.
            function ChangeSystemStateSomehow {
                "placeholder function"
            }

            Mock ChangeSystemStateSomehow {"fake execution"}

            It 'can have functions within mocked' {
                 & .\StateChangingDemo.ps1 | Should -Be "fake execution"
            }
        }

    }

Testing parameter parsing on the script

This aspect of testing the script benefits from two approaches.

Should -HaveParameter

The first approach is to recognize that Should -HaveParameter can be used directly on the script:

Describe 'test StateChangingDemo.ps1' {
    Context 'no mock again' {       
        It 'has expected parameters' {
             Get-command .\StateChangingDemo.ps1 | Should -Not -BeNullOrEmpty 
             Get-command .\StateChangingDemo.ps1 | Should -HaveParameter TypeA_Requests -Type [object[]] 
             Get-command .\StateChangingDemo.ps1 | Should -HaveParameter TypeB_All -Type [switch] 
        }
    }
}

The second approach is to mock the script itself. This is not as straightforward:

    Context 'whole script mock attempted' {
        Mock .\StateChangingDemo.ps1 {"fake script execution"}

        It 'cannot be mocked directly' {
             .\StateChangingDemo.ps1 | Should -Be "system state was modified"
        }
    }

The above passing test shows that even though we established a mock, the script still executed when invoked.

Mocking the Script itself

So instead we create a function whose implementation comes from treating the script as a script block:

    Context 'whole script wrapped' {
        Set-Item function:fnStateChangingDemo ([ScriptBlock]::Create((get-content -Raw .\StateChangingDemo.ps1)))

        It 'has expected parameters' {
             Get-command fnStateChangingDemo | Should -Not -BeNullOrEmpty 
             Get-command fnStateChangingDemo | Should -HaveParameter TypeA_Requests -Type "object[]" -Because 'it has TypeA_Requests'
             Get-command fnStateChangingDemo | Should -HaveParameter TypeB_All -Type "switch" -Because 'it has TypeB_All'
        }

        Mock ChangeSystemStateSomehow {"fake execution"}

        It 'can still have functions within mocked when it is wrapped with a function' {
             & fnStateChangingDemo | Should -Be "fake execution"
        }
    }

The above test shows that the function presents the same interface as the script itself did, so we can test parameters on it in the same way. Moreover, we can now use the mock to safely make more complex assertions about parameter parsing:

    Context 'whole script wrapped and mocked' {       
        Set-Item function:fnStateChangingDemo ([ScriptBlock]::Create((get-content -Raw .\StateChangingDemo.ps1)))

        Mock fnStateChangingDemo {"fake script function execution"}

        It 'can be mocked when wrapped as function' {
             fnStateChangingDemo | Should -Be "fake script function execution"
        }

        It 'cannot accept conflicting parameters' {
            # Using multiple parameter sets is not allowed
            {fnStateChangingDemo -TypeA_Requests 1,2,3 -TypeB_All} | Should -Throw "Parameter set cannot be resolved"
        }
    }

Using more sophisticated mock bodies, Assert-MockCalled and Assert-VerifiableMocks and parameter filters, we can use the mocked function wrapper to design and test usages of the script without risking that the tests ask it to do something real on accident.

John Doggett
  • 137
  • 2
  • 7