Skip to content

One mechanism for testing modules and scripts, and no more dot sourcing #2999

Description

@nohwnd

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.

🤖

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions