Inhalt

PowerShell Modul Entwicklung: Pester Tests

Mittels Pester Tests lässt sich bei der PowerShell Modul Entwicklung ein Grad an Qualität sicherstellen, die man sonst nur sehr aufwändig manuell erreichen würde. Dabei gilt es zwei wichtige Faktoren zu prüfen. Modul- und Funktions-Integrität.

Funktions-Integrität

Auf diesen Punkt werde ich in einem späteren Blogeintrag genauer eingehen. Kurz gesagt muss die eigentliche Funktion des Moduls sichergestellt werden.

Ein beliebtes Beispiel ist eine Funktion die zwei Zahlen addiert. Pester erlaubt es zu prüfen ob die Funktion bei der Übergabe von 2 + 3 wirklich 5 zurückliefert. Bei einem echtem Modul ist dies schnell komplexer.

Modul-Integrität

Alle Funktionen in einem Modul und auch das Modul selbst sollten gewissen Qualitätsanforderungen entsprechen und natürlich gültige PowerShell Scripte beinhalten.

Bei der Entwicklung meines Moduls AzureSimpleREST habe ich mich umgesehen wie andere Entwickler dies bewerkstelligen. Bei Kevin Marquette bin ich über seinen Blogeintrag “Powershell: Let’s build the CI/CD pipeline for a new module” fündig geworden und habe damit die Basis für mein Modul Pester Test gefunden.

Dabei sollten folgende Punkte berücksichtigt werden:

  • Ist in jeder PowerShell Datei auch valider Code
  • Ist das Modul Manifest, die Beschreibung des Moduls, gültig
  • Sind die exportierten Funktionen im Manifest vorhanden
  • Werden interne Funktionen nicht exportiert
  • Sind alle Aliasse im Manifest definiert
  • Entsprechen die Scripte den PSScriptAnalyzer Best Practice

Nicht alle diese Funktionen hat die ursprüngliche Version mitgebracht, daher habe ich Sie stark erweitert und modifiziert. Im Folgenden werde ich die einzelnen Tests erklären. Das komplette Script findet Ihr im GitHub Repo.

Pester Tests

Allgemeine Variablen

Einige der folgenden Variablen werden in den Tests weiter verwendet z.B. für die Namen der Tests oder Pfadangaben. Außerdem ermöglich das dynamische Auslesen des Modulnamens, -Manifests und -Pfads ein einfaches Wiederverwenden.

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"

Modulprüfung

Jedes PowerShell Script, sowie das Modul Manifest und die Modul Datei wird im Abschnitt ‘Basic Module Testing’ überprüft. Dabei wird vorerst nur geprüft ob es gültigen PowerShell Code und keine Syntaxfehler enthält. Zusätzlich wird geprüft ob die Datei vorhanden ist und zum Abschluss ob das Modul sauber importiert werden kann.

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
    }
}

PowerShell Manifest prüfen

Im nächsten Abschnitt wird die Manifest Datei (PSD1) überprüft. Dazu gehören folgende Tests

  • Gibt es kritische Fehler in der Manifestdatei (Test-ModuleManifest)
  • Entspricht der Name des Moduls dem im Manifest
  • Ist eine Version hinterlegt
  • Wurde eine Beschreibung definiert
  • Ist die Modul Datei (PSM1) vorhanden
  • Wurde die eindeutige Modul GUID nicht verändert
  • Werden keine “Format Files” exportiert
  • Sind alle benötigten Module auch angegeben
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')
    }
}

Funktionen

Bei den Funktionen wird geprüft ob diese in der Manifest Datei angegeben sind und somit sauber in der PowerShell Gallery angezeigt werden. Das wird einmal anhand der Dateinamen gemacht und abschließend noch geprüft ob die Anzahl korrekt ist. So vergisst man nicht eine neue Funktion einzutragen.

Bei den internen Funktionen, also Funktionen die nicht für den Endanwender gedacht sind, wird geprüft ob der Aufruf auch einen Fehler provoziert.

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
    }
}

Aliasse

Auch bei den Aliassen wird geprüft ob diese alle im Manifest hinterlegt sind und ob auch alle exportiert werden.

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

Der Abschnitt für die PSScriptAnalyzer Tests ist etwas umfangreicher. Das Modul ‘PSScriptAnalyzer’ erlaubt die automatisierte Prüfung der Scripte in einem Modul auf bestimmte Best Practices. Somit ist sichergestellt das z.B. keine unnötigen Leerzeichen in den Scripten sind oder auch das keine Variablen deklariert werden, die später nicht verwendet werden. Aktuell kann das Script 55 verschiedene Regeln prüfen, wobei ich nur 45 nutze. Alles was der Severity Warning oder Error entspricht wird so als Fehler gewertet.

Im Internet finden sich viele Implementierungen für Pester und jede hat Ihre Vorzüge. Persönlich war es mir wichtig auch anzuzeigen wenn eine Datei “sauber” ist, also keine Regelverstöße meldet. Außerdem sollte jeder Verstoß in den Tests genau zu sehen sein und nicht nur der Hinweis auf mehrere Fehler in einer Funktion.

Dazu musste ich etwas in die Trickkiste greifen.

Alle Verstöße gegen die definierten Regeln werden in die Variable $ScriptAnalyzerErrors geschrieben und anschließend in ein PSCustomObject $testCase geschrieben. Dabei werden der Regelname, Scriptname, der Fehler sowie Zeilennummer und Severity gespeichert. Aus diesen Fehlern generiert Pester jetzt dynamische Tests die in Ihrem Namen den Funktionsname, den Fehler und die Zeilennummer beinhalten. So ist es leicht nachvollziehbar warum der Test fehlgeschlagen ist und was korrigiert werden muss.

Zusätzlich werden alle Funktionen mit Fehlern in die Variable $FunctionsWithErrors geschrieben. Diese Variable wird anschließend mit der kompletten Liste von Funktionen verglichen und solche ohne Fehler in die Variable $FunctionsWithoutErrors gespeichert. Diese Liste wird dann genutzt um eine Reihe an immer erfolgreichen Tests zu generieren und somit jene zu belohnen die keine Fehler gemacht haben.

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
        }
    }
}