diff --git a/src/functions/Pester.SessionState.Mock.ps1 b/src/functions/Pester.SessionState.Mock.ps1 index 142f32d6c..d9499bf9c 100644 --- a/src/functions/Pester.SessionState.Mock.ps1 +++ b/src/functions/Pester.SessionState.Mock.ps1 @@ -366,51 +366,63 @@ function Get-VerifiableBehaviors { $currentTest = Get-CurrentTest $inTest = $null -ne $currentTest - $behaviors = [System.Collections.Generic.List[Object]]@() + # Collect the mock-behavior tables from the closest scope (the test) outwards to the root block, so we can + # walk them in precedence order. A default mock (one without a -ParameterFilter) in a closer scope shadows + # every behavior for the same command in an outer scope, because the closer default catches all calls and + # the outer behaviors can never be invoked. A verifiable behavior that is shadowed like that must not be + # required by Should -InvokeVerifiable, otherwise overriding a verifiable mock in an inner scope always + # fails verification. (#2672) + $behaviorTables = [System.Collections.Generic.List[Object]]@() if ($inTest) { - if ($PesterPreference.Debug.WriteDebugMessages.Value) { - Write-PesterDebugMessage -Scope Mock "We are in a test. Finding all behaviors in this test." - } - $allBehaviors = $currentTest.PluginData.Mock.Behaviors.Values - if ($null -ne $allBehaviors -and $allBehaviors.Count -gt 0) { - # all behaviors for all commands - foreach ($commandBehaviors in $allBehaviors) { - if ($null -ne $commandBehaviors -and $commandBehaviors.Count -gt 0) { - # all behaviors for single command - foreach ($behavior in $commandBehaviors) { - if ($behavior.Verifiable) { - $behaviors.Add($behavior) - } - } - } - } - } + $behaviorTables.Add($currentTest.PluginData.Mock.Behaviors) } $block = Get-CurrentBlock - - # recurse up while ($null -ne $block) { + $behaviorTables.Add($block.PluginData.Mock.Behaviors) + $block = $block.Parent + } - ## action - $allBehaviors = $block.PluginData.Mock.Behaviors.Values - # all behaviors for all commands - if ($null -ne $allBehaviors -or $allBehaviors.Count -ne 0) { - foreach ($commandBehaviors in $allBehaviors) { - if ($null -ne $commandBehaviors -and $commandBehaviors.Count -gt 0) { - # all behaviors for single command - foreach ($behavior in $commandBehaviors) { - if ($behavior.Verifiable) { - $behaviors.Add($behavior) - } - } + $behaviors = [System.Collections.Generic.List[Object]]@() + $shadowedCommands = @{} + + foreach ($behaviorTable in $behaviorTables) { + if ($null -eq $behaviorTable) { + continue + } + + $allBehaviors = $behaviorTable.Values + if ($null -eq $allBehaviors -or $allBehaviors.Count -eq 0) { + continue + } + + # Commands that get a default mock in this scope shadow the same command in outer scopes. Gather them + # and only apply the shadowing after this scope is processed, so behaviors defined together in the same + # scope (e.g. a default and a parameter-filtered mock) don't shadow each other. + $defaultedInThisScope = [System.Collections.Generic.List[string]]@() + + foreach ($commandBehaviors in $allBehaviors) { + if ($null -eq $commandBehaviors -or $commandBehaviors.Count -eq 0) { + continue + } + + $key = "$($commandBehaviors[0].ModuleName)||$($commandBehaviors[0].CommandName)" + $isShadowed = $shadowedCommands.ContainsKey($key) + + foreach ($behavior in $commandBehaviors) { + if ($behavior.Verifiable -and -not $isShadowed) { + $behaviors.Add($behavior) + } + + if ($behavior.IsDefault) { + $defaultedInThisScope.Add($key) } } } - # end action - $block = $block.Parent + foreach ($shadowedKey in $defaultedInThisScope) { + $shadowedCommands[$shadowedKey] = $true + } } - # end $behaviors } diff --git a/tst/Pester.Mock.RSpec.ts.ps1 b/tst/Pester.Mock.RSpec.ts.ps1 index 45f2d75d3..7fe25482f 100644 --- a/tst/Pester.Mock.RSpec.ts.ps1 +++ b/tst/Pester.Mock.RSpec.ts.ps1 @@ -657,6 +657,70 @@ i -PassThru:$PassThru { $t = $r.Containers[0].Blocks[0].Tests[0] $t.Result | Verify-Equal "Passed" } + + t "verifiable mock overridden by a verifiable mock in a narrower scope passes (#2672)" { + $sb = { + Describe 'd' { + BeforeAll { function Get-Data { 'real' } } + Context 'c' { + BeforeAll { Mock Get-Data { 'outer' } -Verifiable } + It 'i' { + Mock Get-Data { 'inner' } -Verifiable + Get-Data | Should -Be 'inner' + Should -InvokeVerifiable + } + } + } + } + + $r = Invoke-Pester -Configuration ([PesterConfiguration]@{ + Run = @{ ScriptBlock = $sb; PassThru = $true } + }) + + $r.Containers[0].Blocks[0].Blocks[0].Tests[0].Result | Verify-Equal "Passed" + } + + t "verifiable mock overridden by a default mock in a narrower scope is not required (#2672)" { + $sb = { + Describe 'd' { + BeforeAll { function Get-Data { 'real' } } + Context 'c' { + BeforeAll { Mock Get-Data { 'outer' } -Verifiable } + It 'i' { + # not verifiable, but a default mock so it shadows the outer verifiable one + Mock Get-Data { 'inner' } + Get-Data | Should -Be 'inner' + Should -InvokeVerifiable + } + } + } + } + + $r = Invoke-Pester -Configuration ([PesterConfiguration]@{ + Run = @{ ScriptBlock = $sb; PassThru = $true } + }) + + $r.Containers[0].Blocks[0].Blocks[0].Tests[0].Result | Verify-Equal "Passed" + } + + t "an uninvoked verifiable mock that is not shadowed still fails InvokeVerifiable" { + $sb = { + Describe 'd' { + BeforeAll { function Get-Data { 'real' } } + It 'i' { + Mock Get-Data { 'mock' } -Verifiable + # never call Get-Data, so the verifiable mock must make InvokeVerifiable fail + Should -InvokeVerifiable + } + } + } + + $r = Invoke-Pester -Configuration ([PesterConfiguration]@{ + Run = @{ ScriptBlock = $sb; PassThru = $true } + }) + + $r.Containers[0].Blocks[0].Tests[0].Result | Verify-Equal "Failed" + } } b "top-level mocks" {