Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 118 additions & 4 deletions src/functions/Mock.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
[Parameter(Mandatory)]
$Hook,
[string[]]$RemoveParameterType,
[string[]]$RemoveParameterValidation
[string[]]$RemoveParameterValidation,

Check warning

Code scanning / PSScriptAnalyzer

The parameter 'RemoveParameterValidation' has been declared but not used. Warning

The parameter 'RemoveParameterValidation' has been declared but not used.
[Switch] $Aggregate
)

[PSCustomObject] @{
Expand All @@ -26,6 +27,9 @@
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
Expand Down Expand Up @@ -948,6 +952,32 @@
}
}

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 (
Expand Down Expand Up @@ -1006,6 +1036,21 @@
}

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
Expand All @@ -1017,7 +1062,7 @@

if ($null -ne $behavior) {
$call = @{
BoundParams = $BoundParameters

Check notice

Code scanning / PSScriptAnalyzer

The built-in *-Object-cmdlets are slow compared to alternatives in .NET. To fix a violation of this rule, consider using an alternative like foreach/for-keyword etc.`. Note

The built-in *-Object-cmdlets are slow compared to alternatives in .NET. To fix a violation of this rule, consider using an alternative like foreach/for-keyword etc.`.
Args = $ArgumentList
Hook = $Hook
Behavior = $behavior
Expand Down Expand Up @@ -1050,6 +1095,58 @@
}

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."
Expand Down Expand Up @@ -1140,7 +1237,11 @@
$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
Expand Down Expand Up @@ -1173,7 +1274,11 @@

${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.
Expand All @@ -1192,7 +1297,14 @@
}) -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) {
Expand All @@ -1217,6 +1329,8 @@
}
}
'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
Expand Down
50 changes: 47 additions & 3 deletions src/functions/Pester.SessionState.Mock.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"} }

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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."
Expand Down
Loading
Loading