Words: 1009
Time to read: ~ 5 minutes
Update:
2019-02-22: Added a test for Jakub’s (don’t freak out, don’t freak out) comment.
2019-06-13: Check out Jakub’s post on the topic – Such elegance http://jakubjares.com/2019/06/09/2019-07-testing-whole-scripts/
I’m going to (optimistically) say that you are all Pester testing your scripts.
It makes me feel better to believe that. If you aren’t, then you don’t even realise the amount of mental energy that you are spending trying to keep track of thoughts like this:
“oh can’t touch that in case that breaks that which would break this and feck up that…”
Once you start Pester-ing your tests, it gets easier since you know that any unwanted bugs introduced by your changes will be captured and raised up to you.
So you code along, happy in the knowledge that you can focus on the problem and free up some mental memory to work on the problem at hand.
The first few lines of a Pester “.tests.ps1” file…
…follow the same format:
This is known as “dot-sourcing” the file.
This normally isn’t a problem since the agreed best practice way of writing functions is to have a single file that just defines the function (preferably in a module) e.g. Do-Something.ps1.
That way you can dot-source the function . .\Do-Something.ps1
and then you can call the function normally from the console e.g. Do-Something -ToThis 'SQL Server'
.
Where things differ…
…could be when you try to accommodate different people and create a .ps1 file that both defines and calls a function. Self Contained scripts, if you would call them that.
Normally the reason that I’ve heard from this is you’re trying to help a non-technical minded person and they just want a file that they can open, hit “run”, and everything is done for them.
Have you ever tried to Pester test those files though? It’s not recommended, especially if your function removes or modifies objects.
It’s pretty hard to test something when it does the work when you try and load it in…
PSPowerHour
I was watching the video of PSPowerHour that was done on the 18th Feb 2019 and one of the videos was from Jakub Jares ( blog | twitter ) on testing self contained scripts.
If there is a video about Pester from Jakub Jares, then I recommend you watch it. Here’s a link to the part I was watching.
https://youtu.be/7uYDux0HJ7w?t=915
It’s impressive! Yet after the part about Aliases it got to a level that I just haven’t been able to reach yet. Plus I’m pretty sure that if I use Invoke-Expression
in my scripts then Joel Sallow ( blog | twitter ) will have very strong words with me…
Whether for good or for bad, I have my own way of testing my self contained scripts.
I use the Abstract Syntax Tree (AST).
AST
Ah yes, the Abstract Syntax Tree! Honestly, I only know enough to know how to get what I want from it. It’s on my list of things to learn and get more comfortable with but then so is basically everything.
I’m still working on getting 30 hour days first 🙁
In case, you don’t know or want to figure out the AST then don’t worry! As most things the PowerShell community has you covered.
Mike Robbins ( blog | twitter ) has a great blog about getting into it and Chris Dent ( github ) has a function that does nearly all the work for you! Get-FunctionInfo
using namespace System.Management.Automation | |
using namespace System.Management.Automation.Language | |
using namespace System.Reflection | |
function Get-FunctionInfo { | |
<# | |
.SYNOPSIS | |
Get an instance of FunctionInfo. | |
.DESCRIPTION | |
FunctionInfo does not present a public constructor. This function calls an internal / private constructor on FunctionInfo to create a description of a function from a script block or file containing one or more functions. | |
.PARAMETER IncludeNested | |
By default functions nested inside other functions are ignored. Setting this parameter will allow nested functions to be discovered. | |
.PARAMETER Path | |
The path to a file containing one or more functions. | |
.PARAMETER ScriptBlock | |
A script block containing one or more functions. | |
.INPUTS | |
System.String | |
.OUTPUTS | |
System.Management.Automation.FunctionInfo | |
.EXAMPLE | |
Get-ChildItem -Filter *.psm1 | Get-FunctionInfo | |
Get all functions declared within the *.psm1 file and construct FunctionInfo. | |
.EXAMPLE | |
Get-ChildItem C:\Scripts -Filter *.ps1 -Recurse | Get-FunctionInfo | |
Get all functions declared in all ps1 files in C:\Scripts. | |
.NOTES | |
Change log: | |
10/12/2015 – Chris Dent – Improved error handling. | |
28/10/2015 – Chris Dent – Created. | |
#> | |
[CmdletBinding(DefaultParameterSetName = 'FromPath')] | |
[OutputType([System.Management.Automation.FunctionInfo])] | |
param ( | |
[Parameter(Position = 1, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FromPath')] | |
[Alias('FullName')] | |
[String]$Path, | |
[Parameter(ParameterSetName = 'FromScriptBlock')] | |
[ScriptBlock]$ScriptBlock, | |
[Switch]$IncludeNested | |
) | |
begin { | |
$executionContextType = [PowerShell].Assembly.GetType('System.Management.Automation.ExecutionContext') | |
$constructor = [FunctionInfo].GetConstructor( | |
[BindingFlags]'NonPublic, Instance', | |
$null, | |
[CallingConventions]'Standard, HasThis', | |
([String], [ScriptBlock], $executionContextType), | |
$null | |
) | |
} | |
process { | |
if ($pscmdlet.ParameterSetName -eq 'FromPath') { | |
try { | |
$scriptBlock = [ScriptBlock]::Create((Get-Content $Path –Raw)) | |
} catch { | |
$ErrorRecord = @{ | |
Exception = $_.Exception.InnerException | |
ErrorId = 'InvalidScriptBlock' | |
Category = 'OperationStopped' | |
} | |
Write-Error @ErrorRecord | |
} | |
} | |
if ($scriptBlock) { | |
$scriptBlock.Ast.FindAll( { | |
param( $ast ) | |
$ast -is [FunctionDefinitionAst] | |
}, | |
$IncludeNested | |
) | ForEach-Object { | |
try { | |
$internalScriptBlock = $_.Body.GetScriptBlock() | |
} catch { | |
Write-Debug $_.Exception.Message | |
} | |
if ($internalScriptBlock) { | |
$constructor.Invoke(([String]$_.Name, $internalScriptBlock, $null)) | |
} | |
} | |
} | |
} | |
} |
I saved the above into a file called Get-FunctionInfo.ps1
, because consistency is important, and now I can use that to help solve our little “testing self contained scripts” problem.
Our Self Contained Script
Let’s say we have a self contained script like the one below, saved in the file Get-Name.ps1
.
function Get-Name {
[CmdletBinding()]
param(
[String]$Name = 'you'
)
'Hello, {0}' -f $Name
}
Get-Name -Name Shane

Now if we were to load Chris’ Get-FunctionInfo
script, and run it against our function, we get…
# dot source the script to use it
. .\Get-FunctionInfo.ps1
Get-Function -Path .\Get-Name.ps1

Don’t worry, there’s more information then that available. Let’s save the information in a variable and run Get-Member
on it.
$fInfo = Get-FunctionInfo -Path .\Get-Name.ps1
$fInfo | Get-Member

We take a quick look at the Definition
property and we see something very familiar.
$fInfo.Definition

So we have our function definition but how to add it to our scope? Personally I use the below
# I'm going to add a "2" after the name to show it's ours!
$FakeFunction = "function $($fInfo.Name)2 { $($fInfo.Definition) }
$OurFakeFunction = [scriptblock]::create($FakeFunction)
# Now we can dot-source our variable
. $OurFakeFunction
Get-Name2

Now our Get-Name2
runs and works just like our original script. Lets Pester test this now.
Now we can test it!
Let’s run a simple test in a brand new window to prove there’s no funny business.
describe 'testing our vGet-Name' {
# Get our self containted script info
. .\Get-FunctionInfo.ps1
$fInfo = Get-FunctionInfo -Path .\Get-Name.ps1
# Create something we can dot-source
$Fake = "function $($fInfo.Name) { $($fInfo.Definition) }"
$FakeFunc = [scriptblock]::Create($Fake)
. $FakeFunc
Context 'Results' {
it 'should have the expected default result' {
Get-Name | Should -Be 'Hello, you'
}
it 'should have the expected result for passed in values' {
Get-Name -Name 'Shane' | Should -Be 'Hello, Shane'
}
}
}

UPDATE: 2019-02-22.
Jakub added a comment to this post saying that 1). he’s going to work his presentation into a module, and 2). …
Jakub Jares
I like your approach with ast as well, but I think it won’t work when you use $PSScriptRoot in your script.
So in the interest of completeness, I wanted to test it out and add it here. First of all, we have to add the $PSScriptRoot
to our script.
function Get-Name { | |
[CmdletBinding()] | |
param( | |
[String]$Name = 'you' | |
) | |
'Hello, {0}. Script root is {1}' -f $Name, $PSScriptRoot | |
} | |
Get-Name –Name Shane |
I’ve gone back to gists after seeing the default code formatter and having the automatic response of “eww”. 😐
Running this shows that the $PSScriptRoot
variable works.

Now let’s see what happens when we try to use our testing workaround with this function…
describe 'testing our function with scriptroot' { | |
# Get our self contained script info. | |
. .\Get-FunctionInfo.ps1 | |
$fInfo = Get-FunctionInfo –Path .\Get-NameScriptBlock.ps1 | |
# Create something we can dot-source. | |
$Fake = "function $($fInfo.Name) { $($fInfo.Definition) }" | |
$FakeFunc = [scriptblock]::Create($Fake) | |
. $FakeFunc | |
Context 'Results' { | |
It 'should have the expected default result' { | |
Get-Name | Should –Be 'Hello, you. Script root is C:\Users\shane.oneill\Git\Blog' | |
} | |
It 'should have the expected result for passed in values' { | |
Get-Name –Name 'Shane' | Should –Be 'Hello, Shane. Script root is C:\Users\shane.oneill\Git\Blog' | |
} | |
} | |
} |

And after that test, we’ve proved that Jakub was right!
Did you expect otherwise?
Now if you’ll excuse me…
… apparently I have to go learn a lot more about Scopes and Session Contexts and then re-watch Jakub’s video!
param([switch]$Testing)
function Get-Name {
[CmdletBinding()]
param(
[String]$Name = ‘you’
)
‘Hello, {0}’ -f $Name
}
if(!$Testing){Get-Name -Name Shane}
Thanks for the article 🙂 That invoke expression is just for the demo, in real life you’d use just an empty function. I’ll clean it up and publish it as a module. And add it to Pester v5. It already does a similar thing with Add-Dependency.
I like your approach with ast as well, but I think it won’t work when you use $PSScriptRoot in your script.