As part of a big refactor on an internal module I’ve decided to add a big pile of Pester tests. The module was lacking them previously due to various reasons and this seemed like the perfect oppurtunity to add them.

With my Pester test suites one of the things I like to do is have a bunch of tests for parameters and their various attributes that I want to ensure are correctly set. Previously I’d focused on the easy things like aliases, mandatory-ness, pipeline input etc. but this time I wanted to check for default values since we make use of them in a few places.

Possible soltuions

When approaching this problem there were a few options that occured to me, I could use some regular expressions (regex) to handle it or I could delve into the PowerShell Abstract Syntax Tree (AST). Here’s the sample function we’ll be working with for this:

Function Get-SomeData {
    [cmdletbinding()]
    param (
        [Parameter(Mandatory)]
        [string]$ParameterName = 'DefaultValue',

        [int]$HowMany
    )

    $ParameterName
}

Regex

There’s a running joke among developers that when you choose to use regex to solve a problem then you now have two problems. However in this case it seemend like quite a reasonable solution to implement as it was a very regular pattern that would never be repeated in a file.

So here’s the regex I came up with, and a handy comment above it with what it all means (perhaps a little too verbose a description):

# \[ - Matches a [ character
# \w+ - Matches any word character 1 or more times
# \] - Matches a ] character
# \$ - Matches a $ character
# ParameterName - Matches the string ParamterName
#  * - Matches 0 or more spaces
# = - Matches a = character
# ("DefaultValue"|'DefaultValue') - Matches the DefaultValue string in either quotes and captures in a group
# ,$ - Matches a comma at the end of a line

$regex = "\[\w+\]\`$ParameterName *= *(`"DefaultValue`"|'DefaultValue'),$"

This works pretty well and will happily find me any instances of that parameter in a file, there should only be 1 so the rest of my It block would look like this:

It "Should have a default value of 'DefaultValue' for ParameterName parameter" {
    $regex = "^\[\w+\]\`$ParameterName *= *(`"DefaultValue`"|'DefaultValue'),$"
    $MatchStrings = $Sut | Select-String $Regex
    $MatchStrings.Count | Should -Be 1
}

The major problem I have with this approach is that I have to run it against the functions source file rather than the compiled psm1 file, like all my other tests. It’s unlikely there has been any change in the short time between my script combining all the ps1 files into a single psm1 and the tests running but I still prefer to always test the compiled module.

It would still be possible to do this test against the compiled psm1 but I’d have to account for how many functions use that parameter with a default value, which either means updating all my effected tests each time I add a new function or maintaining a config file that I use to control the tests. The later is certainly an option but doesn’t feel quite right for this particular testing problem.

AST

The alternative approach to this is using the Abstract Syntax Tree and the various methods built into it. There are a number of great posts and books out there describing the AST much better than I can, including quite a good example on Hey, Scripting Guy.

So let’s dive into my solution to this problem. First we have to get our function and its AST representation, which Get-Command is able to provide quite easily. Let’s see what properties we can retrieve using the ever useful Get-Member:

Get-Command -Name Get-SomeData | Get-Member

  TypeName: System.Management.Automation.FunctionInfo

Name                MemberType     Definition
----                ----------     ----------
Equals              Method         bool Equals(System.Object obj)
GetHashCode         Method         int GetHashCode()
GetType             Method         type GetType()
ResolveParameter    Method         System.Management.Automation.ParameterMetadata ResolveParameter(string name)
ToString            Method         string ToString()
CmdletBinding       Property       bool CmdletBinding {get;}
CommandType         Property       System.Management.Automation.CommandTypes CommandType {get;}
DefaultParameterSet Property       string DefaultParameterSet {get;}
Definition          Property       string Definition {get;}
Description         Property       string Description {get;set;}
HelpFile            Property       string HelpFile {get;}
Module              Property       psmoduleinfo Module {get;}
ModuleName          Property       string ModuleName {get;}
Name                Property       string Name {get;}
Noun                Property       string Noun {get;}
Options             Property       System.Management.Automation.ScopedItemOptions Options {get;set;}
OutputType          Property       System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.PSTypeName] OutputType {get;}
Parameters          Property       System.Collections.Generic.Dictionary[string,System.Management.Automation.ParameterMetadata] Parameters {get;}
ParameterSets       Property       System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.CommandParameterSetInfo] Par...
RemotingCapability  Property       System.Management.Automation.RemotingCapability RemotingCapability {get;}
ScriptBlock         Property       scriptblock ScriptBlock {get;}
Source              Property       string Source {get;}
Verb                Property       string Verb {get;}
Version             Property       version Version {get;}
Visibility          Property       System.Management.Automation.SessionStateEntryVisibility Visibility {get;set;}
HelpUri             ScriptProperty System.Object HelpUri {get=$oldProgressPreference = $ProgressPreference...

That’s a whole lot of properties but the one we’re interested in is ScriptBlock. That will, unsurprisingly, return us a ScriptBlock object of the function and part of the scriptblock is the AST representation of it.

(Get-Command -Name Get-SomeData).ScriptBlock.Ast | Get-Member

   TypeName: System.Management.Automation.Language.FunctionDefinitionAst

Name           MemberType Definition
----           ---------- ----------
Copy           Method     System.Management.Automation.Language.Ast Copy()
Equals         Method     bool Equals(System.Object obj)
Find           Method     System.Management.Automation.Language.Ast Find(System.Func[System.Management.Automation.Language.Ast,bool] predicate...
FindAll        Method     System.Collections.Generic.IEnumerable[System.Management.Automation.Language.Ast] FindAll(System.Func[System.Managem...
GetHashCode    Method     int GetHashCode()
GetHelpContent Method     System.Management.Automation.Language.CommentHelpInfo GetHelpContent(), System.Management.Automation.Language.Commen...
GetType        Method     type GetType()
SafeGetValue   Method     System.Object SafeGetValue()
ToString       Method     string ToString()
Visit          Method     System.Object Visit(System.Management.Automation.Language.ICustomAstVisitor astVisitor), void Visit(System.Managemen...
Body           Property   System.Management.Automation.Language.ScriptBlockAst Body {get;}
Extent         Property   System.Management.Automation.Language.IScriptExtent Extent {get;}
IsFilter       Property   bool IsFilter {get;}
IsWorkflow     Property   bool IsWorkflow {get;}
Name           Property   string Name {get;}
Parameters     Property   System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.Language.ParameterAst] Parameters {get;}
Parent         Property   System.Management.Automation.Language.Ast Parent {get;}

From here we want to make use of the FindAll method on the AST. This will let us find all AST elements which match a set of criteria we specify. In this case we want to find all of the AST elements which are of type ParameterAST and which have the name ParameterName.

(Get-Command -Name $Sut).ScriptBlock.Ast.FindAll( {
    $args[0] -is [System.Management.Automation.Language.ParameterAst] -and
    $args[0].Name.VariablePath.UserPath -eq 'ParameterName'
}, $true)

Here we have a scriptblock in a similar format to those used in Where-Object and we make use of the $args[0] automatic variable, which is populated with each AST element one at a time. We can also declare a param block within this scriptblock if we wanted to use a more descriptive variable name.

Let’s break down what this comparison is doing and look at it more closely:

$args[0] -is [System.Management.Automation.Language.ParameterAst] -and

First we make sure the type of AST we’re looking at is the type we care about, due to how -and comparisons work if this doesn’t match then it’ll continue on to the next AST element without even checking the other part of the comparison. To find the AST type you want to look at, and to generally explore the AST representation of a scriptblock, you can make use of Show-AST from the ShowPSAst Module or there are a few other AST modules available on the gallery as well.

$args[0].Name.VariablePath.UserPath -eq 'ParameterName'

Next we compare the name of the parameter to what we want. This is buried a little deeper than just $args[0].name and took a little digging around in the resulting AST object that came back without this part of the query.

}, $true)

This section is a little interesting as FindAll expects two arguements passed to it, a Predicate (the scriptblock) and a boolean. The Predicate is what we’ve just looked and it should just return true or false. The boolean is to tell FindAll if it should recurse through nested scriptblocks. In this case we’ll want to do this so I’ve set it to $true but there are some cases where you won’t want to do this and can therefore set it to $false.

So now, hopefully, we’ll have an output from this containing the AST object for the parameter we’re looking for. From here we just want to access the Value property of the DefaultValue property (as it’s a nested object with some more details in it).

(Get-Command -Name $Sut).ScriptBlock.Ast.FindAll( {
    $args[0] -is [System.Management.Automation.Language.ParameterAst] -and
    $args[0].Name.VariablePath.UserPath -eq 'ParameterName'
}, $true).DefaultValue

StringConstantType : SingleQuoted
Value              : DefaultValue
StaticType         : System.String
Extent             : 'DefaultValue'
Parent             : [Parameter(Mandatory)]
                     [string]$ParameterName = 'DefaultValue'

So our final test when all this is put together looks like the below. We could add another check in here too to ensure the parameter is only present once but that would probably be better suited a separate test.

It "Should have a default value of 'DefaultValue' for ParameterName parameter" {
    (Get-Command -Name $Sut).ScriptBlock.Ast.FindAll( {
        $args[0] -is [System.Management.Automation.Language.ParameterAst] -and
        $args[0].Name.VariablePath.UserPath -eq 'ParameterName'
    }, $true).DefaultValue.Value | Should -Be 'DefaultValue'
}

The major benefit this has over the regex approach is that it’ll run against the psm1 file as it’s pulling the functions from Get-Command after the psm1 has been imported. It’s also a lot more focused and less brittle than the regex approach, if a parameter isn’t strongly typed then the regex will miss it. There are solutions to these problems but it will often feel like investing time and effort into a less flexible solution.

Conclusion

The AST is really powerful but can also be pretty complicated when you’re first looking at it, tools like Show-AST help a lot and there are a lot of really good blog posts out there about working with the AST. There are also a few really helpful people on the PowerShell Slack who know a good deal about the AST. As we can see in this situation it has given us a much more flexible solution to the problem we were trying to solve and it accounts for a lot more possible situations.

Regex is an even more powerful and complicated beast but it can be pretty difficult to get your pattern correct for the various use cases you have. It’s a tool I’ll often employ first but it is not always the best solution, as can be seen here.