diff --git a/src/functions/Mock.ps1 b/src/functions/Mock.ps1 index 95d129cd2..53723caf1 100644 --- a/src/functions/Mock.ps1 +++ b/src/functions/Mock.ps1 @@ -16,7 +16,8 @@ function New-MockBehavior { [Parameter(Mandatory)] $Hook, [string[]]$RemoveParameterType, - [string[]]$RemoveParameterValidation + [string[]]$RemoveParameterValidation, + [Switch] $Aggregate ) [PSCustomObject] @{ @@ -26,6 +27,9 @@ function New-MockBehavior { IsDefault = $null -eq $ParameterFilter IsInModule = -not [string]::IsNullOrEmpty($ContextInfo.TargetModule) Verifiable = $Verifiable + # When set the whole pipeline is collected and MockWith runs once from the end block over + # all the input, instead of once per piped item. See the -End switch on Mock. (#2154) + Aggregate = [bool]$Aggregate Executed = $false ScriptBlock = $MockWith Hook = $Hook @@ -948,6 +952,32 @@ function Resolve-Command { } } +function Get-PipelineParameterName { + # Return the names of the parameters that take pipeline input (ValueFromPipeline or + # ValueFromPipelineByPropertyName). Used by -End (aggregate) mocks to bind the whole collected + # pipeline to the pipeline parameter, so a parameter filter can assert on all the input. Native + # applications have no metadata and no such parameters, so this returns nothing for them. (#2154) + param ($Metadata) + + if ($null -eq $Metadata -or $null -eq $Metadata.Parameters) { + return + } + + foreach ($parameter in $Metadata.Parameters.Values) { + $takesPipeline = $false + foreach ($parameterSet in $parameter.ParameterSets.Values) { + if ($parameterSet.ValueFromPipeline -or $parameterSet.ValueFromPipelineByPropertyName) { + $takesPipeline = $true + break + } + } + + if ($takesPipeline) { + $parameter.Name + } + } +} + function Invoke-MockInternal { [CmdletBinding()] param ( @@ -1006,6 +1036,21 @@ function Invoke-MockInternal { } Process { + # -End (aggregate) mock: do not match or execute per piped item. Collect the whole pipeline + # and defer a single behavior match and a single execution to the end block. Behaviors are + # homogeneous per command (guarded in Mock), so the first one tells us the type. We stash the + # resolved behaviors and call history so the end block can reuse them without resolving again. (#2154) + $firstBehavior = @($Behaviors)[0] + if ($null -ne $firstBehavior -and $firstBehavior.Aggregate) { + $MockCallState['Aggregate'] = $true + $MockCallState['AggregateBehaviors'] = $Behaviors + $MockCallState['AggregateCallHistory'] = $CallHistory + if ($null -ne $InputObject) { + $null = $MockCallState['InputObjects'].AddRange(@($InputObject)) + } + return + } + # the incoming caller session state is the place from where # the mock hook is invoked, this does not have to be the same as # the test "caller scope" that we saved earlier, we won't use the @@ -1050,6 +1095,58 @@ function Invoke-MockInternal { } End { + # -End (aggregate) mock: the whole pipeline was collected in the process block. Now match a + # single behavior against the collected input and run it once, piping the input in so MockWith + # sees it as $input (and its own process/end blocks work). One call is recorded per pipeline. (#2154) + if ($MockCallState['Aggregate']) { + $SessionState = if ($CallerSessionState) { $CallerSessionState } else { $Hook.SessionState } + $collected = $MockCallState['InputObjects'] + + # Build the bound parameters the filter and the call history see: the parameters bound + # up front (everything except the pipeline) plus the pipeline parameter(s) set to the whole + # collection, so a filter can assert on all the piped input at once. + $aggregateBound = if ($null -ne $MockCallState['BeginBoundParameters']) { $MockCallState['BeginBoundParameters'].Clone() } else { @{} } + foreach ($pipelineParameterName in (Get-PipelineParameterName -Metadata $Hook.Metadata)) { + if (-not $aggregateBound.ContainsKey($pipelineParameterName)) { + $aggregateBound[$pipelineParameterName] = $collected.ToArray() + } + } + + $behavior, $failedFilterInvocations = FindMatchingBehavior -Behaviors @($MockCallState['AggregateBehaviors']) -BoundParameters $aggregateBound -ArgumentList @($MockCallState['BeginArgumentList']) -SessionState $SessionState -Hook $Hook -DynamicParamAliases $MockCallState['DynamicParamAliases'] + + if ($null -ne $behavior) { + $call = @{ + BoundParams = $aggregateBound + Args = $MockCallState['BeginArgumentList'] + Hook = $Hook + Behavior = $behavior + DynamicParamAliases = $MockCallState['DynamicParamAliases'] + } + $key = "$($behavior.ModuleName)||$($behavior.CommandName)" + $aggregateCallHistory = $MockCallState['AggregateCallHistory'] + if (-not $aggregateCallHistory.ContainsKey($key)) { + $aggregateCallHistory.Add($key, [Collections.Generic.List[object]]@($call)) + } + else { + $aggregateCallHistory[$key].Add($call) + } + + ExecuteBehavior -Behavior $behavior ` + -Hook $Hook ` + -BoundParameters $MockCallState['BeginBoundParameters'] ` + -ArgumentList @($MockCallState['BeginArgumentList']) ` + -InputObject $collected ` + -PipeInput + + return + } + else { + $failedFilterInvocations = @($failedFilterInvocations) + $filterList = ($failedFilterInvocations | & $SafeCommands['ForEach-Object'] { " $_" }) -join [System.Environment]::NewLine + throw "No mock for command '$($Hook.CommandName)' matched the call: none of the parameter filters matched, and there is no default mock to fall back to. Add a default mock (e.g. ``Mock $($Hook.CommandName) { ... } -End``) or adjust an existing -ParameterFilter.$([System.Environment]::NewLine)$([System.Environment]::NewLine)The following parameter filters were evaluated and did not match:$([System.Environment]::NewLine)$filterList" + } + } + if ($MockCallState['MatchedNoBehavior']) { if ($PesterPreference.Debug.WriteDebugMessages.Value) { Write-PesterDebugMessage -Scope Mock "The mock did not match any filtered behavior, and there was no default behavior. Failing." @@ -1140,7 +1237,11 @@ function ExecuteBehavior { $Behavior, $Hook, [hashtable] $BoundParameters = @{ }, - [object[]] $ArgumentList = @() + [object[]] $ArgumentList = @(), + # -End (aggregate) mocks pass the collected pipeline here and set -PipeInput so MockWith runs + # once with the whole pipeline as its $input, reproducing the real command's end-block behaviour. (#2154) + [object] $InputObject, + [switch] $PipeInput ) $ModuleName = $Behavior.ModuleName @@ -1173,7 +1274,11 @@ function ExecuteBehavior { ${M o d u l e N a m e}, - ${Set Dynamic Parameter Variable} + ${Set Dynamic Parameter Variable}, + + ${I n p u t O b j e c t}, + + [switch] ${Pipe Input} ) # This script block exists to hold variables without polluting the test script's current scope. @@ -1192,7 +1297,14 @@ function ExecuteBehavior { }) -ScriptBlock ${Script Block} # define this in the current scope to be used instead of $PSBoundParameter if needed $PesterBoundParameters = if ($null -ne $___BoundParameters___) { $___BoundParameters___ } else { @{} } - & ${Script Block} @___BoundParameters___ @___ArgumentList___ + if (${Pipe Input}) { + # -End (aggregate) mock: pipe the whole collected input into MockWith so it runs once and + # sees the pipeline as $input, the same way the real command's end block would. (#2154) + ${I n p u t O b j e c t} | & ${Script Block} @___BoundParameters___ @___ArgumentList___ + } + else { + & ${Script Block} @___BoundParameters___ @___ArgumentList___ + } } if ($null -eq $Hook) { @@ -1217,6 +1329,8 @@ function ExecuteBehavior { } } 'Set Dynamic Parameter Variable' = $SafeCommands['Set-DynamicParameterVariable'] + 'I n p u t O b j e c t' = $InputObject + 'Pipe Input' = [switch]$PipeInput } # the real scriptblock is passed to the other one, we are interested in the mock, not the wrapper, so I pass $block.ScriptBlock, and not $scriptBlock diff --git a/src/functions/Pester.SessionState.Mock.ps1 b/src/functions/Pester.SessionState.Mock.ps1 index b3a1c8a24..fa8f361a2 100644 --- a/src/functions/Pester.SessionState.Mock.ps1 +++ b/src/functions/Pester.SessionState.Mock.ps1 @@ -129,6 +129,18 @@ function Mock { validation requirements, and allows functions that are strict about their parameter validation to be mocked more easily. + .PARAMETER End + Makes the mock aggregate the whole pipeline and run MockWith once from the end + block, instead of once per piped item. Use it when the mocked command collects + pipeline input and produces its result once (a command with a process block that + accumulates and an end block that returns). Inside MockWith the collected input is + available as $input, and MockWith may define its own begin/process/end blocks. + Should -Invoke then counts one call per pipeline, and the parameter filter sees the + whole collected input bound to the pipeline parameter. + + All mocks for a single command must be the same type. You cannot mix an -End mock and + a per-call mock for the same command in the same scope. + .EXAMPLE Mock Get-ChildItem { return @{FullName = "A_File.TXT"} } @@ -248,6 +260,20 @@ function Mock { Forward the caller's arguments with @PesterBoundParameters, a hashtable of the parameters that were bound on the mocked command. + .EXAMPLE + ```powershell + # Save-State collects the pipeline and writes once from its end block. + Mock Save-State { "saved $(@($input).Count) items" } -End + + 1..3 | Save-State | Should -Be "saved 3 items" + Should -Invoke Save-State -Times 1 -Exactly + ``` + + Without -End the mock would run once per piped item and return three objects. With + -End the whole pipeline is collected and MockWith runs once, matching how a command + with an aggregating end block behaves, and Should -Invoke counts one call for the + whole pipeline. + .LINK https://pester.dev/docs/commands/Mock @@ -262,7 +288,8 @@ function Mock { [ScriptBlock]$ParameterFilter, [string]$ModuleName, [string[]]$RemoveParameterType, - [string[]]$RemoveParameterValidation + [string[]]$RemoveParameterValidation, + [switch]$End ) if (Is-Discovery) { # this is to allow mocks in between Describe and It which is discouraged but common @@ -330,9 +357,19 @@ function Mock { $mockData.Behaviors[$contextInfo.Command.Name] = $behaviors } - $behavior = New-MockBehavior -ContextInfo $contextInfo -MockWith $MockWith -Verifiable:$Verifiable -ParameterFilter $ParameterFilter -Hook $hook + # All behaviors for one command must be the same type. A per-call mock records one call per piped + # item, an -End mock records one call per pipeline. Mixing them in the same scope would make the + # call history hold two different granularities and Should -Invoke could not count it coherently, + # so we reject it at definition time. (#2154) + if ($behaviors.Count -gt 0 -and [bool]$behaviors[0].Aggregate -ne [bool]$End) { + $existingType = if ($behaviors[0].Aggregate) { 'an -End (aggregate)' } else { 'a per-call' } + $newType = if ($End) { 'an -End (aggregate)' } else { 'a per-call' } + throw "Cannot add $newType mock for '$($contextInfo.Command.Name)' because $existingType mock is already defined for it in this scope. All mocks for a command must be the same type. Remove -End from the other mock, or add it here." + } + + $behavior = New-MockBehavior -ContextInfo $contextInfo -MockWith $MockWith -Verifiable:$Verifiable -ParameterFilter $ParameterFilter -Hook $hook -Aggregate:$End if ($PesterPreference.Debug.WriteDebugMessages.Value) { - Write-PesterDebugMessage -Scope Mock -Message "Adding a new $(if ($behavior.IsDefault) {"default"} else {"parametrized"}) behavior to $(if ($behavior.ModuleName) { "$($behavior.ModuleName) - "})$($behavior.CommandName)." + Write-PesterDebugMessage -Scope Mock -Message "Adding a new $(if ($behavior.IsDefault) {"default"} else {"parametrized"})$(if ($behavior.Aggregate) {" aggregate (end)"}) behavior to $(if ($behavior.ModuleName) { "$($behavior.ModuleName) - "})$($behavior.CommandName)." } $behaviors.Add($behavior) } @@ -1094,6 +1131,13 @@ function Invoke-Mock { } if ('End' -eq $FromBlock) { + # -End (aggregate) mock: the process block collected the pipeline and stashed the resolved + # behaviors and call history. Forward to the core so the end block can match once and run once + # over the whole input. (#2154) + if ($MockCallState['Aggregate']) { + Invoke-MockInternal @PSBoundParameters -Behaviors $MockCallState['AggregateBehaviors'] -CallHistory $MockCallState['AggregateCallHistory'] + return + } if (-not $MockCallState.MatchedNoBehavior) { if ($PesterPreference.Debug.WriteDebugMessages.Value) { Write-PesterDebugMessage -Scope MockCore "Mock for $CommandName was invoked from block $FromBlock, and matched at least one behavior, returning." diff --git a/tst/functions/Mock.EndBlock.Tests.ps1 b/tst/functions/Mock.EndBlock.Tests.ps1 new file mode 100644 index 000000000..9a8172bf7 --- /dev/null +++ b/tst/functions/Mock.EndBlock.Tests.ps1 @@ -0,0 +1,144 @@ +Set-StrictMode -Version Latest + +BeforeAll { + $PSDefaultParameterValues = @{ 'Should:ErrorAction' = 'Stop' } + + function outer { 1..3 | inner } + + function inner { + param ([Parameter(ValueFromPipeline)] $InputObject) + # implicit end block, runs once for the whole pipeline + "RETURN" + } + + function Save-Set { + param ( + [Parameter(ValueFromPipeline)] $InputObject, + [Parameter(Position = 0)] $Path + ) + begin { $items = [System.Collections.Generic.List[object]]@() } + process { $items.Add($InputObject) } + end { "wrote $($items.Count) items to $Path" } + } +} + +Describe 'Mock -End (aggregate pipeline mock)' { + Context 'output shape' { + It 'runs MockWith once over the whole pipeline instead of once per item' { + Mock inner { 'FOO' } -End + @(outer).Count | Should -Be 1 + outer | Should -Be 'FOO' + } + + It 'matches the unmocked end-block output count' { + @(outer).Count | Should -Be 1 + Mock inner { 'RETURN' } -End + @(outer).Count | Should -Be 1 + } + + It 'exposes the whole pipeline to MockWith as $input' { + Mock inner { "got $(@($input).Count)" } -End + outer | Should -Be 'got 3' + } + + It 'lets MockWith define its own begin/process/end blocks' { + Mock Save-Set -End { + begin { $count = 0 } + process { $count++ } + end { "mock saw $count" } + } + (1..5 | Save-Set -Path 'x.txt') | Should -Be 'mock saw 5' + } + + It 'does not change the default per-item behaviour when -End is not used' { + Mock inner { 'FOO' } + @(outer).Count | Should -Be 3 + Should -Invoke inner -Times 3 -Exactly + } + } + + Context 'Should -Invoke counts one call per pipeline' { + It 'records a single call for a multi-item pipeline' { + Mock inner { 'FOO' } -End + $null = outer + Should -Invoke inner -Times 1 -Exactly + } + + It 'records one call per pipeline across several pipelines' { + Mock inner { 'FOO' } -End + $null = outer + $null = outer + Should -Invoke inner -Times 2 -Exactly + } + + It 'supports Should -Not -Invoke' { + Mock inner { 'FOO' } -End + Should -Not -Invoke inner + } + + It 'supports Should -InvokeVerifiable' { + Mock inner { 'FOO' } -End -Verifiable + $null = outer + Should -InvokeVerifiable + } + } + + Context 'ParameterFilter sees the whole pipeline' { + It 'binds the collected pipeline to the pipeline parameter' { + Mock inner { 'FOO' } -End -ParameterFilter { $InputObject.Count -eq 3 } + outer | Should -Be 'FOO' + Should -Invoke inner -Times 1 -Exactly -ParameterFilter { $InputObject -contains 2 } + } + + It 'can still assert on parameters bound outside the pipeline' { + Mock Save-Set { 'mocked' } -End + $null = 1..2 | Save-Set -Path 'y.txt' + Should -Invoke Save-Set -Times 1 -Exactly -ParameterFilter { $Path -eq 'y.txt' } + } + + It 'throws when no filter matches and there is no default' { + Mock inner { 'FOO' } -End -ParameterFilter { $InputObject -contains 99 } + { outer } | Should -Throw -ExpectedMessage "*No mock for command 'inner' matched*" + } + } + + Context 'calling convention' { + It 'works when the command is called with a direct argument, not a pipeline' { + Mock inner { 'FOO' } -End + inner -InputObject 5 | Should -Be 'FOO' + Should -Invoke inner -Times 1 -Exactly + } + } + + Context 'one mock type per command' { + It 'rejects adding an -End mock when a per-call mock already exists' { + Mock inner { 'FOO' } + { Mock inner { 'BAR' } -End } | Should -Throw -ExpectedMessage '*must be the same type*' + } + + It 'rejects adding a per-call mock when an -End mock already exists' { + Mock inner { 'FOO' } -End + { Mock inner { 'BAR' } } | Should -Throw -ExpectedMessage '*must be the same type*' + } + } +} + +Describe 'Mock -End inside a module' { + BeforeAll { + $null = New-Module -Name AggMod { + function Get-Thing { 1..3 } + function Use-Thing { Get-Thing | Consume } + function Consume { + param([Parameter(ValueFromPipeline)] $InputObject) + 'consumed' + } + } | Import-Module -PassThru -Force + } + AfterAll { Remove-Module AggMod -Force } + + It 'aggregates a pipeline consumed inside the module' { + Mock Consume { 'mocked' } -End -ModuleName AggMod + InModuleScope AggMod { Use-Thing } | Should -Be 'mocked' + Should -Invoke Consume -Times 1 -Exactly -ModuleName AggMod + } +}