Dot sourcing a script under test pours everything into the test scope:
. ./Sut.ps1
# every function is now in test scope, and $script:Cache leaked into the caller as well
If we wrap the file in a dynamic module instead, the same file behaves like a module and nothing leaks:
$m = New-Module -Name Sut -ScriptBlock ([scriptblock]::Create((Get-Content ./Sut.ps1 -Raw))) | Import-Module -PassThru
Everything from #2998 works on it unchanged, validation kept, defaults kept, and module state is reachable with & $m { $script:Cache = 'SEEDED' }. A param() at the top of the script works through -ArgumentList.
Scripts that actually do something
A deployment script runs when you load it, so you cannot wrap it and then set up mocks, the work already happened.
Parsing it fixes that. Leave function definitions as definitions, and move every other top level statement into a generated entry point that carries the script's own ParamBlock:
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
# functions stay as they are, the rest becomes:
# function <entry> { <the original param block> <the rest> }
You get a named module that exists before anything has executed, so mocks can target it, and the script runs only when you call it. I ran this against 6.1.0 and it passes:
It 'does not touch the filesystem' {
Mock New-Item { 'mocked' } -ModuleName Deploy
Invoke-ScriptUnderTest -Path '/tmp/should-not-exist-9271'
Should -Invoke New-Item -ModuleName Deploy -Times 1
Test-Path '/tmp/should-not-exist-9271' | Should -BeFalse
}
Parameters and switches pass through, -ParameterFilter { $Force -eq $true } matches, Mandatory survives onto the entry point, and the script's own internal functions are mockable.
So it is one mechanism
| case |
mechanism |
| module |
import it |
| script that defines functions |
wrap the file in a dynamic module |
| script that does things |
hoist the top level statements into an entry point, then wrap |
There is no mode to pick, we can tell from the path. .psd1, .psm1 or a folder means import as a module, .ps1 means wrap it.
Open
The syntax, roughly
BeforeAll { $sut = <keyword> ../src/MyModule }
BeforeAll { $sut = <keyword> ./Deploy.ps1 }
and the entry point name is the ugly part. Naming it after the script gives you Deploy, or we do not name it at all and you reach it only through what came back, & $sut -Path x. The second is cleaner but then the script case reads differently from the module case, which undercuts the one mechanism story.
New-Module exports everything by default, so a wrapped script has no public and internal split. For a script I think that is right, it has no public surface to begin with.
Part of #2996.
🤖
Dot sourcing a script under test pours everything into the test scope:
If we wrap the file in a dynamic module instead, the same file behaves like a module and nothing leaks:
Everything from #2998 works on it unchanged, validation kept, defaults kept, and module state is reachable with
& $m { $script:Cache = 'SEEDED' }. Aparam()at the top of the script works through-ArgumentList.Scripts that actually do something
A deployment script runs when you load it, so you cannot wrap it and then set up mocks, the work already happened.
Parsing it fixes that. Leave
functiondefinitions as definitions, and move every other top level statement into a generated entry point that carries the script's ownParamBlock:You get a named module that exists before anything has executed, so mocks can target it, and the script runs only when you call it. I ran this against 6.1.0 and it passes:
Parameters and switches pass through,
-ParameterFilter { $Force -eq $true }matches,Mandatorysurvives onto the entry point, and the script's own internal functions are mockable.So it is one mechanism
There is no mode to pick, we can tell from the path.
.psd1,.psm1or a folder means import as a module,.ps1means wrap it.Open
The syntax, roughly
BeforeAll { $sut = <keyword> ../src/MyModule } BeforeAll { $sut = <keyword> ./Deploy.ps1 }and the entry point name is the ugly part. Naming it after the script gives you
Deploy, or we do not name it at all and you reach it only through what came back,& $sut -Path x. The second is cleaner but then the script case reads differently from the module case, which undercuts the one mechanism story.New-Moduleexports everything by default, so a wrapped script has no public and internal split. For a script I think that is right, it has no public surface to begin with.Part of #2996.
🤖