Skip to content

Add -End switch to Mock for pipeline aggregation - #2947

Draft
nohwnd wants to merge 1 commit into
mainfrom
nohwnd-mock-end-block-2154
Draft

Add -End switch to Mock for pipeline aggregation#2947
nohwnd wants to merge 1 commit into
mainfrom
nohwnd-mock-end-block-2154

Conversation

@nohwnd

@nohwnd nohwnd commented Jul 31, 2026

Copy link
Copy Markdown
Member

Fix #2154

Mocking a command that takes pipeline input and returns once from its end block never
worked. MockWith runs once per piped item, so a command that aggregates the pipeline
and returns a single object returns one object per input instead, and Should -Invoke
counts one call per item.

function outer { 1..3 | inner }
function inner {
    param ([Parameter(ValueFromPipeline)] $InputObject)
    "RETURN"   # implicit end block, runs once
}

Mock inner { "FOO" }
@(outer).Count   # 3, should be 1

The change

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.

Mock inner { "FOO" } -End
@(outer).Count   # 1

The collected input is piped into MockWith, so it is there as $input and MockWith can
have its own begin/process/end blocks, just like the real command.

Mock Save-State { "saved $(@($input).Count) items" } -End
1..3 | Save-State   # "saved 3 items"

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.

Mock inner { "FOO" } -End
$null = outer
Should -Invoke inner -Times 1 -Exactly
Should -Invoke inner -Times 1 -Exactly -ParameterFilter { $InputObject -contains 2 }

Design

The idea: one mock type per command.

  • A per-call mock (today) runs MockWith per item and records one call per item.
  • An -End mock collects the pipeline and records one call per pipeline.

All mocks for a single command must be the same type. Mixing the two would put two different
granularities in the call history and Should -Invoke could not count it coherently, so it is
rejected at definition time. That is what keeps Should -Invoke a plain counter, it does not
have to know about the two types, it just counts the history entries. Per-call mocks behave
exactly as before, -End is purely additive.

Because the type carries the granularity, the filter naturally sees the aggregate for an -End
mock, so there is no separate -EndParameterFilter.

Verification

262 existing Mock tests still pass, plus 16 new ones in Mock.EndBlock.Tests.ps1 (output shape,
per-pipeline counting, filter over the aggregate, -Verifiable, module scope, the homogeneity
guard). Built inline and ran on PS 7.5.

Open questions, @fflaten LMK before I polish

  • Name. I went with -End because that is what we said, but it reads a bit odd as a bare
    switch (Mock inner { } -End). -Aggregate / -PipelineEnd / -OnEnd are alternatives.
  • Homogeneity is checked in the current scope only. A parent-scope -End mock and a
    child-scope per-call mock for the same command would slip through the definition-time check.
    Do we care, or add a resolution-time guard too?
  • Native commands. Piping to a mocked application (e.g. pwsh, see Can't mock a function that takes pipeline input #2154 powercode comment)
    has no ValueFromPipeline parameter, so nothing binds by name for the filter. The input is
    still collected and piped into MockWith, but you cannot assert on it by parameter name.
    Probably fine for v1, but worth calling out.
  • Should we fold the tests into Mock.Tests.ps1 instead of a separate file?

🤖

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.
Comment thread src/functions/Mock.ps1
$Hook,
[string[]]$RemoveParameterType,
[string[]]$RemoveParameterValidation
[string[]]$RemoveParameterValidation,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can't mock a function that takes pipeline input

2 participants