diff --git a/src/functions/Mock.ps1 b/src/functions/Mock.ps1 index 7f299b532..20ee42b0f 100644 --- a/src/functions/Mock.ps1 +++ b/src/functions/Mock.ps1 @@ -28,20 +28,22 @@ function New-MockBehavior { [Parameter(Mandatory)] $Hook, [string[]]$RemoveParameterType, - [string[]]$RemoveParameterValidation + [string[]]$RemoveParameterValidation, + [switch]$AllowFallback ) [PSCustomObject] @{ - CommandName = $ContextInfo.Command.Name - ModuleName = $ContextInfo.TargetModule - Filter = $ParameterFilter - IsDefault = $null -eq $ParameterFilter - IsInModule = -not [string]::IsNullOrEmpty($ContextInfo.TargetModule) - Verifiable = $Verifiable - Executed = $false - ScriptBlock = $MockWith - Hook = $Hook - PSTypeName = 'MockBehavior' + CommandName = $ContextInfo.Command.Name + ModuleName = $ContextInfo.TargetModule + Filter = $ParameterFilter + AllowFallback = $AllowFallback + IsDefault = $null -eq $ParameterFilter + IsInModule = -not [string]::IsNullOrEmpty($ContextInfo.TargetModule) + Verifiable = $Verifiable + Executed = $false + ScriptBlock = $MockWith + Hook = $Hook + PSTypeName = 'MockBehavior' } } @@ -982,6 +984,8 @@ function FindMatchingBehavior { $foundDefaultBehavior = $false $defaultBehavior = $null + # Allow fallback unless parameterized behaviors exist. Required to support nested runs where mock exists but no behaviors. + $blockOriginalCommand = @($Behaviors).IsDefault -contains $false foreach ($b in $Behaviors) { if ($b.IsDefault -and -not $foundDefaultBehavior) { @@ -1005,6 +1009,8 @@ function FindMatchingBehavior { } return $b } + + if ($b.AllowFallback) { $blockOriginalCommand = $false } } } @@ -1018,6 +1024,11 @@ function FindMatchingBehavior { if ($PesterPreference.Debug.WriteDebugMessages.Value) { Write-PesterDebugMessage -Scope Mock "No parametrized or default behaviors were found filter." } + + if ($blockOriginalCommand) { + throw 'Not implemented. None of the parameterized mocks match the call or accept fallback to original command.' + } + return $null } diff --git a/src/functions/Pester.SessionState.Mock.ps1 b/src/functions/Pester.SessionState.Mock.ps1 index aa4b97a48..2ba5d9f67 100644 --- a/src/functions/Pester.SessionState.Mock.ps1 +++ b/src/functions/Pester.SessionState.Mock.ps1 @@ -124,6 +124,10 @@ function Mock { validation requirements, and allows functions that are strict about their parameter validation to be mocked more easily. + .PARAMETER AllowFallback + Allows original command to execute when only mocks with -ParameterFilter exists and the call does not match any of them. + Creating a default mock (no -ParameterFilter specified) will take precedence over this. + .EXAMPLE Mock Get-ChildItem { return @{FullName = "A_File.TXT"} } @@ -219,15 +223,22 @@ function Mock { .LINK https://pester.dev/docs/usage/mocking #> - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Default')] + # TODO: Breaking change due to parameter sets. Previously had positions: + # CommandName, MockWith, ParameterFilter, ModuleName, RemoveParameterType, RemoveParameterValidation param( + [Parameter(Position = 0)] [string]$CommandName, + [Parameter(Position = 1)] [ScriptBlock]$MockWith = {}, [switch]$Verifiable, + [Parameter(ParameterSetName = 'WithParameterFilter')] [ScriptBlock]$ParameterFilter, [string]$ModuleName, [string[]]$RemoveParameterType, - [string[]]$RemoveParameterValidation + [string[]]$RemoveParameterValidation, + [Parameter(ParameterSetName = 'WithParameterFilter')] + [switch]$AllowFallback ) if (Is-Discovery) { # this is to allow mocks in between Describe and It which is discouraged but common @@ -280,7 +291,7 @@ function Mock { $mockData.Behaviors[$contextInfo.Command.Name] = $behaviors } - $behavior = New-MockBehavior -ContextInfo $contextInfo -MockWith $MockWith -Verifiable:$Verifiable -ParameterFilter $ParameterFilter -Hook $hook + $behavior = New-MockBehavior -ContextInfo $contextInfo -MockWith $MockWith -Verifiable:$Verifiable -ParameterFilter $ParameterFilter -Hook $hook -AllowFallback:$AllowFallback 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)." } @@ -1045,7 +1056,7 @@ function Invoke-Mock { param ($b) " Target module: $(if ($b.ModuleName) { $b.ModuleName } else { '$null' })`n" " Body: { $($b.ScriptBlock.ToString().Trim()) }`n" - " Filter: $(if (-not $b.IsDefault) { "{ $($b.Filter.ToString().Trim()) }" } else { '$null' })`n" + " Filter$(if ($b.AllowFallback) { ' (with fallback)' }): $(if (-not $b.IsDefault) { "{ $($b.Filter.ToString().Trim()) }" } else { '$null' })`n" " Default: $(if ($b.IsDefault) { '$true' } else { '$false' })`n" " Verifiable: $(if ($b.Verifiable) { '$true' } else { '$false' })" } diff --git a/tst/functions/Mock.Tests.ps1 b/tst/functions/Mock.Tests.ps1 index ab769e95a..eb31cc60e 100644 --- a/tst/functions/Mock.Tests.ps1 +++ b/tst/functions/Mock.Tests.ps1 @@ -374,7 +374,7 @@ Describe 'When calling Mock, StrictMode is enabled, and variables are used in th Describe "When calling Mock on existing function without matching bound params" { It "Should redirect to real function" { - Mock FunctionUnderTest { return "fake results" } -parameterFilter { $param1 -eq "test" } + Mock FunctionUnderTest { return "fake results" } -parameterFilter { $param1 -eq "test" } -AllowFallback $result = FunctionUnderTest "badTest" $result | Should -Be "I am a real world test" } @@ -390,7 +390,7 @@ Describe "When calling Mock on existing function with matching bound params" { Describe "When calling Mock on existing function without matching unbound arguments" { It "Should redirect to real function" { - Mock FunctionUnderTestWithoutParams { return "fake results" } -parameterFilter { $param1 -eq "test" -and $args[0] -eq 'notArg0' } + Mock FunctionUnderTestWithoutParams { return "fake results" } -parameterFilter { $param1 -eq "test" -and $args[0] -eq 'notArg0' } -AllowFallback $result = FunctionUnderTestWithoutParams -param1 "test" "arg0" $result | Should -Be "I am a real world test with no params" } @@ -518,7 +518,7 @@ Describe 'When calling Mock on a module-internal function.' { BeforeAll { Mock -ModuleName TestModule InternalFunction { 'I am the mock test' } Mock -ModuleName TestModule Start-Sleep { } - Mock -ModuleName TestModule2 InternalFunction -ParameterFilter { $args[0] -eq 'Test' } { + Mock -ModuleName TestModule2 InternalFunction -ParameterFilter { $args[0] -eq 'Test' } -AllowFallback { "I'm the mock who's been passed parameter Test" } # Mock -ModuleName TestModule2 InternalFunction2 { @@ -526,7 +526,7 @@ Describe 'When calling Mock on a module-internal function.' { # # so this module internal function is not accessible to the mock body # InternalFunction 'Test' # } - Mock -ModuleName TestModule2 Get-CallerModuleName -ParameterFilter { $false } + Mock -ModuleName TestModule2 Get-CallerModuleName -ParameterFilter { $false } -AllowFallback Mock -ModuleName TestModule2 Get-Content { } } @@ -632,7 +632,7 @@ Describe "When Applying multiple Mocks on a single command where one has no filt Describe "When Creating Verifiable Mock that is not called" { Context "In the test script's scope" { It "Should throw" { - Mock FunctionUnderTest { return "I am a verifiable test" } -Verifiable -parameterFilter { $param1 -eq "one" } + Mock FunctionUnderTest { return "I am a verifiable test" } -Verifiable -parameterFilter { $param1 -eq "one" } -AllowFallback FunctionUnderTest "three" | Out-Null $result = $null try { @@ -1952,9 +1952,9 @@ Describe 'Mocking a function taking input from pipeline' { $noMockResultByProperty = $psobj | PipelineInputFunction -PipeStr 'val' $noMockArrayResultByProperty = $psArrayobj | PipelineInputFunction -PipeStr 'val' - Mock PipelineInputFunction { write-output 'mocked' } -ParameterFilter { $PipeStr -eq 'blah' } + Mock PipelineInputFunction { write-output 'mocked' } -ParameterFilter { $PipeStr -eq 'blah' } -AllowFallback } - context 'when calling original function with an array' { + Context 'when calling original function with an array' { BeforeAll { $result = @(1, 2) | PipelineInputFunction } @@ -1967,7 +1967,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling original function with an int' { + Context 'when calling original function with an int' { BeforeAll { $result = 1 | PipelineInputFunction } @@ -1978,7 +1978,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling original function with a string' { + Context 'when calling original function with a string' { BeforeAll { $result = '1' | PipelineInputFunction } @@ -1989,7 +1989,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling original function and pipeline is bound by property name' { + Context 'when calling original function and pipeline is bound by property name' { BeforeAll { $result = $psobj | PipelineInputFunction -PipeStr 'val' } @@ -2001,7 +2001,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling original function and forcing a parameter binding exception' { + Context 'when calling original function and forcing a parameter binding exception' { BeforeAll { Mock PipelineInputFunction { if ($MyInvocation.ExpectingInput) { @@ -2017,7 +2017,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling original function and pipeline is bound by property name with array values' { + Context 'when calling original function and pipeline is bound by property name with array values' { BeforeAll { $result = $psArrayobj | PipelineInputFunction -PipeStr 'val' } @@ -2029,7 +2029,7 @@ Describe 'Mocking a function taking input from pipeline' { } } - context 'when calling the mocked function' { + Context 'when calling the mocked function' { BeforeAll { $result = 'blah' | PipelineInputFunction } @@ -2289,7 +2289,7 @@ Describe 'Nested Mock calls' { BeforeAll { $testDate = New-Object DateTime(2012, 6, 13) - Mock Get-Date -ParameterFilter { $null -eq $Date } { + Mock Get-Date -ParameterFilter { $null -eq $Date } -AllowFallback { Get-Date -Date $testDate -Format o } } @@ -2452,7 +2452,7 @@ Describe "Mocking Set-Variable" { # we mock the command but the mock will never be triggered because # the filter will never pass, so this mock will always call through # to the real Set-Variable - Mock Set-Variable -ParameterFilter { $false } + Mock Set-Variable -ParameterFilter { $false } -AllowFallback Set-Variable -Name v2 -Value 10 @@ -2467,7 +2467,7 @@ Describe "Mocking Set-Variable" { Set-Variable -Name v1 -Value 1 $v1 | Should -Be 1 -Because "we defined it without mocking Set-Variable" - Mock Set-Variable -ParameterFilter { $false } + Mock Set-Variable -ParameterFilter { $false } -AllowFallback Set-Variable -Name v2 -Value 11 -Scope 0 $v2 | Should -Be 11 @@ -2478,7 +2478,7 @@ Describe "Mocking Set-Variable" { Set-Variable -Name v1 -Value 1 $v1 | Should -Be 1 -Because "we defined it without mocking Set-Variable" - Mock Set-Variable -ParameterFilter { $false } + Mock Set-Variable -ParameterFilter { $false } -AllowFallback Set-Variable -Name v2 -Value 12 -Scope Local @@ -2509,7 +2509,7 @@ Describe "Mocking Set-Variable" { & { # scope 1 & { - Mock Set-Variable -ParameterFilter { $false } + Mock Set-Variable -ParameterFilter { $false } -AllowFallback Set-Variable -Name v2 -Value 11 -Scope 3 } } @@ -2536,7 +2536,7 @@ Describe "Mocking functions with conflicting parameters" { $ParamToAvoid } - Mock Get-ExampleTest { "World" } -ParameterFilter { $_ParamToAvoid -eq "Hello" } + Mock Get-ExampleTest { "World" } -ParameterFilter { $_ParamToAvoid -eq "Hello" } -AllowFallback } It 'executes the mock' { @@ -2798,6 +2798,79 @@ Describe 'Mocking using ParameterFilter' { } } +Describe 'Calls not matching ParameterFilter' { + BeforeAll { + function demo ($name) { + "hello $name" + } + } + + Context 'When default mock does not exist in parent scope' { + It 'Throws by default' { + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } + { demo -name 'you' } | Should -Throw + } + + It 'Calls locally defined default mock' { + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } + Mock demo { 'default mock' } + demo -name 'you' | Should -Be 'default mock' + } + + It 'Calls real function when -AllowFallback is used' { + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } -AllowFallback + demo -name 'you' | Should -Be 'hello you' + } + + It 'Calls real function when at least one parameterized mock has -AllowFallback' { + # TODO: Is this expected? Or should all parameterized mocks allow fallback? + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } -AllowFallback + Mock demo -ParameterFilter { $name -eq 'Wisconsin' } -MockWith { 'mocked2' } + demo -name 'you' | Should -Be 'hello you' + } + } + + Context 'When default mock exists in parent scope' { + BeforeAll { + Mock demo -MockWith { 'default mock' } + } + It 'Calls default mock without -AllowFallback' { + # TODO: Is this expected? Should -AllowFallback only have effect when there's no default mock at any level? + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } + demo -name 'you' | Should -Be 'default mock' + } + } + + Context 'When mock with ParameterFilter exists in parent scope' { + BeforeAll { + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } + } + It 'Throws by default' { + { demo -name 'you' } | Should -Throw + } + + It 'Calls locally defined default mock' { + Mock demo { 'default mock' } + demo -name 'you' | Should -Be 'default mock' + } + } + + Context 'When mock with ParameterFilter and -AllowFallback exists in parent scope' { + BeforeAll { + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } -AllowFallback + } + It 'Calls real function' { + demo -name 'you' | Should -Be 'hello you' + } + + It 'Throws when a more local parameterized mock does not allow fallback' -Skip { + # TODO:Do we expect this? If so, we need to expose mock scope depth from Get-AllMockBehaviors + Mock demo -ParameterFilter { $name -eq 'world' } -MockWith { 'mocked' } + { demo -name 'you' } | Should -Throw + } + } +} + if ($PSVersionTable.PSVersion.Major -ge 3) { Describe "-RemoveParameterType" {