Closed alx9r closed 9 years ago
Rather than using global variables, I prefer to set up a hashtable as the container for my values, and modify that instead. This is a subtle difference; instead of writing to a PowerShell variable (which is scoped to the script block where the write happens, unless you do something like $script:
or $global:
, you're only reading the $hash value, which will resolve in the parent scopes. Once that happens, you can fiddle with the key/value pairs in the hashtable however you like.
Example:
InModuleScope module {
Describe 'simple state shared between mocks.' {
BeforeEach {
$hash = @{
widgetExists = $false
widgetIncorrigible = $false
}
}
Mock -Verifiable New-Widget {
if ( -not ($hash.widgetIncorrigible) )
{
$hash.widgetExists = $true
}
}
Mock -Verifiable Test-Widget {
return $hash.widgetExists
}
Context 'widget exists.' {
It 'returns true.' {
$hash.widgetExists = $true
Set-Widget | Should be $true
}
It 'calls Test-Widget once.' {
Assert-MockCalled Test-Widget -Exactly -Times 1
}
It 'does not call New-Widget.' {
Assert-MockCalled New-Widget -Exactly -Times 0
}
}
Context 'widget doesn''t exist.' {
It 'returns true.' {
Set-Widget | Should be $true
}
It 'calls Test-Widget twice.' {
Assert-MockCalled Test-Widget -Exactly -Times 2
}
It 'calls New-Widget once.' {
Assert-MockCalled New-Widget -Exactly -Times 1
}
}
Context 'widget can''t be created.' {
It 'returns false.' {
$hash.widgetIncorrigible = $true
Set-Widget | Should be $false
}
It 'calls Test-Widget twice.' {
Assert-MockCalled Test-Widget -Exactly -Times 2
}
It 'calls New-Widget once.' {
Assert-MockCalled New-Widget -Exactly -Times 1
}
}
}
}
Incidentally, I use this same technique when I want to have assertions for both Should Not Throw
and Should Be
on the same function call:
Describe 'Something' {
It 'Works' {
$hash = @{}
$scriptBlock = { $hash.Result = Do-Something }
$scriptBlock | Should Not Throw
$hash.Result | Should Be 'Something'
}
}
This makes good sense. Thanks very much @dlwyatt.
You are mixing two concerns in your Set-Widget
function, that's why it's hard to test, because it creates the Widget and also Tests if it exists. If you implemented it like this:
function Get-Widget {
#here you'd find another boundary like Get-Item, that you'd be able to mock
#and you'd test Get-Widget by mocking or faking it
}
function Test-Widget {
[bool](Get-Widget)
}
function New-Widget {
#here you'd find another boundary, like New-Item, that you'd be able to mock
#here and you'd test Get-Widget by mocking or faking it
}
function Set-Widget
{
process
{
if (-not (Test-Widget))
{
New-Widget
}
Get-Widget
}
}
Describe 'Test-Widget' {
It 'Returns true if widget exists' {
Mock Get-Widget {"widget"}
Test-Widget | Should Be $true
}
It 'Returns false if widget does not exiss' {
Mock Get-Widget {}
Test-Widget | Should Be $false
}
}
Describe 'Set-Widget' {
It 'Widget is created when it does not exist' {
Mock Test-Widget { $false }
Mock New-Widget {}
Set-Widget
Assert-MockCalled New-Widget -Exactly 1 -Scope It
}
It 'Returns the current widget if it already exists' {
Mock New-Widget {} # prevents test side-effects if accidentally called
Mock Test-Widget { $true }
Mock Get-Widget { "widget" }
Set-Widget | Should Be "widget"
}
}
Your test would not be so coupled to the code and you would not need to share state between mocks.
@nohwnd My example was meant to demonstrate explicit call history tracking, and nothing else. Are you implying there should never be a need to make multiple calls to the same mock from a function-under-test?
@alx9r I am implying that rather than coming up with ways to share the state between mocks so you can reuse them for all your tests cases. You should make your code and tests simpler by using different, more testable, design. And multiple local Mocks.
But to answer your question: I am reluctant to say never, but it should be very rare. Especially in unit tests. But I can imagine an integration test where you try to simulate some interaction with an external system and you have to simulate a different states based on incoming requests. But for that I would use an object that would have logic to simulate the actual system. Like this:
function Test-Widget {}
function New-Widget {}
function Set-Widget
{
[CmdletBinding()]
param ()
process
{
if ( -not (Test-Widget) )
{
New-Widget
if ( -not (Test-Widget))
{
return $false
}
}
return $true
}
}
Describe 'simple state shared between mocks.' {
Context 'widget exists.' {
Mock New-Widget {} #prevents side-effects
Mock Test-Widget { $true }
It 'returns true.' {
Set-Widget | Should be $true
}
It 'calls Test-Widget once.' {
# this operation has no side-effects you don't have to care how many times it was called
#I would remove this test
Assert-MockCalled Test-Widget -Exactly -Times 1
}
It 'does not call New-Widget.' {
Assert-MockCalled New-Widget -Exactly -Times 0
}
}
Context 'widget doesn''t exist and can be created.' {
$widgetFake = New-Module -AsCustomObject -Name "StateObject" {
$script:exists = $false
function Create () {
$Script:exists = $true
}
function Exists() {
$script:exists
}
}
Mock New-Widget { $widgetFake.Create() }
Mock Test-Widget { $widgetFake.Exists() }
It 'returns true.' {
Set-Widget | Should be $true
}
It 'calls Test-Widget twice.' {
Assert-MockCalled Test-Widget -Exactly -Times 2
}
It 'calls New-Widget once.' {
Assert-MockCalled New-Widget -Exactly -Times 1
}
}
Context 'widget doesn''t exist and can''t be created.' {
Mock New-Widget { } #cant be created
Mock Test-Widget { $false }
It 'returns false.' {
Set-Widget | Should be $false
}
It 'calls Test-Widget twice.' {
Assert-MockCalled Test-Widget -Exactly -Times 2
}
It 'calls New-Widget once.' {
Assert-MockCalled New-Widget -Exactly -Times 1
}
}
}
Please notice I am testing your original logic, and I only need to share the state for a single test case.
I also commented on your other issue about parameter values in mock, showing that no explicit mock history is needed there: https://github.com/pester/Pester/issues/325#issuecomment-97201503 hope this will make the bigger picture clearer and will help you make your tests simpler.
@nohwnd OK, I get your point and I agree that, ideally, mocks live close to the test cases and are as simple as they can be for that particular test case. It does seem, however, that explicitly defining new mocks for each Context
results in more repetition as the number of test cases that use the same mock behavior increases.
@alx9r I think that's is fine. DRY (Don't Repeat Yourself) is not priority for test code. Readability is. If you introduce some mocks here and some mocks there, you have to think about scopes and your tests are becoming more complex.
Plus if you start introducing logic in your code you will soon arrive at a point where you need tests to test your test code. Correctly I should've written a few tests for the "widget state object":
function New-WidgetFake
{
New-Module -AsCustomObject -Name "StateObject" {
[bool]$script:exists = $false
function Create() {
$Script:exists = $true
}
function Exists() {
$script:exists
}
}
}
Describe 'WidgetFake' {
It 'Fresh Widgetfake indicated that widget does not exist' {
$fake = New-WidgetFake
$fake.Exists() | Should Be $false
}
It 'Creating widget sets its internal state to true' {
$fake = New-WidgetFake
$fake.Create()
$fake.Exists() | Should Be $true
}
}
Some argue that you should " favor DRY in production code, favor DAMP in test code", which makes sense to me.
@alx9r Yup, so we agree? :)
I've been writing idempotent functions which would become the building-blocks of DSC resources. The following code demonstrates a prototypical module and test fixture.
Module.psm1
Modules.Tests.ps1
Mock State Variables
To get complete code coverage,
Test-Widget
gets called twice with different return values depending on the setup. The result ofTest-Widget
might also change depending on whetherNew-Widget
has been called yet. The most obvious way to achieve this is using variables that can be accessed from theNew-
andTest-
mocks and theIt
block.The least bad way I have found to do this is using global variables. Is there a better way to do this? I generally don't like to use global variables because you risk defeating isolation between tests whenever you use them.