Skip to content

Add experimental global mocks#2850

Draft
nohwnd wants to merge 4 commits into
mainfrom
nohwnd-global-mocks
Draft

Add experimental global mocks#2850
nohwnd wants to merge 4 commits into
mainfrom
nohwnd-global-mocks

Conversation

@nohwnd

@nohwnd nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member

A normal mock only applies to calls from the scope where it is defined, or from the module you pass with -ModuleName. Code in another module looks the command up in its own session state and never sees the mock. So to be sure a command like Invoke-WebRequest is never called from any code under test, you have to know every module that might call it and mock it in each one.

Global mocks apply to calls of the mocked command from any module or script in the runspace, so you don't have to. This is experimental.

Mock.Global configuration option

Turn global mocks on for the whole run with the experimental option:

$config = New-PesterConfiguration
$config.Mock.Global = $true

With it on you write your mocks exactly as you do today and they simply reach the command wherever it is called. One mock covers every module, so you no longer have to name each calling module:

Mock Invoke-WebRequest { '<html />' }
Get-Data   # a function in another module that calls Invoke-WebRequest
Should -Invoke Invoke-WebRequest -Times 1

To make sure a command never runs, mock it to throw. Combine that with -ParameterFilter to block only the calls you care about and let the rest fall through to the real command:

# block deleting anything outside TestDrive, from any code under test
Mock Remove-Item { throw 'blocked' } -ParameterFilter { $Path -notlike "$TestDrive*" }

The mock is removed when the test or block that defined it ends, like any other mock.

With the option on, -ModuleName no longer scopes a mock. It is used only as a hint to resolve the command, so a module-private command can still be found and mocked. You can point it to any place where the command is resolvable - that is to the module where it is defined, or to the module where the function is exported to. If your current mocks use -ModuleName, you should not need to change them to use the new .Global option.

How it works

A global mock is a normal Pester mock plus a registration in the runspace-wide InvokeCommand.PreCommandLookupAction hook. That hook fires on every parser-driven command lookup, in any module, so the lookup is redirected to the mock's bootstrap function no matter where the call comes from. The hook is installed while at least one global mock exists and removed once none remain.

Pester's own internals are not affected. Pester calls its internal commands through $SafeCommands, a captured CommandInfo invoked with the call operator, which does not go through command lookup. So even mocking a command Pester uses internally (for example Get-Command) does not break Pester itself.

Isolation between runs

Because the hook and its bootstrap alias are runspace-wide, a global mock is tied to the run that created it so it can't leak elsewhere:

  • Each run has its own identity. Every Invoke-Pester run gets a unique id that is baked into each mock it creates. A mock only applies while its own run is executing.
  • Nested Pester-in-Pester runs are fully isolated. If an outer run's global mock leaks into a nested run through the outer script's scope, the nested run sees the id mismatch and calls the real command instead of applying the leaked mock. The nested run can mock the same command independently, and the outer run's global mocks are snapshotted and restored around the nested run so they are never clobbered.
  • Interrupted runs recover. The hook is runspace state that outlives a single test, so a Ctrl+C during a global mock could leave it armed. A fresh top-level run resets the hook before it starts, so it always begins with a clean slate.

The configuration option is experimental and may change based on feedback.

What -ModuleName does

Without the option, a mock is module-scoped: Mock -ModuleName A Cmd affects only calls made from module A. This is the default, and it gives you three things a global mock does not:

  • Isolation to a single module. Only calls from A are affected; the same-named command in module B, or in the test script, is untouched. A global mock reaches all of them.
  • Disambiguating same-named commands. Two modules — or two nested copies — that each define Get-Data can be mocked independently with -ModuleName. One global Get-Data mock catches every copy.
  • Module-scoped call history and messages. Should -Invoke -ModuleName A counts only A's calls, and the "verifiable not called" message names the module. A global mock records once with no module, so both the per-module count and that message qualifier go away.

Fix #2705

A normal mock only applies to calls made from the scope where it is
defined, or from the module passed with -ModuleName. Code in another
module resolves the command in its own session state and never sees the
mock, which is why -ModuleName exists. That makes it hard to guarantee a
command is never called, for example Invoke-WebRequest from any code
under test.

This adds global mocks, which apply to calls of the mocked command from
any module or script in the runspace:

- Mock -Global defines a single global mock, -ModuleName is ignored. It
  is removed on teardown like any other mock.
- Mock -Throw defines a global mock that throws when the command is
  called, so the real command cannot run from anywhere. The call is
  still recorded, so Should -Invoke works. Combine it with
  -ParameterFilter to block only some calls and let the rest fall
  through to the original command.
- The experimental Mock.Global configuration option makes every mock
  global without passing -Global.

Global mocks work by pointing the runspace-wide
InvokeCommand.PreCommandLookupAction hook at the mock's bootstrap
function, so the lookup is redirected no matter which module the call
comes from. Pester dispatches its own internal commands through
$SafeCommands (a captured CommandInfo invoked with the call operator),
which does not go through command lookup, so Pester internals are not
affected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nohwnd nohwnd added this to the 6.1.0 milestone Jul 9, 2026
@nohwnd

nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

wrote to wrong pr.

@nohwnd nohwnd closed this Jul 9, 2026
@fflaten

fflaten commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Wrong PR?

@nohwnd

nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

yup

@nohwnd nohwnd reopened this Jul 9, 2026
@nohwnd

nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@fflaten tested on pester itself found few issues, like us totally ignoring the moduleName, and because of that not being able to resolve the function when it is module private.

@nohwnd

nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

so don't waste your time reviewing yet. I will anyway publish it for people to try under experimental a flag. Makes it easiest to find edge cases.

A global mock previously blanked -ModuleName before resolving the command,
so module-private commands could not be found ("Could not find Command"),
Should -Invoke -ModuleName found no calls, and cmdlet dynamic parameters
(e.g. Get-ChildItem -Hidden) were lost. Running Pester's own suite with
Mock.Global enabled broke 27 tests because of this over-crippling.

ModuleName is still ignored as the mock destination (a global mock is
effective across the whole runspace via the engine hook), but it is now
used as a hint to resolve the command:

- Resolve-Command -Global resolves from the caller/script scope first
  (which also reuses an existing global mock's hook on re-mock), and falls
  back to the module named by the hint to find a module-private command.
  The mock is still installed in the caller scope with no target module.
- Should -Invoke normalizes ModuleName to empty when a global mock exists
  for the command, so it resolves the script-scope bootstrap and matches
  the call history recorded without a module.
- Dynamic parameters: the engine hook shadows the command name in every
  scope, so resolving the original cmdlet to discover its dynamic
  parameters was redirected back to the mock. GlobalMockHook now supports
  suppressing the redirect for a single name while that resolution runs.

This takes the suite from 27 failures down to 5 with Mock.Global on, and
the remaining 5 are intentional global semantics (a mock reaching every
module, no per-module disambiguation, no module in the call history), not
bugs. The non-global suite is unchanged (0 failures).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nohwnd

nohwnd commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

So with making -ModuleName a hint where the function can be found (e.g. where it is exported to or where it comes from), there are no failures in pester own test suite - when we run it with the new mock.global = true option. Apart from 5 that test that the mock isolation works - the exact opposite of what we are trying to achieve here.

Global mocks are now opt-in for a whole run through the experimental
Mock.Global configuration option only. The per-mock -Global and -Throw
switches are removed: the bet is that global (no per-module isolation) is
the behavior people want by default, so it is a run-level setting rather
than something you reach for on individual mocks.

- Mock no longer has -Global or -Throw parameters.
- Blocking a command everywhere is done with an ordinary throwing mock
  (Mock Cmd { throw ... }) while Mock.Global is on, optionally scoped with
  -ParameterFilter, instead of a dedicated -Throw switch.
- Updated the Mock.Global config description and about help so they no
  longer say ModuleName is "ignored": it is used as a resolve hint (to find
  a module-private command), it just no longer scopes where the mock
  applies.
- Rewrote Mock.Global.Tests.ps1 to exercise the behavior through a nested
  Pester run with Mock.Global = $true (reaches other modules, records
  calls, ModuleName as hint, throwing mock blocks everywhere, parameter
  filter fall-through, dynamic parameters, SafeCommands isolation,
  cleanup).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nohwnd nohwnd marked this pull request as draft July 9, 2026 21:34
Give each Invoke-Pester run a unique id and bake it into every mock's
bootstrap. A global mock installs its bootstrap alias in the run that
created it; when that alias leaks into a nested Invoke-Pester run, the
dispatcher now compares the mock's owner id with the currently executing
run and defers to the original command when they differ, so a leaked
global mock has no effect outside its own run (full isolation).

Also make the runspace-wide global mock hook lifecycle robust:
- Reset the hook at the start of a top-level run so an interrupted
  previous run (e.g. Ctrl+C during a global mock) never leaves it armed.
- Snapshot and clear the hook around a nested run, then restore it in a
  finally, so a nested run gets a clean slate and can never clobber or
  detach the outer run's global mocks.
- When a nested run re-mocks the same command, unwrap a leaked bootstrap
  to the original command so the nested run builds and owns its own hook
  instead of reusing the outer run's.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

Idea: can we inject mock into the original module, even if binary?

2 participants