From 78022d94856363685afcf36586d1757f12bf1789 Mon Sep 17 00:00:00 2001 From: Matt Wrock Date: Thu, 29 Oct 2015 22:12:50 -0700 Subject: [PATCH] improved suport for pipeline processing with begin block --- Functions/Mock.Tests.ps1 | 115 ++++++++++++++ Functions/Mock.ps1 | 331 ++++++++++++++++++++++++++++++--------- Pester.psd1 | 3 +- Pester.psm1 | 2 +- 4 files changed, 375 insertions(+), 76 deletions(-) diff --git a/Functions/Mock.Tests.ps1 b/Functions/Mock.Tests.ps1 index 6346c23df..91354320b 100644 --- a/Functions/Mock.Tests.ps1 +++ b/Functions/Mock.Tests.ps1 @@ -39,6 +39,42 @@ function CommonParamFunction ( return "Please strip me of my common parameters. They are far too common." } +function PipelineInputFunction { + param( + [Parameter(ValueFromPipeline=$True)] + [int]$PipeInt1, + [Parameter(ValueFromPipeline=$True)] + [int[]]$PipeInt2, + [Parameter(ValueFromPipeline=$True)] + [string]$PipeStr, + [Parameter(ValueFromPipelineByPropertyName=$True)] + [int]$PipeIntProp, + [Parameter(ValueFromPipelineByPropertyName=$True)] + [int[]]$PipeArrayProp, + [Parameter(ValueFromPipelineByPropertyName=$True)] + [string]$PipeStringProp + ) + begin{ + $p = 0 + } + process { + foreach($i in $input) + { + $p += 1 + write-output @{ + index=$p; + val=$i; + PipeInt1=$PipeInt1; + PipeInt2=$PipeInt2; + PipeStr=$PipeStr; + PipeIntProp=$PipeIntProp; + PipeArrayProp=$PipeArrayProp; + PipeStringProp=$PipeStringProp; + } + } + } +} + Describe "When calling Mock on existing function" { Mock FunctionUnderTest { return "I am the mock test that was passed $param1"} @@ -1343,3 +1379,82 @@ Describe 'Mocking New-Object' { Assert-MockCalled New-Object } } + +Describe 'Mocking a function taking input from pipeline' { + $psobj = New-Object -TypeName psobject -Property @{'PipeIntProp'='1';'PipeArrayProp'=1;'PipeStringProp'=1} + $psArrayobj = New-Object -TypeName psobject -Property @{'PipeArrayProp'=@(1)} + $noMockArrayResult = @(1,2) | PipelineInputFunction + $noMockIntResult = 1 | PipelineInputFunction + $noMockStringResult = '1' | PipelineInputFunction + $noMockResultByProperty = $psobj | PipelineInputFunction -PipeStr 'val' + $noMockArrayResultByProperty = $psArrayobj | PipelineInputFunction -PipeStr 'val' + + Mock PipelineInputFunction { write-output 'mocked' } -ParameterFilter { $PipeStr -eq 'blah' } + + context 'when calling original function with an array' { + $result = @(1,2) | PipelineInputFunction + it 'Returns actual implementation' { + $result[0].keys | % { + $result[0][$_] | Should Be $noMockArrayResult[0][$_] + $result[1][$_] | Should Be $noMockArrayResult[1][$_] + } + } + } + + context 'when calling original function with an int' { + $result = 1 | PipelineInputFunction + it 'Returns actual implementation' { + $result.keys | % { + $result[$_] | Should Be $noMockIntResult[$_] + } + } + } + + context 'when calling original function with a string' { + $result = '1' | PipelineInputFunction + it 'Returns actual implementation' { + $result.keys | % { + $result[$_] | Should Be $noMockStringResult[$_] + } + } + } + + context 'when calling original function and pipeline is bound by property name' { + $result = $psobj | PipelineInputFunction -PipeStr 'val' + it 'Returns actual implementation' { + $result.keys | % { + $result[$_] | Should Be $noMockResultByProperty[$_] + } + } + } + + context 'when calling original function and forcing a parameter binding exception' { + Mock PipelineInputFunction { + if($MyInvocation.ExpectingInput) { + throw New-Object -TypeName System.Management.Automation.ParameterBindingException + } + write-output $MyInvocation.ExpectingInput + } + $result = $psobj | PipelineInputFunction + + it 'falls back to no pipeline input' { + $result | Should Be $false + } + } + + context 'when calling original function and pipeline is bound by property name with array values' { + $result = $psArrayobj | PipelineInputFunction -PipeStr 'val' + it 'Returns actual implementation' { + $result.keys | % { + $result[$_] | Should Be $noMockArrayResultByProperty[$_] + } + } + } + + context 'when calling the mocked function' { + $result = 'blah' | PipelineInputFunction + it 'Returns mocked implementation' { + $result | Should Be 'mocked' + } + } +} diff --git a/Functions/Mock.ps1 b/Functions/Mock.ps1 index 005d72a83..22d2cce23 100644 --- a/Functions/Mock.ps1 +++ b/Functions/Mock.ps1 @@ -268,7 +268,8 @@ about_Mocking $newContent = $newContent -replace '#FUNCTIONNAME#', $CommandName $newContent = $newContent -replace '#MODULENAME#', $ModuleName - $mockScript = [scriptblock]::Create("$cmdletBinding`r`nparam( $paramBlock )`r`n$dynamicParamBlock`r`nprocess{`r`n$newContent}") + # Wrap in an End block and batch pipeline to be sent to Invoke-Mock + $mockScript = [scriptblock]::Create("$cmdletBinding`r`nparam( $paramBlock )`r`n$dynamicParamBlock`r`nend{`r`n$newContent}") $mock = @{ OriginalCommand = $contextInfo.Command @@ -751,7 +752,109 @@ function MockPrototype { ${session state} = if (${p s cmdlet}) { ${p s cmdlet}.SessionState } - Invoke-Mock -CommandName '#FUNCTIONNAME#' -ModuleName '#MODULENAME#' -BoundParameters $PSBoundParameters -ArgumentList ${a r g s} -CallerSessionState ${session state} + if($myinvocation.ExpectingInput) { + $pipeArray = @($Input) + # We have pipeline input so strip off params bound from pipeline + $filtered = Select-NonPipelinedBoundParameters ` + -Invocation $MyInvocation ` + -PipelineInput $pipeArray ` + -ParameterSet $PSCmdlet.ParameterSetName ` + -BoundParameters $PSBoundParameters + + # using the , operator to batch all pipelineInput in an array + ,$pipeArray | Invoke-Mock -CommandName '#FUNCTIONNAME#' ` + -ModuleName '#MODULENAME#' ` + -BoundParameters $PSBoundParameters ` + -ArgumentList ${a r g s} ` + -CallerSessionState ${session state} ` + -FilteredParameters $filtered + } + else { + Invoke-Mock -CommandName '#FUNCTIONNAME#' ` + -ModuleName '#MODULENAME#' ` + -BoundParameters $PSBoundParameters ` + -ArgumentList ${a r g s} ` + -CallerSessionState ${session state} + } +} + +function Select-NonPipelinedBoundParameters { + <# + .SYNOPSIS + This command is used by Pester's Mocking framework. You do not need to call it directly. + + .DESCRIPTION + This attempts to "unbind" all parameters bound from the pipeline. + We will examine the parameter metadata and values of the bound + parameters and compare the values with the pieline input stripping + parametrs that appear to be candidates to be bound from the pipeline. + + Here are the criteria used to determine such candidates: + - The parameters must be attributed ValueFromPipeline or ValueFromPipelineByPropertyName + - One of the following is true of the parameter value: + - -eq with the pipeline input evaluates to $True + - Compare-Object with the pipeline input releals that no values are different + #> + + [CmdletBinding()] + param ( + [object] + $Invocation, + + [object[]] + $PipelineInput, + + [string] + $ParameterSet, + + [hashtable] + $BoundParameters = @{} + ) + + # This is done in an End block so use the last value on the pipeline + $lastInput=$PipelineInput[$PipelineInput.count-1] + + $filteredParameters = @{} + + if($Invocation.MyCommand.Parameters -eq $Null) { return $filteredParameters } + + $BoundParameters.Keys | % { + $paramAttributes = $Invocation.MyCommand.Parameters[$_].Attributes | ? { $_ -is [System.Management.Automation.ParameterAttribute] } + $match = $false + + if($paramAttributes | ? { $_.ValueFromPipeline }){ + $testVal = $BoundParameters[$_] + + # If this is an array of 1, just use that value because thats what powershell does + if($testVal -is [Array] -and $testVal.Length -eq 1){ + $testVal = $BoundParameters[$_][0] + } + $testMatch = ($testVal -eq $lastInput) + if($testMatch.Gettype() -ne [bool]) { + $testMatch = ((Compare-Object $testVal $lastInput -ExcludeDifferent) -eq $null) + } + if($testMatch) { $match = $true } + } + if($paramAttributes | ? { $_.ValueFromPipelineByPropertyName }){ + $hasProperty = $false + try { $hasProperty = $lastInput.$_ } catch { } + + # Get-Member takes too long + if($hasProperty) { + $testMatch = ($BoundParameters[$_] -eq $lastInput.$_) + if($testMatch.Gettype() -ne [bool]) { + $testMatch = ((Compare-Object $BoundParameters[$_] $lastInput.$_ -ExcludeDifferent) -eq $null) + } + if($testMatch) { + $match = $true + } + } + } + + if(!$match) { $filteredParameters[$_] = $BoundParameters[$_]} + } + + return $filteredParameters } function Invoke-Mock { @@ -775,103 +878,183 @@ function Invoke-Mock { [object[]] $ArgumentList = @(), - [object] $CallerSessionState - ) + [object] $CallerSessionState, - if ($mock = $mockTable["$ModuleName||$CommandName"]) - { - for ($idx = $mock.Blocks.Length; $idx -gt 0; $idx--) + [Parameter(ValueFromPipeline = $True)] + [object] + $pipelineInput, + + [hashtable] + $FilteredParameters + ) + process { + if ($mock = $mockTable["$ModuleName||$CommandName"]) { - $block = $mock.Blocks[$idx - 1] + for ($idx = $mock.Blocks.Length; $idx -gt 0; $idx--) + { + $block = $mock.Blocks[$idx - 1] - $params = @{ - ScriptBlock = $block.Filter - BoundParameters = $BoundParameters - ArgumentList = $ArgumentList - Metadata = $mock.Metadata - } + $params = @{ + ScriptBlock = $block.Filter + BoundParameters = $BoundParameters + ArgumentList = $ArgumentList + Metadata = $mock.Metadata + } - if (Test-ParameterFilter @params) - { - $block.Verifiable = $false - $mock.CallHistory += @{CommandName = "$ModuleName||$CommandName"; BoundParams = $BoundParameters; Args = $ArgumentList; Scope = $pester.Scope } + if (Test-ParameterFilter @params) + { + $block.Verifiable = $false + $mock.CallHistory += @{CommandName = "$ModuleName||$CommandName"; BoundParams = $BoundParameters; Args = $ArgumentList; Scope = $pester.Scope } - $scriptBlock = { - param ( - [Parameter(Mandatory = $true)] - [scriptblock] - $ScriptBlock, + $scriptBlock = { + param ( + [Parameter(Mandatory = $true)] + [scriptblock] + $ScriptBlock, - [hashtable] - $BoundParameters = @{}, + [hashtable] + $BoundParameters = @{}, - [object[]] - $ArgumentList = @(), + [object[]] + $ArgumentList = @(), - [System.Management.Automation.CommandMetadata] - $Metadata, + [System.Management.Automation.CommandMetadata] + $Metadata, - [System.Management.Automation.SessionState] - $SessionState - ) + [System.Management.Automation.SessionState] + $SessionState, - # This script block exists to hold variables without polluting the test script's current scope. - # Dynamic parameters in functions, for some reason, only exist in $PSBoundParameters instead - # of being assigned a local variable the way static parameters do. By calling Set-DynamicParameterValues, - # we create these variables for the caller's use in a Parameter Filter or within the mock itself, and - # by doing it inside this temporary script block, those variables don't stick around longer than they - # should. + [Parameter(ValueFromPipeline = $True)] + [object] + $pipelineInput + ) + process { + # This script block exists to hold variables without polluting the test script's current scope. + # Dynamic parameters in functions, for some reason, only exist in $PSBoundParameters instead + # of being assigned a local variable the way static parameters do. By calling Set-DynamicParameterValues, + # we create these variables for the caller's use in a Parameter Filter or within the mock itself, and + # by doing it inside this temporary script block, those variables don't stick around longer than they + # should. + + # Because Set-DynamicParameterVariables might potentially overwrite our $ScriptBlock, $BoundParameters and/or $ArgumentList variables, + # we'll stash them in names unlikely to be overwritten. + + $___ScriptBlock___ = $ScriptBlock + $___BoundParameters___ = $BoundParameters + $___ArgumentList___ = $ArgumentList + + Set-DynamicParameterVariables -SessionState $SessionState -Parameters $BoundParameters -Metadata $Metadata + & $___ScriptBlock___ @___BoundParameters___ @___ArgumentList___ + } + } - # Because Set-DynamicParameterVariables might potentially overwrite our $ScriptBlock, $BoundParameters and/or $ArgumentList variables, - # we'll stash them in names unlikely to be overwritten. + Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $mock.SessionState - $___ScriptBlock___ = $ScriptBlock - $___BoundParameters___ = $BoundParameters - $___ArgumentList___ = $ArgumentList + $mockWithPipeline = $false + if($myinvocation.ExpectingInput) { + $mockWithPipeline = $true + } - Set-DynamicParameterVariables -SessionState $SessionState -Parameters $BoundParameters -Metadata $Metadata - & $___ScriptBlock___ @___BoundParameters___ @___ArgumentList___ - } + if($mockWithPipeline){ + $currentErrorPreference = $ErrorActionPreference + $ErrorActionPreference = "Stop" + try { + $pipelineInput | & $scriptBlock -ScriptBlock $block.Mock -ArgumentList $ArgumentList -BoundParameters $FilteredParameters -Metadata $mock.Metadata -SessionState $mock.SessionState + } + catch [System.Management.Automation.ParameterBindingException] { + # There was an error in our parameter stripping + # Fall back to original non pipeline call + $mockWithPipeline = $false + } + catch { + if($_.GetType() -eq [System.Management.Automation.ErrorRecord]){ + write-error $_ + } + else{ + throw $_ + } + } + finally { + $ErrorActionPreference = $currentErrorPreference + } + } - Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $mock.SessionState - & $scriptBlock -ScriptBlock $block.Mock -ArgumentList $ArgumentList -BoundParameters $BoundParameters -Metadata $mock.Metadata -SessionState $mock.SessionState + if(!$mockWithPipeline){ + & $scriptBlock -ScriptBlock $block.Mock -ArgumentList $ArgumentList -BoundParameters $BoundParameters -Metadata $mock.Metadata -SessionState $mock.SessionState + } - return + return + } } - } - $scriptBlock = { - param ($Command, $ArgumentList, $BoundParameters) - & $Command @ArgumentList @BoundParameters - } + $scriptBlock = { + param ($Command, $ArgumentList, $BoundParameters) + if($myinvocation.ExpectingInput) { + $input | & $Command @ArgumentList @BoundParameters + } + else { + & $Command @ArgumentList @BoundParameters + } + } - $state = if ($CallerSessionState) { $CallerSessionState } else { $mock.SessionState } + $state = if ($CallerSessionState) { $CallerSessionState } else { $mock.SessionState } - Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $state + Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $state + $mockWithPipeline = $false + if($myinvocation.ExpectingInput) { + $mockWithPipeline = $true + } - & $scriptBlock -Command $mock.OriginalCommand -ArgumentList $ArgumentList -BoundParameters $BoundParameters - } - elseif ($mock = $mockTable["||$CommandName"]) - { - # This situation can happen if the test script is dot-sourced in the global scope. Under these conditions, - # a module can wind up executing Invoke-Mock when that was not the intent of the test. Try to recover from - # this by executing the original command. + if($mockWithPipeline){ + $currentErrorPreference = $ErrorActionPreference + $ErrorActionPreference = "Stop" + try { + $pipelineInput | & $scriptBlock -Command $mock.OriginalCommand -ArgumentList $ArgumentList -BoundParameters $FilteredParameters + } + catch [System.Management.Automation.ParameterBindingException] { + # There was an error in our parameter stripping + # Fall back to original non pipeline call + $mockWithPipeline = $false + } + catch { + if($_.GetType() -eq [System.Management.Automation.ErrorRecord]){ + write-error $_ + } + else{ + throw $_ + } + } + finally { + $ErrorActionPreference = $currentErrorPreference + } + } - $scriptBlock = { - param ($Command, $ArgumentList, $BoundParameters) - & $Command @ArgumentList @BoundParameters + if(!$mockWithPipeline){ + & $scriptBlock -Command $mock.OriginalCommand -ArgumentList $ArgumentList -BoundParameters $BoundParameters + } } + elseif ($mock = $mockTable["||$CommandName"]) + { + # This situation can happen if the test script is dot-sourced in the global scope. Under these conditions, + # a module can wind up executing Invoke-Mock when that was not the intent of the test. Try to recover from + # this by executing the original command. - $state = if ($CallerSessionState) { $CallerSessionState } else { $mock.SessionState } + $scriptBlock = { + param ($Command, $ArgumentList, $BoundParameters) + & $Command @ArgumentList @BoundParameters + } - Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $state + $state = if ($CallerSessionState) { $CallerSessionState } else { $mock.SessionState } - & $scriptBlock -Command $mock.OriginalCommand -ArgumentList $ArgumentList -BoundParameters $BoundParameters - } - else - { - # If this ever happens, it's a bug in Pester. The scriptBlock that calls Invoke-Mock should be removed at the same time as the entry in the mock table. - throw "Internal error detected: Mock for '$CommandName' in module '$ModuleName' was called, but does not exist in the mock table." + Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $state + + & $scriptBlock -Command $mock.OriginalCommand -ArgumentList $ArgumentList -BoundParameters $BoundParameters + } + else + { + # If this ever happens, it's a bug in Pester. The scriptBlock that calls Invoke-Mock should be removed at the same time as the entry in the mock table. + throw "Internal error detected: Mock for '$CommandName' in module '$ModuleName' was called, but does not exist in the mock table." + } } } diff --git a/Pester.psd1 b/Pester.psd1 index f42a1a74a..be2e33ba2 100644 --- a/Pester.psd1 +++ b/Pester.psd1 @@ -45,7 +45,8 @@ FunctionsToExport = @( 'BeforeAll', 'AfterAll' 'Get-MockDynamicParameters', - 'Set-DynamicParameterVariables' + 'Set-DynamicParameterVariables', + 'Select-NonPipelinedBoundParameters' ) # # Cmdlets to export from this module diff --git a/Pester.psm1 b/Pester.psm1 index f7acf62c5..61401a52b 100644 --- a/Pester.psm1 +++ b/Pester.psm1 @@ -379,6 +379,6 @@ if ((Test-Path -Path Variable:\psise) -and ($null -ne $psISE) -and ($PSVersionTa } Export-ModuleMember Describe, Context, It, In, Mock, Assert-VerifiableMocks, Assert-MockCalled -Export-ModuleMember New-Fixture, Get-TestDriveItem, Should, Invoke-Pester, Setup, InModuleScope, Invoke-Mock +Export-ModuleMember New-Fixture, Get-TestDriveItem, Should, Invoke-Pester, Setup, InModuleScope, Invoke-Mock, Select-NonPipelinedBoundParameters Export-ModuleMember BeforeEach, AfterEach, BeforeAll, AfterAll Export-ModuleMember Get-MockDynamicParameters, Set-DynamicParameterVariables