Contents

PowerShell Modul Development: Pester Tests

Pester tests can be used to ensure a level of quality in PowerShell module development that would otherwise be difficult to achieve manually. There are two important factors to consider. Module and Function Integrity.

Function Integrity

I will explore this point in more detail in a later blog entry. In short, the actual function of the module must be ensured.

A popular example is a function that adds up two numbers. Pester allows you to check if the function really returns 5 when passing 2 + 3. With a real module this gets more complex quickly.

Module Integrity

All functions in a module and also the module itself should meet certain quality requirements and of course contain valid PowerShell scripts.

During the development of my module AzureSimpleREST I looked around how other developers did it. I found Kevin Marquette’s blog entry “Powershell: Let’s build the CI/CD pipeline for a new module” and used it as a basis for my module Pester Test.

The following points should be taken into account:

  • Does every PowerShell file contain valid code ?
  • Is the module manifest, the description of the module, valid?
  • Are the exported functions available in the manifest?
  • Are internal functions not exported?
  • Are all aliases defined in the manifest as well?
  • Do the PSScriptAnalyzer Best Practice apply to all scripts?

Not all of these features were included in the original version, so I have greatly extended and modified it. In the following I will explain the individual tests. You can find the complete script in the GitHub Repo.

Pester Tests

Common variables

Some of the following variables are used later on in the tests, e.g. for the names of the tests or path specifications. In addition, it keeps everything dynamic and can be easily  reused.

Write-Host -Object "Running $PSCommandpath" -ForegroundColor Cyan
$Path = Split-Path -Parent $MyInvocation.MyCommand.Path
$ModulePath = (Get-Item $Path).Parent.FullName
$ModuleName = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -Replace ".Tests.ps1"
$ModuleManifest = Resolve-Path "$ModulePath\$ModuleName.psd1"

Module

Every PowerShell script as well as the module manifest and the module file is checked in the section ‘Basic Module Testing’. At first it is only checked if it contains valid PowerShell code and no syntax errors. Additionally it is checked if the file exists and finally if the module can be imported without errors.

Context 'Basic Module Testing' {
    # Original idea from: https://kevinmarquette.github.io/2017-01-21-powershell-module-continious-delivery-pipeline/
    $scripts = Get-ChildItem $ModulePath -Include *.ps1, *.psm1, *.psd1 -Recurse
    $testCase = $scripts | Foreach-Object {
        @{
            FilePath = $_.fullname
            FileName = $_.Name

        }
    }
    It "Script <FileName> should be valid powershell" -TestCases $testCase {
        param(
            $FilePath,
            $FileName
        )

        $FilePath | Should Exist

        $contents = Get-Content -Path $FilePath -ErrorAction Stop
        $errors = $null
        $null = [System.Management.Automation.PSParser]::Tokenize($contents, [ref]$errors)
        $errors.Count | Should Be 0
    }

    It "Module '$moduleName' can import cleanly" {
        {Import-Module (Join-Path $ModulePath "$moduleName.psm1") -force } | Should Not Throw
    }
}

Testing the PowerShell Manifest

In the next section, the manifest file (PSD1) is checked. This includes the following tests

  • Are there critical errors in the manifest file (Test-ModuleManifest)?
  • If the name of the module corresponds to the name in the manifest
  • Is a version declared?
  • If a description has been defined?
  • Is the module file (PSM1) available?
  • If the unique GUID module has not been changed?
  • If no “Format Files” are exported.
  • Are all required modules also specified?
Context 'Manifest Testing' {
    It 'Valid Module Manifest' {
        {
            $Script:Manifest = Test-ModuleManifest -Path $ModuleManifest -ErrorAction Stop -WarningAction SilentlyContinue
        } | Should Not Throw
    }
    It 'Valid Manifest Name' {
        $Script:Manifest.Name | Should be $ModuleName
    }
    It 'Generic Version Check' {
        $Script:Manifest.Version -as [Version] | Should Not BeNullOrEmpty
    }
    It 'Valid Manifest Description' {
        $Script:Manifest.Description | Should Not BeNullOrEmpty
    }
    It 'Valid Manifest Root Module' {
        $Script:Manifest.RootModule | Should Be "$ModuleName.psm1"
    }
    It 'Valid Manifest GUID' {
        $Script:Manifest.Guid | Should be '52b2fee3-fc54-4b9a-ad52-4e382b194641'
    }
    It 'No Format File' {
        $Script:Manifest.ExportedFormatFiles | Should BeNullOrEmpty
    }

    It 'Required Modules' {
        $Script:Manifest.RequiredModules | Should Be @('AzureRM')
    }
}

Functions

The functions are checked whether they are specified in the manifest file and are therefore displayed cleanly in the PowerShell Gallery. This is done once using the file names and finally it is checked if the count is correct as well. Thus one does not forget to enter a new function.

For internal functions, that is, functions that are not intended for the end user, the system checks whether the direct call provokes an error.

Context 'Exported Functions' {
    $ExportedFunctions = (Get-ChildItem -Path "$ModulePath\functions" -Filter *.ps1 | Select-Object -ExpandProperty Name ) -replace '\.ps1$'
    $testCase = $ExportedFunctions | Foreach-Object {@{FunctionName = $_}}
    It "Function <FunctionName> should be in manifest" -TestCases $testCase {
        param($FunctionName)
        $ManifestFunctions = $Manifest.ExportedFunctions.Keys
        $FunctionName -in $ManifestFunctions | Should Be $true
    }

    It 'Proper Number of Functions Exported compared to Manifest' {
        $ExportedCount = Get-Command -Module $ModuleName -CommandType Function | Measure-Object | Select-Object -ExpandProperty Count
        $ManifestCount = $Manifest.ExportedFunctions.Count

        $ExportedCount | Should be $ManifestCount
    }

    It 'Proper Number of Functions Exported compared to Files' {
        $ExportedCount = Get-Command -Module $ModuleName -CommandType Function | Measure-Object | Select-Object -ExpandProperty Count
        $FileCount = Get-ChildItem -Path "$ModulePath\functions" -Filter *.ps1 | Measure-Object | Select-Object -ExpandProperty Count

        $ExportedCount | Should be $FileCount
    }

    $InternalFunctions = (Get-ChildItem -Path "$ModulePath\internal\functions" -Filter *.ps1 | Select-Object -ExpandProperty Name ) -replace '\.ps1$'
    $testCase = $InternalFunctions | Foreach-Object {@{FunctionName = $_}}
    It "Internal function <FunctionName> is not directly accessible outside the module" -TestCases $testCase {
        param($FunctionName)
        { . $FunctionName } | Should Throw
    }
}

Aliase

The system also checks whether the aliases are all stored in the manifest and whether they are all exported.

Context 'Exported Aliases' {
    It 'Proper Number of Aliases Exported compared to Manifest' {
        $ExportedCount = Get-Command -Module $ModuleName -CommandType Alias | Measure-Object | Select-Object -ExpandProperty Count
        $ManifestCount = $Manifest.ExportedAliases.Count

        $ExportedCount | Should be $ManifestCount
    }

    It 'Proper Number of Aliases Exported compared to Files' {
        $AliasCount = Get-ChildItem -Path "$ModulePath\functions" -Filter *.ps1 | Select-String "New-Alias" | Measure-Object | Select-Object -ExpandProperty Count
        $ManifestCount = $Manifest.ExportedAliases.Count

        $AliasCount  | Should be $ManifestCount
    }
}

PSScriptAnalyzer

The section for the PSScriptAnalyzer tests is somewhat more extensive. The module ‘PSScriptAnalyzer’ allows the automated checking of scripts in a module for certain best practices. This ensures, for example, that there are no unnecessary spaces in the scripts or that no variables are declared that will not be used later. Currently the script can check 55 different rules, whereby I only use 45. Anything that corresponds to the severity warning or error will be considered as an error.

In the internet there are many implementations for PSScriptAnalyzer with Pester and each one has its advantages. Personally, it was important to me to also indicate when a file is “clean”, so no rule violations are reported. In addition, every violation should be clearly visible in the tests and not only the indication of several errors in a function.

To achive that I had to jump through some hoops.

All violations of the defined rules are saved in the $ScriptAnalyzerErrors variable and then written to a PSCustomObject $testCase. The rule name, script name, error, line number and severity are saved. From these errors Pester now generates dynamic tests which contain the function name, the error and the line number in your name. So it is easy to understand why the test failed and what needs to be corrected.

In addition, all functions with errors are written to the variable $FunctionsWithErrors. This variable is then compared with the complete list of functions and those without errors are stored in the variable $FunctionsWithoutErrors. This list is then used to generate a series of always successful tests to reward those who did not make any mistakes.

Describe "$ModuleName ScriptAnalyzer" -Tag 'Compliance' {
    $PSScriptAnalyzerSettings = @{
        Severity    = @('Error', 'Warning')
        ExcludeRule = @('PSUseSingularNouns')
    }
    # Test all functions with PSScriptAnalyzer
    $ScriptAnalyzerErrors = @()
    $ScriptAnalyzerErrors += Invoke-ScriptAnalyzer -Path "$ModulePath\functions" @PSScriptAnalyzerSettings
    $ScriptAnalyzerErrors += Invoke-ScriptAnalyzer -Path "$ModulePath\internal\functions" @PSScriptAnalyzerSettings
    # Get a list of all internal and Exported functions
    $InternalFunctions = Get-ChildItem -Path "$ModulePath\internal\functions" -Filter *.ps1 | Select-Object -ExpandProperty Name
    $ExportedFunctions = Get-ChildItem -Path "$ModulePath\functions" -Filter *.ps1 | Select-Object -ExpandProperty Name
    $AllFunctions = ($InternalFunctions + $ExportedFunctions) | Sort-Object
    $FunctionsWithErrors = $ScriptAnalyzerErrors.ScriptName | Sort-Object -Unique
    if ($ScriptAnalyzerErrors) {
        $testCase = $ScriptAnalyzerErrors | Foreach-Object {
            @{
                RuleName   = $_.RuleName
                ScriptName = $_.ScriptName
                Message    = $_.Message
                Severity   = $_.Severity
                Line       = $_.Line
            }
        }
        # Compare those with not successful
        $FunctionsWithoutErrors = Compare-Object -ReferenceObject $AllFunctions -DifferenceObject $FunctionsWithErrors  | Select-Object -ExpandProperty InputObject
        Context 'ScriptAnalyzer Testing' {
            It "Function <ScriptName> should not use <Message> on line <Line>" -TestCases $testCase {
                param(
                    $RuleName,
                    $ScriptName,
                    $Message,
                    $Severity,
                    $Line
                )
                $ScriptName | Should BeNullOrEmpty
            }
        }
    } else {
        # Everything was perfect, let's show that as well
        $FunctionsWithoutErrors = $AllFunctions
    }

    # Show good functions in the test, the more green the better
    Context 'Successful ScriptAnalyzer Testing' {
        $testCase = $FunctionsWithoutErrors | Foreach-Object {
            @{
                ScriptName = $_
            }
        }
        It "Function <ScriptName> has no ScriptAnalyzerErrors" -TestCases $testCase {
            param(
                $ScriptName
            )
            $ScriptName | Should Not BeNullOrEmpty
        }
    }
}