-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathPSMock.psm1
594 lines (471 loc) · 17 KB
/
PSMock.psm1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
<#
PSMock - copyright(c) 2013 - Jon Wagner
See https://github.com/jonwagner/PSMock for licensing and other information.
Version: $version$
Changeset: $changeset$
#>
# set up the outermost mock context
$mockContext = @{ Mocks = @{} }
$paramsMatch = '^\s*param\s*\('
<#
.Synopsis
Returns a string containing a function that enables the Mock function.
.Description
Returns a string containing a function that enables the Mock function.
The string should be piped to Invoke-Expression to create the Mock function.
The Mock function automatically looks up a command name and calls Add-Mock.
Enable-Mock only needs to be called once per script.
.Example
Enable-Mock | iex
Mock MyFunction { "mocked" }
This enables mocking, then mocks MyFunction
.Link
Add-Mock
#>
function Enable-Mock {
@'
<#
.FORWARDHELPTARGETNAME Add-Mock
#>
function Mock {
param (
[Parameter(Mandatory=$true)] [string] $CommandName,
[scriptblock] $With = {},
[scriptblock] $When = {$true},
[string] $Name,
[switch] $OutputMock,
[switch] $OutputCase
)
$PSBoundParameters.Remove('CommandName') | Out-Null
# look up the original method for you
$original = Microsoft.PowerShell.Management\Get-Item function:$CommandName -ErrorAction SilentlyContinue
if (!$original) {
$original = Microsoft.PowerShell.Core\Get-Command -Name $CommandName -ErrorAction SilentlyContinue
}
Add-Mock -CommandName $CommandName -Original $original @PSBoundParameters
}
'@
}
<#
.FORWARDHELPTARGETNAME Enable-Mock
#>
function Mock {
throw "Mocks are not enabled. Call Enable-Mock | iex"
}
<#
.Synopsis
Adds a new mock.
Alias: Mock
.Description
Adds a new mock. When the command is executed, the mock's With block will be called instead of the
specified command. This allows you to override the implementation of existing functions and commandlets.
Generally you should call Enable-Mock|iex and then use the created Mock function to create mocks.
.Parameter CommandName
The name of the command to mock.
.Parameter Original
The original function to mock. This is required in order to automatically map the parameters for the With and When
clauses.
.Parameter With
The script block to execute when the mock is called.
The script block will have the same parameters as the command it is mocking.
If not specified, With is an empty script block and does nothing.
.Parameter When
An optional script block that determines when the mock should be executed.
The script block will have the same parameters as the command it is mocking.
It should return $true if the With block should be called, $false to allow another mock to execute.
If not specified, When returns $true and applied to all argument sets.
.Parameter Name
An optional name that can be applied to this mock case so that the case can be identified later.
See Get-Mock.
.Parameter OutputMock
When specified, the mock is output.
.Parameter OutputCase
When specified, the mock case is output.
.Notes
If there are no mocks applicable to the current call, the original function is executed.
You can prevent the original function from executing by adding an empty mock.
When mocking a function that does not exist, Add-Mock does not know the parameters
for the With and When script blocks. In this case, you must manually add the param clause
or access $args directly.
If more than one mock is added for a command, each addition is called a Mock Case.
Each case is tracked separately. The call counts and arguments are separate, and
each case can be removed separately. To give a case a name, use the -Name parameter
when creating the case.
.Example
Enable-Mock | iex
Mock Get-ChildItem { "no soup for you" }
This example overrides the Get-ChildItem commandlet to return "no soup for you" in all cases.
.Example
Enable-Mock | iex
Mock Get-ChildItem { "no $Path for you" } -when { $Path -eq 'soup' }
This example overrides the Get-ChildItem commandlet to return "no soup for you" when you ask for soup.
Note that the $Path parameters are available from within the script blocks.
.Example
Enable-Mock | iex
Mock Get-ChildItem { "no $Path for you" } -when { $Path -eq 'soup' } -name Soup
This example overrides the Get-ChildItem commandlet with two separate cases.
.Link
Get-Mock
.Link
MockContext
.Link
Remove-Mock
#>
function Add-Mock {
param (
[string] $CommandName,
[object] $Original,
[scriptblock] $With = {},
[scriptblock] $When = {$true},
[string] $Name,
[switch] $OutputMock,
[switch] $OutputCase
)
# default the name if it is not given
if (!$Name) {
$Name = $When.ToString()
if ($Name -eq '$true') {
$Name = 'default'
}
}
# build a new mock case
$case = @{
"Name" = $Name
"When" = $When
"With" = $With
"Calls" = @()
"Count" = 0
"IsDefault" = ($When.ToString() -eq '$true')
}
# see if there is an existing mock at the current level
$mock = $mockContext.Mocks[$CommandName]
# if there is already a mock, add this case to the list of cases
if ($mock) {
# add the new case to the list.
if ($case.IsDefault) {
# default cases go last in the list, but before any older cases
$cases = @($mock.Cases |? { !$_.IsDefault })
$cases += $case
$cases += @($mock.Cases |? { $_.IsDefault })
$mock.Cases = $cases
}
else {
# new non-default cases go in the front of the list
$mock.Cases = ,$case + $mock.Cases
}
if ($mock.Parameters) {
# we also have to inject the parameters into when and with so the developer doesn't need to
if ($case.When -notmatch $paramsMatch) {
$case.When = "{ $($mock.CmdletBinding) param ($($mock.Parameters)) $($case.When) }" | iex
}
if ($case.With -notmatch $paramsMatch) {
$case.With = "{ $($mock.CmdletBinding) param ($($mock.Parameters)) $($case.With) }" | iex
}
}
if ($OutputMock) { $mock }
if ($OutputCase) { $case }
return
}
# no mock yet, need to wire up the mock function
# create the mock
$mock = @{
"Cases" = @($case)
"Calls" = @()
"Count" = 0
"BaseMock" = (Get-Mock $CommandName)
"Original" = $Original
}
# you can't mock an alias, because we use aliases to mock
if ($Original.CommandType -eq 'Alias') {
throw "PSMock cannot mock alias $CommandName. Mock the target instead."
return
}
# if there isn't a base mock, then we need to initialize the mock
if (!$mock.BaseMock) {
# if there was an original command, figure out its parameters
if ($mock.Original) {
# get the parameters from the original command
$metadata=Microsoft.PowerShell.Utility\New-Object System.Management.Automation.CommandMetaData $mock.Original
@('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'ErrorVariable', 'WarningVariable', 'OutVariable', 'OutBuffer') |
% { $metaData.Parameters.Remove($_) | Out-Null }
$mock.CmdletBinding = [Management.Automation.ProxyCommand]::GetCmdletBindingAttribute($metadata)
$mock.Parameters = [Management.Automation.ProxyCommand]::GetParamBlock($metadata) -replace 'Mandatory=\$true','Mandatory=$false'
# we also have to inject the parameters into when and with so the developer doesn't need to
if ($case.When -notmatch $paramsMatch) {
$case.When = "{ $($mock.CmdletBinding) param ($($mock.Parameters)) $($case.When) }" | iex
}
if ($case.With -notmatch $paramsMatch) {
$case.With = "{ $($mock.CmdletBinding) param ($($mock.Parameters)) $($case.With) }" | iex
}
}
# we need to modify execution in the scope that called this module.
# it may not be the global scope, so
# create a global alias to the new mock function. aliases work in any scope.
Microsoft.PowerShell.Utility\Set-Alias $CommandName PSMock-$CommandName -Scope Global
# create a global function to implement the mock
Microsoft.PowerShell.Management\Set-Item function:\global:PSMock-$CommandName -value `
"$($mock.CmdletBinding) param ($($mock.Parameters)) Invoke-Mock @{ CommandName=`"$CommandName`"; BoundParameters=`$PSBoundParameters; Args=`$args; Input=`@(`$input) }"
}
# this mock is now official
$mockContext.Mocks[$CommandName] = $mock
if ($OutputMock) { $mock }
if ($OutputCase) { $case }
return
}
<#
.Synopsis
Gets the mock associated with a command.
.Description
Gets the mock associated with a command. Returns nothing if there are no mocks.
.Parameter CommandName
The name of the command to look up.
.Notes
If there are nested mock contexts, then returns the most relevant mock.
In a nested mock context, to get a base mock, use $mock.BaseMock.
See MockContext.
If more than one mock is added for a command, each addition is called a Mock Case.
Each case is tracked separately. The call counts and arguments are separate, and
each case can be removed separately. To give a case a name, use the -Name parameter
when creating the case.
.Example
Get-Mock MyFunction
This example gets the mock for MyFunction.
Name Value
---- -----
Cases {System.Collections.Hashtable}
Count 0
Calls {}
.Example
$case = Get-Mock MyFunction -Name "specialcase"
This example gets the specialcase case for MyFunction
.Link
Add-Mock
.Link
MockContext
.Link
Remove-Mock
#>
function Get-Mock {
param (
[Parameter(Mandatory=$true)] [string] $CommandName,
[string] $Case
)
# go through the stack of contexts
$context = $mockContext
while ($context) {
# if there is a mock, return it
$mock = $context.Mocks[$CommandName]
if ($mock) {
if ($Case) {
return $($mock.Cases |? { $_.Name -eq $Case })
}
else {
return $mock
}
}
# keep looking
$context = $context.Inner
}
}
<#
.Synopsis
Removes the mocks associated with a command.
.Description
Removes the mocks associated with a command for the current mock context.
If multiple mock cases are defined on for the command, all of them are removed unless the Name parameter is specified.
.Parameter CommandName
The name of the command to remove mocks.
.Parameter Name
The name of the mock case to remove. If not specified, then all cases are removed.
.Notes
If there are nested mock contexts, Remove-Mock will only remove the mocks defined in the current context.
If there are mocks defined in other contexts, those mocks will still apply.
See MockContext.
.Example
Remove-Mock MyFunction
This example removes the mock associated with MyFunction
.Link
Add-Mock
.Link
Get-Mock
.Link
MockContext
#>
function Remove-Mock {
param (
[Parameter(Mandatory=$true)] [string] $CommandName,
[string] $Name
)
# find the mock
$mock = $mockContext.Mocks[$CommandName]
if (!$mock) {
Write-Error "There is no mock for $CommandName"
return
}
# if a name was specified, remove that case
if ($Name) {
# remove the case
$mock.Cases = @($mock.Cases |? { $_.Name -ne $Name })
# if there are cases left, we can quit, otherwise continue and remove the mock
if ($mock.Cases.Count -gt 0) {
return
}
}
# remove the mock from the table
$mockContext.Mocks.Remove($CommandName)
# if there is no base mock to fall back on
if (!$mock.BaseMock) {
# remove the function and the alias
Microsoft.PowerShell.Management\Remove-Item function:\global:PSMock-$CommandName -ErrorAction SilentlyContinue
Microsoft.PowerShell.Management\Remove-Item function:PSMock-$CommandName -ErrorAction SilentlyContinue
Microsoft.PowerShell.Management\Remove-Item alias:$CommandName
}
}
<#
.Synopsis
Removes all mocks in the current mock context.
.Description
Removes all mocks in the current mock context.
.Notes
If there are nested mock contexts, Remove-Mock will only remove the mocks defined in the current context.
If there are mocks defined in other contexts, those mocks will still apply.
See MockContext.
.Example
Remove-Mock MyFunction
This example removes the mock associated with MyFunction
.Link
Add-Mock
.Link
MockContext
#>
function Clear-Mocks {
@($mockContext.Mocks.Keys) |% { Remove-Mock $_ }
}
<#
.Synopsis
Enters a new mock context that can be removed separately.
.Description
Enters a new mock context that can be removed separately.
New mocks that are created take precedence over existing mocks.
.Example
Enter-MockContext
This example begins a new mock context.
.Link
Exit-MockContext
.Link
MockContext
#>
function Enter-MockContext {
$script:mockContext = @{
Mocks = @{}
Inner = $mockContext
}
}
<#
.Synopsis
Exits the current mock context and clears all of the mocks defined in the context.
.Description
Exits the current mock context and clears all of the mocks defined in the context.
.Example
Exit-MockContext
This example ends the current mock context.
.Link
Enter-MockContext
.Link
MockContext
#>
function Exit-MockContext {
Clear-Mocks
if ($mockContext.Inner) {
$script:mockContext = $mockContext.Inner
}
}
<#
.Synopsis
Executes a script block from within a new mock context.
.Description
Executes a script block from within a new mock context.
Any mocks created within the context are automatically removed when the context is exited.
Mock contexts can be nested, and the mocks are unwound appropriately.
.Example
MockContext {
Mock MyFunction
}
# The mock for MyFunction is removed.
.Link
Enter-MockContext
#>
function MockContext {
param (
[scriptblock] $Script
)
try {
Enter-MockContext
& $Script
}
finally {
Exit-MockContext
}
}
<#
.Synopsis
Resolves and invokes a mock by name.
This is not intended to be called by your code.
.Description
Resolves the proper invocation of a mock by name and parameters.
If the mock does not exist, then nothing happens.
.Parameter Call
The function call to invoke. This should be a hashtable of CommandName, Args, and BoundParameters.
.Example
Invoke-Mock @{ 'CommandName'='Get-ChildItem' 'BoundParameters' = @{ 'Path'="z:\" } }
Invokes the mock installed for Get-ChildItem.
#>
function Invoke-Mock {
param (
[HashTable] $Call
)
# find the mock, there has to be one
$mock = Get-Mock $Call.CommandName
if (!$mock) {
throw "No mock is defined for $($Call.CommandName)"
}
$args = $Call.Args
$boundParameters = $Call.BoundParameters
while ($mock) {
# go through all of the cases and find the first match
for ($i = 0; $i -lt $mock.Cases.Length; $i++) {
$case = $mock.Cases[$i]
# check out the when clause to see if its a match
if (& $case.When @args @BoundParameters) {
# keep track of the number of times called
$case.Count++;
$case.Calls += $call
$mock.Count++;
$mock.Calls += $call
# call the replacement
& $case.With @args @BoundParameters
return
}
}
# if there was an original method to call, call it
if ($mock.Original) {
# no case matches, so fall through to the base
& $mock.Original @args @BoundParameters
return
}
# try the base mock
$mock = $mock.BaseMock
}
}
# Cleans up outstanding mocks if the module is unloaded.
# This keeps the system from getting really crazy if you force-load the module.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
# this gets called when we are unloading the mock library
# exit all outstanding mock contexts
while ($mockContext.Inner) {
Exit-MockContext
}
# clear the root mock context
Clear-Mocks
}
Export-ModuleMember Mock, Enable-Mock, Add-Mock, Remove-Mock, Clear-Mocks, Get-Mock, Invoke-Mock, Enter-MockContext, Exit-MockContext, MockContext