A Boilerplate for Unit testing DSC resources with Pester

Unit testing PowerShell code is slowly but surely becoming mainstream. Pester, the awesome PowerShell testing framework is playing a big part in that trend.
But why the hell would you write more PowerShell code to test your PowerShell code ? Because :

  • It can give you a better understanding of your code, its design, its assumptions and its behaviour.
     
  • When you make changes and the unit tests pass, you can be pretty confident that you didn’t break anything.
    This makes changes less painful and scary and this is a very important notion in DevOps : removing fear and friction to make changes painless, easy, fast and even … boring.
     
  • It helps writing more robust , less buggy code.
     
  • Given the direction that PowerShell community is taking and the way the DevOps movement is permeating the IT industry, this is becoming a valuable skill.
     
  • There is an initial learning curve and it takes time, effort and discipline, but if you do it often enough, it can quickly become second nature.
     

To help reduce this time and effort, I wanted to a build Pester script template which could be reused for unit testing any DSC resource. After all, DSC resources have a number of specific requirements and best practices, for example : Get-TargetResource should return a hashtable, or Test-TargetResource should return a boolean… So we can write tests for all these requirements and these tests can be readily reused for any other DSC resource (non class-based).

Without further ado, here is the full script (which is also available on GitHub) and then we’ll elaborate on the main bits and pieces :

$Global:DSCResourceName = 'My_DSCResource'  #<----- Just change this

Import-Module "$($PSScriptRoot)\..\..\DSCResources\$($Global:DSCResourceName)\$($Global:DSCResourceName).psm1" -Force

# Helper function to list the names of mandatory parameters of *-TargetResource functions
Function Get-MandatoryParameter {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$True)]
        [string]$CommandName
    )
    $GetCommandData = Get-Command "$($Global:DSCResourceName)\$CommandName"
    $MandatoryParameters = $GetCommandData.Parameters.Values | Where-Object { $_.Attributes.Mandatory -eq $True }
    return $MandatoryParameters.Name
}

# Getting the names of mandatory parameters for each *-TargetResource function
$GetMandatoryParameter = Get-MandatoryParameter -CommandName "Get-TargetResource"
$TestMandatoryParameter = Get-MandatoryParameter -CommandName "Test-TargetResource"
$SetMandatoryParameter = Get-MandatoryParameter -CommandName "Set-TargetResource"

# Splatting parameters values for Get, Test and Set-TargetResource functions
$GetParams = @{
    
}
$TestParams = @{
    
}
$SetParams = @{
    
}

Describe "$($Global:DSCResourceName)\Get-TargetResource" {
    
    $GetReturn = & "$($Global:DSCResourceName)\Get-TargetResource" @GetParams

    It "Should return a hashtable" {
        $GetReturn | Should BeOfType System.Collections.Hashtable
    }
    Foreach ($MandatoryParameter in $GetMandatoryParameter) {
        
        It "Should return a hashtable with key named $MandatoryParameter" {
            $GetReturn.ContainsKey($MandatoryParameter) | Should Be $True
        }
    }
}

Describe "$($Global:DSCResourceName)\Test-TargetResource" {
    
    $TestReturn = & "$($Global:DSCResourceName)\Test-TargetResource" @TestParams

    It "Should have the same mandatory parameters as Get-TargetResource" {
        # Does not check for $True or $False but uses the output of Compare-Object.
        # That way, if this test fails Pester will show us the actual difference(s).
        (Compare-Object $GetMandatoryParameter $TestMandatoryParameter).InputObject | Should Be $Null
    }
    It "Should return a boolean" {
        $TestReturn | Should BeOfType System.Boolean
    }
}

Describe "$($Global:DSCResourceName)\Set-TargetResource" {
    
    $SetReturn = & "$($Global:DSCResourceName)\Set-TargetResource" @SetParams

    It "Should have the same mandatory parameters as Test-TargetResource" {
        (Compare-Object $TestMandatoryParameter $SetMandatoryParameter).InputObject | Should Be $Null
    }
    It "Should not return anything" {
        $SetReturn | Should Be $Null
    }
}

 
That’s a lot of information so let’s break it down into more digestible chunks :

$Global:DSCResourceName = 'My_DSCResource'  #<----- Just change this

 
The “My_DSCResource” string is only part in the entire script which needs to be changed from one DSC resource to another. All the rest can be reused for any DSC resource.

Import-Module "$($PSScriptRoot)\..\..\DSCResources\$($Global:DSCResourceName)\$($Global:DSCResourceName).psm1" -Force

The relative path to the module containing the DSC resource is derived from a standard folder structure, with a “Tests” folder at the root of the module and a “Unit” subfolder, containing the resulting unit tests script, for example :

O:\> tree /F "C:\Git\FolderPath\DscModules\DnsRegistration"
Folder PATH listing for volume OS

│   DnsRegistration.psd1
│
├───DSCResources
│   └───DnsRegistration
│       │   DnsRegistration.psm1
│       │   DnsRegistration.schema.mof
│       │
│       └───ResourceDesignerScripts
│               GenerateDnsRegistrationSchema.ps1
│
└───Tests
    └───Unit
            DnsRegistration.Tests.ps1

 
We load the module because we’ll need to use the 3 functions it contains : Get-TargetResource, Set-TargetResource and Test-TargetResource.

By the way, note that this script is divided into 3 Describe blocks : this is a more or less established convention in unit testing with Pester : one Describe block per tested function. The “Force” parameter of Import-Module is to make sure that, even if the module was already loaded, we get the latest version of the module.

Function Get-MandatoryParameter {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$True)]
        [string]$CommandName
    )
    $GetCommandData = Get-Command "$($Global:DSCResourceName)\$CommandName"
    $MandatoryParameters = $GetCommandData.Parameters.Values | Where-Object { $_.Attributes.Mandatory -eq $True }
    return $MandatoryParameters.Name
}

 
This is a helper function used to get the mandatory parameter names for the *-TargetResource functions. If you use a more than a few helper functions in your unit tests, then you should probably gather them in a separate script or module.

# Splatting parameters values for Get, Test and Set-TargetResource functions
$GetParams = @{
     
}
$TestParams = @{
     
}
$SetParams = @{
     
}

 
These are placeholders to be completed with the parameters and values for Get-TargetResource, Test-TargetResource and Set-TargetResource, respectively. Splatting makes them more readable, especially for resources that have many parameters. We might use the same parameters and parameter values for all 3 functions, in that case, we can consolidate these 3 hashtables into a single one.

$GetReturn = & "$($Global:DSCResourceName)\Get-TargetResource" @GetParams

 
Specifying the resource name with the function allows to unambiguously call the Get-TargetResource function from the DSC resource we are currently testing and not the one from another resource.

It "Should return a hashtable" {
        $GetReturn | Should BeOfType System.Collections.Hashtable
    }

 
The first actual test ! This is validating that Get-TargetResource returns a object of the type [hashtable]. The “BeOfType” operator is designed specifically for verifying the type of an object so it’s a great fit.

Foreach ($MandatoryParameter in $GetMandatoryParameter) {
        
        It "Should return a hashtable with key named $MandatoryParameter" {
            $GetReturn.ContainsKey($MandatoryParameter) | Should Be $True
        }
    }

 
An article from the PowerShell Team says this :

The Get-TargetResource returns the status of the modeled entities in a hash table format. This hash table must contain all properties, including the Read properties (along with their values) that are defined in the resource schema.

I’m not sure this is a hard requirement because this is not enforced, and Get-TargetResource is not automatically called by the DSC engine. So this may not be ideal but we are getting the names of the mandatory parameters of Get-TargetResource and we check that the hashtable returned by Get-TargetResource has a key matching each of these parameters. Maybe, we could check against all parameters, not just the mandatory ones ?

Now, let’s turn our attention to Test-TargetResource :

    $TestReturn = & "$($Global:DSCResourceName)\Test-TargetResource" @TestParams

    It "Should have the same mandatory parameters as Get-TargetResource" {
        (Compare-Object $GetMandatoryParameter $TestMandatoryParameter).InputObject | Should Be $Null
    }

 
This test is validating that the mandatory parameters of Test-TargetResource are the same as for Get-TargetResource. There is a PSScriptAnalyzer rule for that, with an “Error” severity, so we can safely assume that this is a widely accepted and important best practice :

GetSetTest Parameters
 
Reading the name of this “It” block, we could assume that it is checking against $True or $False. But here, we use Compare-Object and validate that there is no difference between the 2 lists of mandatory parameters. This is to make the message we get in case the test fails more useful : it will tell us the offending parameter name(s).

    It "Should return a boolean" {
        $TestReturn | Should BeOfType System.Boolean
    }

 
The function Test-TargetResource should always return a boolean. This is a well known requirement and this is also explicitly specified in the templates generated by xDSCResourceDesigner, so there is no excuse for not knowing/following this rule.

Now, it is time to test Set-TargetResource :

    It "Should have the same mandatory parameters as Test-TargetResource" {
        (Compare-Object $TestMandatoryParameter $SetMandatoryParameter).InputObject | Should Be $Null
    }

 
The same as before, but this time we validate that the mandatory parameters of the currently tested function (Set-TargetResource) are the same as for Test-TargetResource.

    It "Should not return anything" {
        $SetReturn | Should Be $Null
    }

 
Set-TargetResource should not return anything. Again, you don’t have to take my word for it, PSScriptAnalyzer is our source of truth :

Set should not return anything
 
That’s it for the script. But then, a boilerplate is more useful when it is readily available as a snippet on your IDE of choice. So I also converted this boilerplate into a Visual Studio Code snippet, this is the first snippet in the custom snippet file I made available here.

The path of Visual Studio Code PowerShell snippet file is : %APPDATA%\Code\User\snippets\PowerShell.json.
Or, for those of us using the PowerShell extension, we can modify the following file : %USERPROFILE%.vscode\extensions\ms-vscode.PowerShell-0.6.1\snippets\PowerShell.json.

Obviously, this set of tests is pretty basic and doesn’t cover the code written specifically for a given resource, but it’s a pretty good starting point. This allows to write basic unit tests for our DSC resources in just a few minutes, so now, there’s no excuse for not doing it.