powershellpester

How to deal with Break statement that is causing Issue for Pester on Should -Invoke Command


I noticed something peculiar when using Should -Invoke on a Mock command. When running without a Break statement in the Code Block, Should -Invoke works perfectly fine; but when running with a Break statement, Should -Invoke will always validate as TRUE, that is, Pester will pass all tests.

I do not want to remove Break statement from my original .ps1 script as I need this. Therefore, I would like to know if there is a way that I can Mock Break statement using pester or if there are any workaround for this, so that i can continue to validate all Mock commands without removing Break statement from my source code.

Below is the code that I used to show the behavior of Break statement given in the else code block when running with Pester. Notice that Context VALIDATE ELSE BLOCK passes all tests.

BeforeAll {
    function Something-Function {
        Param(
            [Parameter(Mandatory)][String]$SamplePath
        )
    
        Write-Host "Hello from Function"
    
        if (Test-Path -Path $SamplePath -PathType Leaf) {
            Write-Host "Hello from If"

            "Hello Earth!" | Out-File $SamplePath -Encoding utf8 -Append
        }
        else {
            Write-Host "Hello from Else"
    
            "Hello World!!" | Out-File $SamplePath -Encoding utf8 -Append

            Break
        }
    }
}

Describe "Something-Function" {
    BeforeAll {
        Mock Write-Host {}

        $TestPath = "TestDrive:\"
        $FileName = "fake.txt"
    }

    Context "VALIDATE IF BLOCK" {
        BeforeAll {
            Mock Test-Path -MockWith {return $true}

            # New-Item -ItemType File -Path $($TestPath + $FileName) -Force
            "Hello World!!" | Out-File $($TestPath + $FileName) -Encoding utf8 -Append
        }

        It "Should -Invoke Write-Host -Exactly -Times 1" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 1
        }

        It "Should -Invoke Write-Host -Exactly -Times 2" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 2
        }

    }

    Context "VALIDATE ELSE BLOCK" {
        BeforeAll {
            Mock Test-Path -MockWith {return $false}
        }

        It "Should -Invoke Write-Host -Exactly -Times 1" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 1
        }

        It "Should -Invoke Write-Host -Exactly -Times 2" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 2
        }

        It "Should -Invoke Write-Host -Exactly -Times 3" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 3
        }

        It "Should -Invoke Out-File -Exactly -Times 50" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Write-Host -Exactly -Times 50
        }

        It "Should -Invoke Import-Csv -Exactly -Times 100" {
            Something-Function -SamplePath $($TestPath + $FileName)
            Should -Invoke Import-Csv -Exactly -Times 100
        }
    }
}

# PS C:\user\repo> Invoke-Pester -Output Detailed .\TEST\Issue.tests.ps1
# Pester v5.3.3

# Starting discovery in 1 files.
# Discovery found 7 tests in 199ms.
# Running tests.

# Running tests from 'C:\user\repo\TEST\Issue.tests.ps1'
# Describing Something-Function
#  Context VALIDATE IF BLOCK
#    [-] Should -Invoke Write-Host -Exactly -Times 1 96ms (93ms|3ms)
#     Expected Write-Host to be called 1 times exactly but was called 2 times
#     at Should -Invoke Write-Host -Exactly -Times 1, C:\user\repo\TEST\Issue.tests.ps1:42
#     at <ScriptBlock>, C:\user\repo\TEST\Issue.tests.ps1:42
#    [+] Should -Invoke Write-Host -Exactly -Times 2 34ms (34ms|0ms)
#  Context VALIDATE ELSE BLOCK
#    [+] Should -Invoke Write-Host -Exactly -Times 1 69ms (66ms|3ms)
#    [+] Should -Invoke Write-Host -Exactly -Times 2 35ms (34ms|0ms)
#    [+] Should -Invoke Write-Host -Exactly -Times 3 33ms (33ms|0ms)
#    [+] Should -Invoke Out-File -Exactly -Times 50 35ms (34ms|0ms)
#    [+] Should -Invoke Import-Csv -Exactly -Times 100 35ms (34ms|1ms)
# Tests completed in 739ms
# Tests Passed: 6, Failed: 1, Skipped: 0 NotRun: 0

I have try mocking Break statement Mock Break {} but failed and generate the below error.

[-] Context Something-Function.VALIDATE ELSE BLOCK failed
   CommandNotFoundException: Could not find Command Break

Solution

  • If you follow the link in @mklement0's comment above you'll see this:

    Do not use break outside of a loop, switch, or trap

    When break is used outside of a construct that directly supports it (loops, switch, trap), PowerShell looks up the call stack for an enclosing construct. If it can't find an enclosing construct, the current runspace is quietly terminated.

    This means that functions and scripts that inadvertently use a break outside of an enclosing construct that supports it can inadvertently terminate their callers.

    Using break inside a pipeline break, such as a ForEach-Object script block, not only exits the pipeline, it potentially terminates the entire runspace.

    The following bad code demonstrates this in action:

    #
    # BAD CODE FOLLOWS - DON'T DO THIS !!!
    #
    
    function Test-Outer
    {
        $x = 1
        write-host "entering outer"
        switch( $x )
        {
            1 {
                write-host "outer before"
                Test-Inner $x
                write-host "outer after"
            }
        }
        write-host "leaving outer"
    }
    
    function Test-Inner
    {
        param( $y )
        if( $y -eq 1 )
        {
            write-host "inner before"
            # DON'T DO THIS OUTSIDE OF A LOOP, SWITCH OR TRAP !!!
            # (the 'break' will bubble up the call stack and terminate the switch case in Test-Outer)
            break;
            write-host "inner after"
        }
    }
    
    Test-Outer
    

    which gives this output:

    entering outer
    outer before
    inner before
    leaving outer
    

    Note that we don't see inner after or outer after in the output - in short, the break inside the Test-Inner actually terminates the switch case inside Test-Outer and execution skips to the code after the switch block.

    You're effectively controlling the flow of the calling code from within Test-Inner, which might work for your current script but it breaks the encapsulation of functions and as you can see from the documentation it's not recommended. A side effect is that trying to test Test-Inner with Pester results in Test-Inner unexpectedly terminating the calling Pester test harness...

    A better approach might be for your Test-Outer function to decide whether to call break to control its own flow based on a result returned from Test-Inner:

    function Test-Outer
    {
        $x = 1
        write-host "entering outer"
        switch( $x )
        {
            1 {
                write-host "outer before"
                if( -not (Test-Inner $x) )
                {
                    break;
                }
                write-host "outer after"
            }
        }
        write-host "leaving outer"
    }
    
    
    # returns $true for success or $false for failure
    function Test-Inner
    {
        param( $y )
        if( $y -eq 1 )
        {
            write-host "inner before"
            return $false
            write-host "inner after"
        }
        return $true
    }
    
    Test-Outer
    

    We still see the same output from the second example...

    entering outer
    outer before
    inner before
    leaving outer
    

    ... but now the control flow (break) is local to the code where the control statements (switch) live and you can call Test-Inner without the break bubbling up to the caller which makes it safe to execute from other callers (e.g. Pester).

    Workaround

    If you really can't live with moving the break out of Test-Inner you could wrap the call in a sacrificial control statement so that gets terminated rather than the main caller code.

    For example, this version of Test-Outer doesn't exit the switch case when Test-Inner invokes break because it terminates the sacrificial for instead...

    function Test-Outer
    {
        $x = 1
        write-host "entering outer"
        switch( $x )
        {
            1 {
                write-host "outer before"
                # run *one* iteration, but also act as a sacrificial 
                # target for the effect of the "break" in Test-Inner
                for( $i = 0; $i -lt 1; $i++ ) { Test-Inner $x }
                write-host "outer after"
            }
        }
        write-host "leaving outer"
    }
    

    This now outputs the following:

    entering outer
    outer before
    inner before
    outer after
    leaving outer
    

    ...but it means you'll have to add the workaround at every call-site - for example in your Pester tests it would become this:

    It "Should -Invoke Write-Host -Exactly -Times 1" {
        for( $i = 0; $i -lt 1; $i++ ) {
            Something-Function -SamplePath $($TestPath + $FileName)
        }
        Should -Invoke Write-Host -Exactly -Times 1
    }
    

    which will get messy very quickly, and overall it's probably better to invest time in cleaning up your script rather than making more convoluted tests to work around the funky break in the main script...