A Boilerplate for Unit testing DSC resources with Pester

07/12/2016  |    6 minute read

Unit testing PowerShell code is slowly but surely becoming mainstream.
Pester, the awesome PowerShell testing framework is playing a big part in that trend.

Why would you write more PowerShell code to test your PowerShell code ?

Very basically, because :

  • It gives you a better understanding of your code, its design, its assumptions
  • It helps writing higher quality code : less buggy, more modular
  • It tells you very quickly whether your code changes are breaking anything

To help reduce the TTGT (Time to get started), I built a 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]
  • Test-TargetResource should return a [Boolean]

So we can write tests for these requirements and these tests can be readily reused for any other MOF-based DSC resource.

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

$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)]
        [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 BeNullOrEmpty
    }
}

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 the 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 :

C:\> 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
  • Test-TargetResource

This script is divided into 3 Describe blocks : this is a convention in unit testing with Pester : 1 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)]
        [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 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.

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 directly called by the DSC engine.

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.

Reading the name of this It block, we could assume that it is checking against $True or $False.
But here, we use Compare-Object to validate that there is no difference between the 2 lists of mandatory parameters. This is to make the test failure message 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 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 BeNullOrEmpty
    }

Set-TargetResource 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 made it into a Visual Studio Code snippet, this is the first snippet in the file available here.

The snippet file should be store as : %APPDATA%\Code\User\snippets\PowerShell.json.
Or, for those of us using the PowerShell extension, we can modify the existing file at : %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 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...