Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: Is it possible to "parameterize" the ParameterFilter for Mocks? #1162

Closed
fourpastmidnight opened this issue Dec 13, 2018 · 29 comments

Comments

@fourpastmidnight
Copy link
Contributor

fourpastmidnight commented Dec 13, 2018

1. General summary of the issue

OK, so I know on face value, the answer to this question is No. But hear me out. I'm using Gherkin style tests and I have several parameter variations for a call to a function within my module. So in my Given step, I have a table which has the parameter values for each call, and in my Then step I have another table that has the resulting parameter values I expect. More details are in order, but as this is a summary section, see below for more information.

2. Describe Your Environment

Pester version : 4.4.2 C:\Program Files\WindowsPowerShell\Modules\Pester\4.4.2\Pester.psd1
PowerShell version : 5.1.17134.165
OS version : Microsoft Windows NT 10.0.17134.0

3. Current Behavior

OK, so one note, the names are not great--still learning how to use Pester and Gherkin style tests within Pester and so, well, things change a lot right now.

Here's my Gherkin file:

Feature: Can query resources from vRA

  Scenario: Can generate correct URLs with specified query options

    Given the following query options:
      | ManagedOnly | WithExtendedData | WithOperations |
      | $false      | $false           | $false         |
      | $true       | $true            | $true          |
      # There are more examples, but these are sufficient

    When Get-vRAResource is invoked with the given parameters

    Then the Url segment generated for the search query should end with:
      | UrlSegment                                                   |
      | /catalog-service/api/consumer/resourceViews                  |
      | /catalog-service/api/consumer/resourceViews?managedOnly=true |

Here are the When and Then step definitions which are relevant, of things that I've tried:

When "Get-vRAResource is invoked with the given parameters" {
    $Script:Context.GenerateParameterFilter = {
        param([Uri]$ExpectedApiUriSegment)

        {
            if ("$ApiUriSegment" -ine "$ExpectedApiUriSegment") {
                Write-Host "This mock will not be executed because the 'ApiUriSegment' parameter did not have the expected value:"
                Write-Host "   Expected: $ExpectedApiUriSegment"
                Write-Host "   Actual:   $ApiUriSegment"
                $false
            } else {
                $true
            }
        }.GetNewClosure()
    }

    $Script:Context.ParameterFilterClosure = &{
        param ([Uri]$ExpectedApiUriSegment)

        {
            if ("$ApiUriSegment" -ine "$ExpectedApiUriSegment") {
                Write-Host "This mock will not be executed because the 'ApiUriSegment' parameter did not have the expected value:"
                Write-Host "   Expected: $ExpectedApiUriSegment"
                Write-Host "   Actual:   $ApiUriSegment"
                $false
            } else {
                $true
            }
        }.GetNewClosure()
    } ""
}

Then "the (?i:URL) generated for the search query should be:?" {
    param([hashtable[]]$Table)

    Mock -ModuleName 'DevOps.DecommissionServer' -CommandName xRequires -ParameterFilter { $Version -eq "7.0" }
    Mock Invoke-vRARestMethod -ModuleName 'DevOps.DecommissionServer'

    $Table.Length | Should -BeExactly $Script:Context.GetVraResourceParameters.Length

    for ($n = 0; $n -lt $Table.Length; $n++) {
        # $mockArgs = @{
        #     ModuleName = 'DevOps.DecommissionServer'
        #     CommandName = 'Invoke-vRARestMethod'
        #     ParameterFilter = &$Script:Context.GenerateParameterFilter ([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring), [UriKind]::Relative))
        # }

        $params = $Script:Context.GetVraResourceParameters[$n]
        try {
            try {
                #&$Script:Context.ParameterFilterClosure.Module Set-Variable ExpectedApiUriSegment ([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring), [UriKind]::Relative)) -Scope Script
                Get-VraResource -vRASession $Script:Context.vRASession @params
            } catch {
                # We'll get a WebException if the Mock is not called due to the generated ApiUriSegment not matching
                # the expected ApiUriSegment, which causes the mock to not be called and the real Invoke-vRARestMethod
                # to be called, which results in an attempt to call Invoke-RestMethod using a non-existent
                # FQDN (e.g. vra.mycompany.com), hence the WebException.
                #
                # Just swallow that, because when Assert-MockCalled below is called, the mock will not have been called
                # and the test will fail.
                if (!$_.Exception.InnerException -or ($_.Exception.InnerException -and $_.Exception.InnerException.GetType().Name -ne 'WebException')) {
                    throw
                }
            }

            #Assert-MockCalled 'Invoke-vRARestMethod' -ModuleName 'DevOps.DecommissionServer' -ParameterFilter (&$Script:Context.GenerateParameterFilter ([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring), [UriKind]::Relative)))

            &$Script:Context.ParameterFilterClosure.Module Set-Variable ExpectedApiUriSegment ([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring), [UriKind]::Relative))
            Assert-MockCalled 'Invoke-vRARestMethod' -ModuleName 'DevOps.DecommissionServer' -ParameterFilter $Script:Context.ParameterFilterClosure
        } catch {
            # Catching the exception thrown by Assert-MockCalled so I can get _useful_ output... :|
            if ($_.Exception) {
                $message = $_.Exception.Message.Trim();
                if ($message.StartsWith("Expected Invoke-vRARestMethod")) {
                    $message = "Expected Invoke-vRARestMethod in module DevOps.DecommissionServer to be called with '$([Uri]::EscapeUriString($Table[$n].UrlSubstring))'."
                    throw [Exception]::new($message, $_.Exception)
                } else {
                    throw
                }
            } else {
                throw
            }
        }
    }
}

I expected that by using a closure, I could capture the variable from $Table[$n].UrlSubstring (another poorly named item--but this is still a work in progress, mostly around getting a passing test!) I tried doing the simplest thing possible: -ParameterFilter { "$ApiUriSegment" -ieq "$([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring)))" }, however, when Test-ParameterFilter executes, $Table is nowhere to be found in any scope whatsoever. Hence my attempt to use closures to capture the value that I want.

4. Expected Behavior

I expected, then, that by capturing $Table[$n].UrlSubstring in $ExpectedApiUriSegment inside the closure, that when Test-ParameterFilter executes, $ExpectedApiUriSegment would be in the local scope. But unfortunately, it is not.

Is there any way to do what I want at all? I fear I may be too close to the trees to see the forest.

@nohwnd
Copy link
Member

nohwnd commented Dec 14, 2018

@fourpastmidnight I don't understand what you are after. Coould you simplify you example to show me only the relevant stuff in a simple code? Here is a simple example of what I think you want, but I cannot break it. Once we can replicated it in simple example I can find out what is possible.

function a ($Parameter) {}

Describe "a" { 
    It "b" {

        $table = "1"

        Mock a -ParameterFilter { 
            # adding the param throws because Pester generates one for you
            # so it's duplicated
            # param($Parameter)
            Write-Host "Parameter -$Parameter-"
            Write-Host "Table -$table-"

            # you want $table to be 1?
            $Parameter -eq $table 
        }

        a -Parameter 10
    }
}

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Dec 14, 2018

@nohwnd Simplistically, that is what I want, but with a Gherkin test.

I just filed a bug with respect to Gherkin tests and setting the scope when calling Assert-MockCalled (#1164). I fixed the immediate issue on my own local copy of Pester, and so now I can use something like -Scope Scenario. I mention this because, after fixing #1164 locally, it just looks like the scope for the parameter filter is not being set correctly (for Gherkin style tests). I also tryed using -Scope 0 and -Scope 1. I don't, however, think I should need to use the Scope parameter at all. I should be able to simply call:

Then "this should pass" {
    param([hashtable[]]$Table)

    Mock Invoke-vRARestMethod -ModuleName 'DevOps.DecommissionServer'

    for ($n = 0; $n -lt $Table.Length; $n++) {
        $params = $Script:Context.GetVraResourceParameters[$n]

        Get-vRAResource -vRASession $vRASession @params

        Assert-MockCalled Invoke-vRARestMethod -ModuleName 'DevOps.DecommissionServer'  -ParameterFilter { "$ApiUriSegment" -ieq "$($Table[$n].UrlSubstring)" } #-Scope Scenario #<-- Should I need this??
    }
}

But whenever I step through the dynamically created script block for the parameter filter (in Test-ParameterFilter), the local and/or script scopes do not have any variable named $Table in them, regardless of whether or not I use the Scope parameter to Assert-MockCalled!

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Dec 14, 2018

Sorry a bit of a follow-on:

Now, because the scopes did not seem to be set properly (at the beginning, I wasn't really sure what's going on), I started asking myself whether I needed to use closures to effectively capture the "Local" variable for the parameter filter, which lead to some of the crazy code up there. I was hoping that by using a closure, it would force the local variable to be "in scope":

$Script:Context.ParameterFilterClosure = {
    param([Url]$ExpectedApiUriSegment)

    $expected = $ExpectedApiUriSegment

    {
        "$ApiUriSegment" -ieq "$expected"
    }.GetNewClosure()
}
# ...

Mock Invoke-vRARestApi -ModuleName 'DevOps.DecommissionServer'

for ($n = 0; $n -lt $Table.Length; $n++)  {
    $parameterFilter = &$Script:Context.ParameterFilterCloser [Uri]::new($Table[$n}.UrlSubstring, [UriKind]::Relative)

    $params = $Script:Context.GetvRAResourceParameters[$n]

    Get-vRAResource -vRASession $Script:Context.vRASession @params

    # This still doesn't work because the scope of $parameterFilter won't have $expected....
    # What gives? I'm still learning about PowerShell scoping, but my understanding is that this _should_ work!
    Assert-MockCall Invoke-vRARestMethod -ModuleName 'DevOps.DecommissionServer' -ParameterFilter $parameterFilter
}

@fourpastmidnight
Copy link
Contributor Author

@nohwnd OK, so the above is not an MVCE (minimally viable and complete example). Let me see if I can come up with one which will demonstrate the problem. That should've been my first go-to in "Triaging" this problem anyway... 😉

@nohwnd
Copy link
Member

nohwnd commented Dec 14, 2018

Does Invoke-vRARestMethod have only one parameter? Maybe by adding the paramblock yourself you are not getting the parameters correctly?

@fourpastmidnight
Copy link
Contributor Author

@nohwnd No, it has many parameters, but all but one are optional. And the test above is only attempting to test 3 of the optional parameters. Stepping through the code in the debugger, when we get to the dynamically generated script block being executed by Test-ParameterFilter, the correct parameters are present.

This whole issue is around the fact that:

  1. Using a "local" variable within the step definition script block is not in scope within the script block that's generated and executed by Test-ParameterFilter
  2. Unable to use a closure, because any variable local to the closure are also "not in scope" in the generated script block executed by Test-ParameterFilter

It's really strange, but the scopes just don't seem to be properly set up/attached to the generated script block.

I'm working on creating a very minimal module with only the most relevant code and the tests above. I'll hopefully have something pushed up to my GitHub account later today. When I do, I'll post a link here.

@fourpastmidnight
Copy link
Contributor Author

@nohwnd OK, I just created a repo in my GitHub account which, as minimally as possible, attempts to:

  1. Reproduce my working environment (see the README)
  2. Uses a full-blown module instead of a simple function "inline" with the tests to more accurately reproduce the execution environment when running the tests, and
  3. Which exhibits the problem I'm experiencing as related in this issue

If you have a bit of time to look at it, I would be very appreciative.

@fourpastmidnight
Copy link
Contributor Author

One more note: there's a file in the repo called vRASession.Steps.ps1 which has a step definition in it where I was forced to put a variable in the Global scope because when using the -MockWith parameter, that script block also did not have access to a variable defined in the step definition script block. So this (in my opinion) reinforces my hypothesis that perhaps something is not quite right with scopes when using mocks with Gherkin-style tests. I could probably use the Global scope again for the current issue. I didn't because my original test design was based on creating mocks in a loop, until I discovered that the current way I've designed my test is much better. In any event, I shouldn't have to resort to putting variables into the Global scope for mocking.

@nohwnd
Copy link
Member

nohwnd commented Dec 15, 2018

@fourpastmidnight This was a nice problem to debug, I actually wrote a lot of super useful code to figure it out :D Here is output from Pester with scope related instrumentation:

PS C:\Projects\PesterGherkinMockScopeTests> cd C:\Projects\PesterGherkinMockScopeTests
#get-item variable:ExpectedUriApiSegment  | Remove-Item -force 
Get-Module Pester | Remove-Module
Import-Module C:\Projects\pester_main\Pester.psm1
Get-Module PesterGherkinMockScopeTests | remove-module
$ExpectedUriApiSegment = "Hello"
Invoke-Gherkin -Path ./specs/features -ExcludeTag ignore -PesterOption (New-PesterOption -TestSuiteName 'PesterGherkinMockSopeTests' -IncludeVSCodeMarker)
Testing all features in './specs/features'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Caller - Captured in Invoke-Gherkin (61485005))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Caller - Captured in Invoke-Gherkin (61485005))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Caller - Captured in Invoke-Gherkin (61485005))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Caller - Captured in Invoke-Gherkin (61485005))'

Feature: Can query for resources from vRA
     As a consumer of the vRA REST API
     I want to be able to retrieve information about resources
     So that I can programmatically manage vRA resources

  Scenario: Can generate correct URLs with specified query options
Setting ScriptBlock state from source state 'Caller - Captured in Invoke-Gherkin (61485005))' to 'Scenario (53986122))'
Invoking scriptblock from location 'Invoke-Gherkin step' in state 'Scenario (53986122))', 0 scopes deep:
    {
        
    if (!$Script:Context) {
        $Script:Context = @{}
    }

    $Script:Context.vRAServerFQDN = 'vra.mycompany.com'
    $Script:Context.Credential = [PSCredential]::new('a-user', ('a-password' | ConvertTo-SecureString -AsPlainText -Force))
    $Script:Context.Tenant = 'the-tenant'
    $step = Find-GherkinStep -Step "When a new vRA session is requested via the 'Credential' parameter set"

    Invoke-Command $step.Implementation -ArgumentList 'Credential'
    $vRASession = $Script:Context.Actual
    $Script:Context = @{}
    $Script:Context.vRASession = $vRASession

    }


Setting ScriptBlock state from source state 'Pester (42654543))' to 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Pester (42654543))' to 'Scenario (53986122))'
Getting scope from ScriptBlock 'Scenario (53986122))'
Unbinding ScriptBlock from 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Unbound ScriptBlock from Mock (Unbound)' to 'Module - PesterGherkinMockScopeTests (10265017))'
Unbinding ScriptBlock from 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Unbound ScriptBlock from Test-ParameterFilter (Unbound)' to 'Caller - Captured in Invoke-Gherkin (61485005))'
Invoking scriptblock from location 'Mock - Parameter filter' in state 'Caller - Captured in Invoke-Gherkin (61485005))', 0 scopes deep:
    {
        
        [CmdletBinding(HelpUri='https://go.microsoft.com/fwlink/?LinkID=217034')] param (${Body},${Headers},${Method},${ContentType},${Uri})

        Set-StrictMode -Off
        
        $Body -and ($Body | ConvertFrom-JSON).tenant -eq 'the-tenant'
    
    
    }


Setting ScriptBlock state from source state 'Pester (42654543))' to 'Module - PesterGherkinMockScopeTests (10265017))'
Invoking scriptblock from location 'Mock - of command Invoke-RestMethodfrom module PesterGherkinMockScopeTests' in state 'Module - PesterGherkinMockScopeTests (10265017))', 2 scopes deep:
    {
        
        @{
            expires = $Global:expiry
            id = 'the-token'
            tenant = 'the-tenant'
        } | ConvertTo-JSON
    
    }


Invoking scriptblock from location 'Mock - of command Invoke-RestMethod' in state 'Module - PesterGherkinMockScopeTests (10265017))', 3 scopes deep:
    {
        
        @{
            expires = $Global:expiry
            id = 'the-token'
            tenant = 'the-tenant'
        } | ConvertTo-JSON
    
    }


    [+] Given an authorized REST API user has authenticated with vRA 224ms
Setting ScriptBlock state from source state 'Scenario (53986122))' to 'Scenario (53986122))'
Invoking scriptblock from location 'Invoke-Gherkin step' in state 'Scenario (53986122))', 0 scopes deep:
    {
        
    param([hashtable[]]$Table)

    # $Script:Context is being used similarly to 'world' in Ruby's cucumber.
    if (!$Script:Context) {
        $Script:Context = @{}
    }

    $Script:Context.GetVraResourceParameters = @(foreach ($row in $Table) {
        $params = @{
            ManagedOnly = (ConvertFrom-TableCellValue $row.ManagedOnly) -as [switch]
            WithExtendedData = (ConvertFrom-TableCellValue $row.WithExtendedData) -as [switch]
            WithOperations = (ConvertFrom-TableCellValue $row.WithOperations) -as [switch]
        }

        foreach ($key in $params.Keys.Clone()) {
            if ($null -eq $params[$key]) {
                $params.Remove($key)
            }
        }

        $params
    })

    }


    [+] Given the following query options:... 8ms
Setting ScriptBlock state from source state 'Scenario (53986122))' to 'Scenario (53986122))'
Invoking scriptblock from location 'Invoke-Gherkin step' in state 'Scenario (53986122))', 0 scopes deep:
    {
        
    $Script:Context.ParameterFilterClosure = {
        param ([Uri]$ExpectedApiUriSegment)

        # I probably don't need to do this, but following an example at
        # https://blogs.technet.microsoft.com/heyscriptingguy/2013/04/05/closures-in-powershell/
        $_ExpectedApiUriSegment = $ExpectedApiUriSegment

        {
            if ("$ApiUriSegment" -ieq "$_ExpectedApiUriSegment") {
                $true
            } else {
                Write-Host "This mock will not be executed because the 'ApiUriSegment' parameter did not have the expected value:"
                Write-Host "   Expected: $_ExpectedApiUriSegment"
                Write-Host "   Actual:   $ApiUriSegment"
                $false
            }
        }.GetNewClosure()
    }

    }


    [+] When Get-vRAResource is invoked with the given parameters 39ms
Setting ScriptBlock state from source state 'Scenario (53986122))' to 'Scenario (53986122))'
+ Invoking scriptblock from location 'Invoke-Gherkin step' in state 'Scenario (53986122))', 0 scopes deep:
    {
        
    param([hashtable[]]$Table)

    # Invoke-vRARestMethod is called from Get-vRAResource, so need to use -ModuleName parameter
    # since Invoke-vRARestMethod is invoked within the context of the module itself.
    Mock Invoke-vRARestMethod -ModuleName 'PesterGherkinMockScopeTests'

    $Table.Length | Should -BeExactly $Script:Context.GetVraResourceParameters.Length

    # Looping through a) the parameters to be passed to Get-vRAResource, and the expected generated Url segment
    for ($n = 0; $n -lt $Table.Length; $n++) {
        $params = $Script:Context.GetVraResourceParameters[$n]
        try {
            Get-VraResource -vRASession $Script:Context.vRASession @params

            # Create a relative Uri with the expected generated Api segment.
            $ExpectedUriApiSegment = [Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring), [UriKind]::Relative)

            # This should be the simplest thing I need to do to assert this mock was called
            Assert-MockCalled Invoke-vRARestMethod -ModuleName PesterGherkinMockScopeTests -ParameterFilter { 
            Write-Host -Fore magenta "$ExpectedUriApiSegment"
            "$ApiUriSegment" -ieq "$ExpectedUriApiSegment" }

            # This should also work, I think..., but shouldn't be required
            #----------------------------------------------------------------------------------------------
            #Assert-MockCalled Invoke-vRARestMethod -ModuleName PesterGherkinMockScopeTests -ParameterFilter { "$ApiUriSegment" -ieq "$ExpectedUriApiSegment" } -Scope 0 #<-- This is current scope, right?

            # And, after fixing Pester issue #1164, this should also work, but again, shouldn't be required:
            #-----------------------------------------------------------------------------------------------
            #Assert-MockCalled Invoke-vRARestMethod -ModuleName PesterGherkinMockScopeTests -ParameterFilter { "$ApiUriSegment" -ieq "$ExpectedUriApiSegment" } -Scope Scenario

            # Since none of the above worked, now we use what we did in the When block above, to generate
            # a script block containing the parameter filter test, as a closure, in the hopes that the local variable
            # passed into the closure is captured, and now local to the closure itself, and therefore available to
            # the parameter filter test script block.
            #
            # BUT, the scope will not contain either the local variable $ExpectedApiUriSegment above, nor the
            # local variable inside the closure, $_ExpectedApiUriSegment.
            #-----------------------------------------------------------------------------------------------
            #$parameterFilter = &$Script:Context.ParameterFilterClosure $ExpectedUriApiSegment
            #Assert-MockCalled 'Invoke-vRARestMethod' -ModuleName 'PesterGherkinMockScopeTests' -ParameterFilter $parameterFilter

            # And once again, you could try scoping the same call above, but nothing changes
            #----------------------------------------------------------------------------------------------
            #Assert-MockCalled 'Invoke-vRARestMethod' -ModuleName 'PesterGherkinMockScopeTests' -ParameterFilter $parameterFilter -Scope 0
            #Assert-MockCalled 'Invoke-vRARestMethod' -ModuleName 'PesterGherkinMockScopeTests' -ParameterFilter $parameterFilter -Scope Scenario # <-- Need to fix #1164 for this to work...

        } catch {
            # Catching the exception thrown by Assert-MockCalled so I can get _useful_ output... :|
            if ($_.Exception) {
                $message = $_.Exception.Message.Trim();
                if ($message.StartsWith("Expected Invoke-vRARestMethod")) {
                    $message = "Expected Invoke-vRARestMethod in module PesterGherkinMockScopeTests to be called with '$([Uri]::EscapeUriString($Table[$n].UrlSubstring))'."
                    throw [Exception]::new($message, $_.Exception)
                } else {
                    throw
                }
            } else {
                throw
            }
        }
    }

    }


Getting scope from ScriptBlock 'Pester (42654543))'
Unbinding ScriptBlock from 'Pester (42654543))'
Setting ScriptBlock state from source state 'Unbound ScriptBlock from Mock (Unbound)' to 'Module - PesterGherkinMockScopeTests (10265017))'
Unbinding ScriptBlock from 'Pester (42654543))'
Setting ScriptBlock state from source state 'Unbound ScriptBlock from Test-ParameterFilter (Unbound)' to 'Caller - Captured in Invoke-Gherkin (61485005))'
Invoking scriptblock from location 'Mock - Parameter filter' in state 'Caller - Captured in Invoke-Gherkin (61485005))', 0 scopes deep:
    {
        
        [CmdletBinding(DefaultParameterSetName='Standard')] param (${ApiUriSegment},${vRASession},${Method})

        Set-StrictMode -Off
        $True
    
    }


Setting ScriptBlock state from source state 'Pester (42654543))' to 'Module - PesterGherkinMockScopeTests (10265017))'
Invoking scriptblock from location 'Mock - of command Invoke-vRARestMethodfrom module PesterGherkinMockScopeTests' in state 'Module - PesterGherkinMockScopeTests (10265017))', 2 scopes deep:
    {
        
    }


Invoking scriptblock from location 'Mock - of command Invoke-vRARestMethod' in state 'Module - PesterGherkinMockScopeTests (10265017))', 3 scopes deep:
    {
        
    }


Unbinding ScriptBlock from 'Scenario (53986122))'
Setting ScriptBlock state from source state 'Unbound ScriptBlock from Test-ParameterFilter (Unbound)' to 'Caller - Captured in Invoke-Gherkin (61485005))'
+ Invoking scriptblock from location 'Mock - Parameter filter' in state 'Caller - Captured in Invoke-Gherkin (61485005))', 0 scopes deep:
    {
        
        [CmdletBinding(DefaultParameterSetName='Standard')] param (${ApiUriSegment},${vRASession},${Method})

        Set-StrictMode -Off
         
            Write-Host -Fore magenta "$ExpectedUriApiSegment"
            "$ApiUriSegment" -ieq "$ExpectedUriApiSegment" 
    
    }


+ Hello
    [-] Then the Url generated for the search query should be:... 254ms
      at <ScriptBlock>, C:\Projects\PesterGherkinMockScopeTests\specs\features\steps\Get-vRAResource.Steps.ps1: line 101
      101:                     throw [Exception]::new($message, $_.Exception)
      
      From C:\Projects\PesterGherkinMockScopeTests\specs\features\Get-vRAResource.feature: line 17
      Expected Invoke-vRARestMethod in module PesterGherkinMockScopeTests to be called with '/catalog-service/api/consumer/resourceViews'.
Testing completed in 526ms
Scenarios Passed: 0 Failed: 1
Steps Passed: 3 Failed: 1 Skipped: 0 Pending: 0 Inconclusive: 0 

As you can hopefully see, almost before the output of the whole step it says

Invoking scriptblock from location 'Invoke-Gherkin step' in state 'Scenario (53986122))', 0 scopes deep:

And then at bottom it says:

Invoking scriptblock from location 'Mock - Parameter filter' in state 'Caller - Captured in Invoke-Gherkin (61485005))', 0 scopes deep:

So the Step and the Filter are both invoked in different scopes. This I knew pretty much from the start, what was more suprising is where the filter actually invokes. The 0 scope is the invocation script (in your case it would probably be the command you pass to VS code, I have run it from a script like this:

cd C:\Projects\PesterGherkinMockScopeTests
#get-item variable:ExpectedUriApiSegment  | Remove-Item -force 
Get-Module Pester | Remove-Module
Import-Module C:\Projects\pester_main\Pester.psm1
Get-Module PesterGherkinMockScopeTests | remove-module
$ExpectedUriApiSegment = "Hello"
Invoke-Gherkin -Path ./specs/features -ExcludeTag ignore -PesterOption (New-PesterOption -TestSuiteName 'PesterGherkinMockSopeTests' -IncludeVSCodeMarker)

And the Hello you see there is also printed into the output. Putting it any deeper, e.g. on the top of your step file does not work, because through parsing and, I guess, dot sourcing it runs at the absolute top. If you want to experiment with it I pushed my changes to branch session-state-hints on Pester repo. I will clean it up before merging so the output is disabled by default of course :)

@fourpastmidnight
Copy link
Contributor Author

@nohwnd Thanks for looking at this! I thought this would be an interesting problem. So, I had some time to review what you posted above last night. I can't say that I completely understand it myself--I understand at a very high, conceptual level, but I don't truly "grok" what you have found.

I'll take a look at your code and see if I can make heads or tails of it. My understanding of PowerShell scoping is a bit weak, and getting into the internals of PowerShell itself even more so.

But, this has been such a frustrating, but interesting problem, I'm looking forward to getting to know PowerShell more intimately because of it!

@nohwnd
Copy link
Member

nohwnd commented Dec 20, 2018

@fourpastmidnight I'll try to explain this in more detail. There are session states and scopes, I might be using the words interchanably in the above description of the problem, so here is a more precise definition.

Session state is like a silo in which all variables, functions and other "resources" reside, and that is attached to one place such as module. -> An example of having two session states is making a module and invoking a function exported from that module from a script. That script is one session state (you can think about it as anonymous module if you want), and the module is another session state. This allows you to have internal variables and functions within a module that are not visible to other modules.

Scope is like a level in that silo. On each level you can define functions and variables and if they are the same as variables on the levels below your level you shadow them. There a multiple ways to create a new scope, caling a function is one of them. Another is just invoking a script block:

$a = 1
& {
    $a = 2 
    & {  
        $a
    }
    $a
}
$a

This piece of code nests two scopes and if you run it you can see how $a is resolved to different values based on in which scope it runs, because the shadowing variables definition $a=2 dies when it's scope dies. So this code outputs 2,2,1.

When you jump between session states you can define new scopes so new variables are available (or shadowed) within that session state, in the same way I did in the previous example. So I can take a scriptblock associate it to the Script (I call it Caller in the output because it is more accurate) session state and define a new variable there and it will stay there until that scope dies.

This has some implications for your Gherkin code, from what I understood the Gherkin runtime captures steps to be run and then invokes them in one place in the top-level scope. So going back to similar structure like the example above:

$script:filter = $null
$a = 1
& {
    $a = 2 
    # captures it here
    $script:filter = {  # param filter
        $a
    }
}

# BUT invokes it here
&$script:filter

The invoked script does not run within the place where $a is defined as 2, but instead it runs in place where it is defined as 1. (or in your case where the filter parameter no longer exists.

To make this even more complicated, as you can see from the output above the scenario and the paramter filter are invoked in two different session states. So the actual invocation looks more like this:

Keep in mind that this iis pseudo code, invoking it won't give you the correct results because the session state transitions are not implemented in the example:

$a = 'hello'
$script:filter = $null
$script:steps = @()
# this is in caller scope -> where filter runs
& {
    # we transitioned to Scenario scope (empty module)
   # this variable will be defined in Scenario scope 
   $a = 2 
    # capture the step
    $script:steps += { <# some step definition #> }
    # capture the filter
    $script:filter = {  # param filter
        $a
    }
}

# invoke step here
& {
    # invoke step here in Scenario scope $a is no longer defined that scope died
    # you would need $script:a = 2 to make it available here
    # $a will most likely be 'hello' here, because the top level scope will be above the 
    # scenario module session state
   & $script:steps[0]
 
   &{
       # invoke filter in caller scope, $a = 'hello' is defined because we are running in caller session state
       # and our scope is inheriting from the top-level scope where $a = 'hello' is defined
       &$script:filter 
  }
}

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Dec 20, 2018 via email

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 9, 2019

@nohwnd I'm not sure what the state of this is. I saw that you put some code into Master pursuant to the above. So, I grabbed the latest 4.5.0-beta2 code, but my tests are still failing. So I set $DisableScopeHints to $False and tried to debug this some more, but, I'm not really sure why I'm getting the error I'm now getting.

Here's the error I'm getting (I've included a bit of the Scope hints, because they may be important):

Setting ScriptBlock state from source state 'Unbound ScriptBlock from Test-ParameterFilter (Unbound)' to ''
Invoking scriptblock from location 'Mock - Parameter filter' in state ' - (30084608)', 6 scopes deep:
    {
        
        [CmdletBinding(DefaultParameterSetName='Standard')] param (${ApiUriSegment},${vRASession},${Method})

        Set-StrictMode -Off
         "$ApiUriSegment" -ieq "$([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring)))" 
    
    }

    [-] Then the Url generated for the search query should be:... 1150.18s
      at <ScriptBlock>, C:\src\git\MyModule\Dependencies\Pester\4.5.0.2\Functions\Mock.ps1: line 1236
      1236:     & $cmd @BoundParameters @ArgumentList
      
      From C:\src\git\MyModule\specs\features\Get-vRAResource.feature: line 22
      Cannot index into a null array.

So, the immediate thing that jumps out to me is the actual line that's being reported as the source of the error, 1236 in Mock.ps1. Here, I think what it's complaining about is @ArgumentList being null (as, $BoundParameters definitely has a value but $ArgumentList is an empty array). Not sure why this would be an issue. (Why wouldn't (couldn't) we just do something like (@BoundParameters + @ArgumentList)? Would this not have the effect we want of passing in all the bound parameters and any other arguments? But then again, I'm not even sure why this is an issue in the first place--once again, my PowerShell knowledge here is probably lacking.)

But, looking at those scope hints above the error, I see where we set the ScriptBlock state from 'Unbound ScriptBlock from Test-ParameterFilter (Unbound)' to ''. Is that empty "to" string something we need to be concerned about with respect to ensuring the session state is set correctly for the parameter filter??

BTW, I ran this code two different ways when asserting the mock:

  1. Assert-MockCalled Invoke-vRARestMethod -ModuleName 'MyModule.DecommissionServer' -ParameterFilter { "$ApiUriSegment" -ieq "$([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring)))" } -Scope Scenario
  2. Then, questioning whether or not I needed to explicitly specify the Scope parameter, I also ran the test with Assert-MockCalled Invoke-vRARestMethod -ModuleName 'MyModule.DecommissionServer' -ParameterFilter { "$ApiUriSegment" -ieq "$([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring)))" }

So, because of this new error with the null array, I'm not convinced that I'm actually testing the code changes you made with respect to setting script block session state/scope. I'll take a closer look at the changes you made to see if there are any obvious differences, but on the surface, I don't see anything overtly suspicious.

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 9, 2019

OK, so one of my hypotheses about the cause has proved to not be the case--it's not because $ArgumentList is an empty array. I quickly changed the code in Test-ParameterFilter to:

if ($ArgumentList.Length -gt 0) {
  & $cmd @BoundParameters @ArgumentList
} else {
  & $cmd @BoundParameters
}

But the code still failed with the exact same error: Cannot index into a null array. When executing this line of code in the debugger, I'm using 'Step Into', hoping to step into the parameter filter script block--but the code throws this error before I even begin executing the script block. So, I'm not really sure what the issue is here.

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 9, 2019

AH! I know what the issue is. Here's my parameter filter:

-ParameterFilter { "$ApiUriSegment" -ieq "$([Uri]::new([Uri]::EscapeUriString($Table[$n].UrlSubstring)))" }

The "null array" is $Table. So this implies that the scope for the parameter filter script block is still not being set appropriately.

@fourpastmidnight
Copy link
Contributor Author

I quickly re-read your explanation above, and on my first reading, I apparently glossed over some salient points. It appears that, while you implemented some hints and what not, the way mocks work (for Gherkin) has not really changed a great deal--which explains my current error--it's really the same error I was getting originally before I went the route of trying to explicitly create closures. So if I were to do the whole closure thing again, I should get similar errors I was getting back on Dec. 14th/15th where whatever I'm trying to compare would be null since it won't exist in the session state (and by extension, scope) that the parameter filter is running in.

Hmm, this will be a tricky issue to resolve.

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 9, 2019

@nohwnd: Well, for the particular test in question, realizing what the issue really is (and better understanding it), and also realizing that I'm just trying to perform a string comparison for the parameter filter of the mock, I was able to modify my code:

$ParameterFilterScriptBlock = [ScriptBlock]::Create("{ `"`$ApiUriSegment`" -ieq `"`$([Uri]::new([Uri]::EscapeUriString(`"$($Table[$n].UrlSubString)`")))`" }")
Assert-MockCalled Invoke-vRARestMethod -ModuleName 'MyModule.DecommissionServer' -ParameterFilter $ParameterFilterScriptBlock

This creates a string containing the expanded $Table[$n] value to create a System.Uri which is compared with the incoming parameter to the call to Invoke-vRARestMethod; and this string is converted into a script block and passed as the ParameterFilter. And now my test passes.

While this works for this particular case, this is not "the solution"; so we need to continue to determine how to correctly set the state for parameter filters for Mocks (and possibly other states for mocks when used with Gherkin style tests??)

One question that came to mind is why we're creating so many scopes for Gherkin style tests. Looking at The Cucumber Book, steps are global--any scenario can make use of any step found during execution according to the options specified for cucumber. Anyway, the point is, however, that when running a Scenario, a step can take it's argument and store it in some "instance variable" (to quote the book), which is then made available for all future steps defined in the scenario. Note that this includes background steps, too. So it would seem that steps should not have their own isolated session state, but should execute within the context of the scenario itself. How would this look in PowerShell? Not sure. Does this mean that during execution of steps we're always setting the step definition session state to be the session state of the enclosing scenario? This seems to make the most sense to me. This may resolve a lot of scoping issues that have cropped up in the issues list, such as #1071, where I resort to using $Script:Context to store data between steps for use in future steps in the scenario. And doing this may also go a long ways towards resolving the mocking isuses with Gherkin style tests (though, I'm thinking it won't get us all the way "there").

@fourpastmidnight
Copy link
Contributor Author

In version 4.4.3 of Pester, in Gherkin.ps1 we do set the step definition's session state to that of the enclosing scenario, so, hmm.

@nohwnd
Copy link
Member

nohwnd commented Jan 14, 2019

Getting the scopes right is difficult, but I try to do right in v5 🙂

Describe 'b' {
    Write-Host "This is Describe"
    BeforeAll { Write-Host "This is Setup" }
    It 'i' { }
}

Invoking this in v4:

  Describing b
This is Setup
This is Describe
    [?] i 1.26s

Invoking this in v5:

Describing b
This is Describe
This is Setup
    [+] i 27ms

and that is just the order. The scoping is different story. But I hope I am getting this right, so it will also work correctly for Gherkin when it's implemented on top of the new core.

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 14, 2019 via email

@nohwnd
Copy link
Member

nohwnd commented Jan 15, 2019

@fourpastmidnight we totally should, and if you think in the mean time how you want the scoping to work, and the output then even better. :)

@fourpastmidnight
Copy link
Contributor Author

Hmm, I just found something interesting I'd glossed over before in Mock.ps1. There's the following line:

#Line 1081
$scope = if ($pester.InTest) { $null } else { $pester.CurrentTestGroup }

At this line, $pester is nowhere to be found in any scope (local, script, or global). So this evaluates to False and $scope is never set to the current test group, which in turn (if I'm understanding things correctly) is then not setting the expected scope on the Mock's MockWith script block. Of course, $pester probably isn't set because somewhere else further up the chain, the scopes were also not set correctly...

@fourpastmidnight
Copy link
Contributor Author

fourpastmidnight commented Jan 18, 2019

And to elaborate on the above, here's my call stack:

ExecuteBlock  (Mock.ps1 1081:5)
Invoke-Mock  (Mock.ps1 966)
Get-vRAResource<Process> (Get-vRAResource .ps1 254)  # <-- The function under test
<ScriptBlock>  (Get-vRAResource.steps.ps1 145)  # <-- The step definition script block currently being executed

What I found is that when I looked into the <ScriptBlock> stack frame, $Pester was defined (as expected, since that's the step definition being executed by Pester). But once inside Invoke-Mock, $pester was no longer in any local, script or global scope.

This may not be earth-shattering to anyone who's looked in-depth at this issue--but since I'm still learning my away around PowerShell, session states and scopes, I found this of interest as it relates to this issue.

@fourpastmidnight
Copy link
Contributor Author

Oh, wait, that $pester is the $PesterState associated with the Mock itself after calling Find-Mock. Hmm, for v5 we want to use better and consistent naming. 😉

@fourpastmidnight
Copy link
Contributor Author

Ok, one more observation/question, so if the parameter $Mockto ExecuteBlock contains the current pester state, why are we trying to use $Pester directly instead of $Mock.PesterState? While this may resolve the issue with $pester not being defined in the current execution scope in ExecuteBlock (outside of the value captured $Mock that is), peeking into $Mock.PesterState I see that $Mock.PesterState.InTest is set to $false, which on line 1081 in Mock.ps1 would once again result in $scope being set to $null instead of $pester.CurrentTestGroup (which, of course would also need to change to $Mock.PesterState.CurrentTestGroup if my line of reasoning is correct).

Ok, and expanding on this, looking at Gherkin.ps1, we call $pester.EnterTestGroup but we never call $pester.EnterTest. So $Pester.InTest never gets set to true.

@fourpastmidnight
Copy link
Contributor Author

Hold on--never mind the above. The VS Code PowerShell debugging is very wonky and not to be trusted (at least, for debugging complex issues like session state in Pester). When I executed line 1081, $pester.InTest was true (even though I could find no reference to $pester in any of the "scopes" displayed in the debugger pane. And once again I locked up my debugging session trying to "dot into" $pester in the Watch window to see what the heck is going on. Sorry for the noise and chatter here.

@nohwnd
Copy link
Member

nohwnd commented Jan 18, 2019

@fourpastmidnight That $pester variable is set inside of Invoke-Pester or Describe fuctions. You should be able to see it when you are inside of Pester module scope (e.g. when you call Invoke-Mock). As you figured out, I think it problem with the tools not with how Pester works.

@fourpastmidnight
Copy link
Contributor Author

I've been working more with Mocks recently and I finally got a PowerShell closure to work! I thought I was going mad and just didn't understand PowerShell closures even though I quite understand them in other languages. Anyway, long story short, I got it to work with the -MockWith parameter with Mock and Assert-MockWasCalled.

Since I got closures to work for that parameter (meaning, I could close over state from my step definitions to use inside the script block to MockWith), I thought, oh, well now that I know how they work I can probably use them with the -ParameterFilter block, too, which is what I was first trying to accomplish when I opened this issue.

Alas, no. But, this is a good thing! It tells me that the way the script block is being set up for use by -MockWith is different from how the script block passed in for -ParameterFilter is being set up. Something's different the way the state/session/scope/whatever (😉) is being constructed for these two script block parameters such that closures for one works, but not for the other.

@nohwnd
Copy link
Member

nohwnd commented Feb 9, 2019

@fourpastmidnight Your observations are correct. When MockWith is a closure then it is kept as is, on the other hand the ParameterFilter is not. Instead the body of the parameter filter is concatenated with a param block, created as a new scriptblock and then bound back to caller scope, so the eventual closure state is dropped. See here.

@nohwnd nohwnd added the Gherkin label May 21, 2021
@nohwnd nohwnd closed this as completed May 21, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants