Add experimental global mocks#2850
Draft
nohwnd wants to merge 4 commits into
Draft
Conversation
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>
Member
Author
|
wrote to wrong pr. |
Collaborator
|
Wrong PR? |
Member
Author
|
yup |
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. |
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>
Member
Author
|
So with making |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 likeInvoke-WebRequestis 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.Globalconfiguration optionTurn global mocks on for the whole run with the experimental option:
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:
To make sure a command never runs, mock it to throw. Combine that with
-ParameterFilterto block only the calls you care about and let the rest fall through to the real command:The mock is removed when the test or block that defined it ends, like any other mock.
With the option on,
-ModuleNameno 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.PreCommandLookupActionhook. 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 capturedCommandInfoinvoked with the call operator, which does not go through command lookup. So even mocking a command Pester uses internally (for exampleGet-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:
Invoke-Pesterrun gets a unique id that is baked into each mock it creates. A mock only applies while its own run is executing.Ctrl+Cduring 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
-ModuleNamedoesWithout the option, a mock is module-scoped:
Mock -ModuleName A Cmdaffects only calls made from moduleA. This is the default, and it gives you three things a global mock does not:Aare affected; the same-named command in moduleB, or in the test script, is untouched. A global mock reaches all of them.Get-Datacan be mocked independently with-ModuleName. One globalGet-Datamock catches every copy.Should -Invoke -ModuleName Acounts onlyA'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