From 9a3ebb06178ab018ede8e06ec271e1ff3e16946a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Fri, 31 Jul 2026 21:33:37 +0200 Subject: [PATCH] Add -End switch to Mock for pipeline aggregation (#2154) Mocking a command that consumes the pipeline and returns once from its end block did not work. The mock runs MockWith once per piped item, so a command that returns a single object returned one object per input instead, and Should -Invoke counted one call per item. Add -End to Mock. With it the process block only collects the pipeline, and MockWith runs once from the end block over all the input. The collected input is piped into MockWith, so it is available as $input and MockWith can have its own begin/process/end blocks. Should -Invoke counts one call per pipeline, and the parameter filter sees the whole collected input bound to the pipeline parameter, so you can assert on all of it at once. All mocks for one command must be the same type. Mixing an -End mock and a per-call mock for the same command would put two different granularities in the call history and Should -Invoke could not count it, so it is rejected at definition time. This keeps the change additive, per-call mocks behave exactly as before. --- src/functions/Mock.ps1 | 122 ++++++++++++++++- src/functions/Pester.SessionState.Mock.ps1 | 50 ++++++- tst/functions/Mock.EndBlock.Tests.ps1 | 144 +++++++++++++++++++++ 3 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 tst/functions/Mock.EndBlock.Tests.ps1 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 + } +}