Zum Inhalt springen
sw
en

Tippe um zu suchen

Scripting & Automatisierung

Pester: Automatisierte Tests fuer PowerShell-Skripte

Wie du mit Pester 5 Admin-Skripte testest, Cmdlets mockst, Code Coverage misst und Tests in CI/CD-Pipelines automatisierst.

12 Min Lesezeit Fortgeschritten Zuletzt aktualisiert:

Warum ueberhaupt Tests fuer PowerShell-Skripte?

Stell dir folgendes Szenario vor: Ein Kollege passt dein Onboarding-Skript an, das automatisch AD-User anlegt, Postfaecher bereitstellt und Gruppenmitgliedschaften setzt. Er aendert eine Zeile in der Namenskonvention, testet manuell “einmal kurz” mit einem Testuser, alles sieht gut aus, er committet. Zwei Wochen spaeter legt das Skript in der Produktion ploetzlich Benutzer ohne UPN-Suffix an, weil ein Edge Case (Vorname mit Umlaut) nicht abgedeckt war. Genau dafuer gibt es Pester.

Pester ist das De-facto-Standard-Test- und Mock-Framework fuer PowerShell und seit PowerShell 5.1 sogar als Inbox-Modul auf jedem Windows-Client vorinstalliert (wenn auch meist in einer veralteten Version 3.x oder 4.x). Es erlaubt dir, Verhalten von Funktionen und Skripten automatisiert zu pruefen, kritische Cmdlets wie Get-ADUser oder Send-MailMessage durch kontrollierte Attrappen (Mocks) zu ersetzen und den Testlauf in eine CI/CD-Pipeline einzubauen, sodass fehlerhafter Code gar nicht erst gemergt wird.

Fuer ein Schweizer KMU ohne dediziertes Testing-Team klingt das nach Overkill fuer “ein paar Skripte”. Die Realitaet sieht anders aus: Genau dort, wo IT-Admins nebenbei kritische Automatisierung bauen (Lizenzverwaltung, Freigaben-Provisionierung, Backup-Validierung), fehlt oft die Zeit fuer manuelles Nachtesten bei jeder Aenderung. Ein Pester-Test, den du einmal schreibst, laeuft danach in Sekunden bei jedem Commit mit.

Grundstruktur: Describe, Context, It

Pester-Tests folgen einer klaren Hierarchie, die sich an BDD-Frameworks (Behavior Driven Development) wie RSpec orientiert:

  • Describe – der aeusserste Container, meist benannt nach der zu testenden Funktion oder dem Skript
  • Context – gruppiert Tests nach Szenario oder Zustand (z.B. “wenn User existiert” vs. “wenn User nicht existiert”)
  • It – der eigentliche Einzeltest mit einer konkreten Erwartung
  • Should – der Assertion-Operator, der die tatsaechliche gegen die erwartete Ausgabe prueft

Testdateien muessen der Namenskonvention *.Tests.ps1 folgen, damit Pester sie automatisch findet. Ein typisches Layout in einem Skript-Repository:

Scripts/
  Get-InactiveUsers.ps1
  Get-InactiveUsers.Tests.ps1
  New-Mailbox-Onboarding.ps1
  New-Mailbox-Onboarding.Tests.ps1

Ein minimales, aber vollstaendiges Beispiel fuer eine Funktion, die inaktive AD-User findet:

# Get-InactiveUsers.ps1
function Get-InactiveUsers {
    param(
        [int]$DaysInactive = 90
    )

    $cutoff = (Get-Date).AddDays(-$DaysInactive)
    Get-ADUser -Filter { LastLogonDate -lt $cutoff -and Enabled -eq $true } `
        -Properties LastLogonDate |
        Select-Object SamAccountName, LastLogonDate
}
# Get-InactiveUsers.Tests.ps1
BeforeAll {
    . $PSScriptRoot\Get-InactiveUsers.ps1
}

Describe 'Get-InactiveUsers' {

    Context 'wenn inaktive User existieren' {

        BeforeAll {
            Mock Get-ADUser {
                @(
                    [pscustomobject]@{ SamAccountName = 'mmuster'; LastLogonDate = (Get-Date).AddDays(-120) }
                    [pscustomobject]@{ SamAccountName = 'ahaller'; LastLogonDate = (Get-Date).AddDays(-95) }
                )
            }
        }

        It 'gibt genau zwei User zurueck' {
            $result = Get-InactiveUsers -DaysInactive 90
            $result.Count | Should -Be 2
        }

        It 'enthaelt das SamAccountName-Feld' {
            $result = Get-InactiveUsers -DaysInactive 90
            $result[0].SamAccountName | Should -Not -BeNullOrEmpty
        }
    }

    Context 'wenn keine inaktiven User existieren' {

        BeforeAll {
            Mock Get-ADUser { @() }
        }

        It 'gibt eine leere Liste zurueck, keinen Fehler' {
            { Get-InactiveUsers -DaysInactive 90 } | Should -Not -Throw
        }
    }
}

Wichtig: BeforeAll innerhalb eines Context-Blocks laeuft nur einmal fuer diesen Context, nicht vor jedem It. Willst du vor jedem Test frisch initialisieren (z.B. weil ein Test Zustand veraendert), nutze BeforeEach statt BeforeAll.

Assertions: Die Should-Operatoren im Detail

Should ist das Herzstueck jeder Pester-Assertion. Die haeufigsten Operatoren im Admin-Skript-Alltag:

OperatorBedeutungBeispiel
-BeExakte Gleichheit (Wert)$result | Should -Be 'aktiv'
-BeExactlyGleichheit inkl. Gross-/Kleinschreibung$result | Should -BeExactly 'ADMIN'
-BeNullOrEmptyWert ist null, leerer String oder leeres Array$user.Mail | Should -Not -BeNullOrEmpty
-ContainArray enthaelt Element$groups | Should -Contain 'IT-Admins'
-MatchRegex-Abgleich$upn | Should -Match '^\w+@firma\.ch$'
-ThrowCodeblock wirft eine Exception{ Remove-Item nichtvorhanden } | Should -Throw
-BeOfTypeTyppruefung$result | Should -BeOfType [System.String]
-BeGreaterThan / -BeLessThanNumerische Vergleiche$count | Should -BeGreaterThan 0
-InvokeVerifiablePrueft als -Verifiable markierte MocksShould -InvokeVerifiable
-InvokePrueft, wie oft ein Mock aufgerufen wurdeShould -Invoke Get-ADUser -Times 1 -Exactly

Alle Operatoren lassen sich mit -Not negieren: $result | Should -Not -BeNullOrEmpty. Achte darauf, dass Should in der Pipeline steht (| Should ...) – das ist die von Pester 5 empfohlene Syntax, die alte Parameter-Syntax (Should Be) ohne Pipe funktioniert zwar teils noch, gilt aber als deprecated.

Mocking: Cmdlets kontrolliert ersetzen

Das ist der Teil, der Pester fuer Admin-Skripte unverzichtbar macht. Deine Skripte rufen typischerweise Cmdlets auf, die reale Systeme veraendern: New-ADUser, Remove-Mailbox, Set-DhcpServerv4Scope, Invoke-RestMethod gegen eine API. Du willst diese Logik testen, ohne bei jedem Testlauf tatsaechlich einen AD-User anzulegen oder eine Mailbox zu loeschen.

Mock ersetzt ein Cmdlet innerhalb des aktuellen Scopes durch ein Scriptblock deiner Wahl:

Describe 'New-EmployeeAccount' {

    BeforeAll {
        . $PSScriptRoot\New-EmployeeAccount.ps1

        Mock New-ADUser { }
        Mock Add-ADGroupMember { }
        Mock Send-MailMessage { }
        Mock Get-ADUser {
            [pscustomobject]@{ SamAccountName = 'jdupont'; Enabled = $true }
        } -ParameterFilter { $Identity -eq 'jdupont' }
    }

    It 'legt genau einen AD-User an' {
        New-EmployeeAccount -Vorname 'Jean' -Nachname 'Dupont' -Abteilung 'Finance'
        Should -Invoke New-ADUser -Times 1 -Exactly
    }

    It 'fuegt den User zur richtigen Abteilungsgruppe hinzu' {
        New-EmployeeAccount -Vorname 'Jean' -Nachname 'Dupont' -Abteilung 'Finance'
        Should -Invoke Add-ADGroupMember -Times 1 -ParameterFilter {
            $Identity -eq 'GRP-Finance'
        }
    }

    It 'versendet keine Mail bei Testmodus' {
        New-EmployeeAccount -Vorname 'Jean' -Nachname 'Dupont' -Abteilung 'Finance' -WhatIf
        Should -Invoke Send-MailMessage -Times 0
    }
}

Wichtige Regeln beim Mocking, die in der Praxis oft zu Verwirrung fuehren:

  1. Scope beachten: Seit Pester 5 gilt ein Mock nur im Block, in dem er definiert wurde (It, BeforeEach) oder – bei Definition in BeforeAll – fuer den gesamten umschliessenden Describe/Context. Ein Mock in einem Context wirkt nicht automatisch im Elternscope.
  2. ParameterFilter: Damit kannst du unterschiedliches Verhalten je nach Aufrufparametern simulieren, z.B. einen “User gefunden”- und einen “User nicht gefunden”-Fall im selben Test.
  3. Should -Invoke statt Assert-MockCalled: Die neue Syntax ab Pester 5 ist Should -Invoke -CommandName Xyz -Times N -Exactly. Das aeltere Assert-MockCalled funktioniert weiterhin, ist aber Legacy-Syntax.
  4. Nur importierte/bekannte Commands mocken: Pester kann nur Commands mocken, die zum Zeitpunkt der Mock-Definition im Session-Scope aufloesbar sind. Bei Modulen mit Export-ModuleMember musst du ggf. -ModuleName an Mock uebergeben.
# Mock innerhalb eines konkreten Moduls
Mock -CommandName Get-ADUser -ModuleName MyOnboardingModule -MockWith {
    [pscustomobject]@{ SamAccountName = 'testuser' }
}

Code Coverage messen

Code Coverage zeigt dir, welche Zeilen deines Skripts tatsaechlich von Tests durchlaufen wurden – ein wichtiges Signal dafuer, ob deine Tests die kritischen Pfade (Fehlerbehandlung, Edge Cases) wirklich abdecken oder nur den Happy Path.

Seit Pester 5 laeuft die Konfiguration ueber das PesterConfiguration-Objekt statt ueber lose Parameter:

$config = New-PesterConfiguration
$config.Run.Path = '.\Scripts'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = '.\Scripts\*.ps1'
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = '.\coverage.xml'
$config.CodeCoverage.CoveragePercentTarget = 80
$config.Output.Verbosity = 'Detailed'

Invoke-Pester -Configuration $config

Nach dem Lauf siehst du in der Konsole eine Zusammenfassung:

Code coverage report:
Covered 78.43% of 51 analyzed commands in 2 files.

Missed commands:
File                     Function              Line Command
----                     --------              ---- -------
Get-InactiveUsers.ps1    Get-InactiveUsers       14 Write-EventLog -LogName ...

Das JaCoCo-XML-Format laesst sich direkt in Azure DevOps, GitHub Actions oder SonarQube als Coverage-Report visualisieren. Alternativ steht CoverageGutters als Format zur Verfuegung, wenn du in VS Code inline sehen willst, welche Zeilen abgedeckt sind.

Testgetriebene Entwicklung (TDD) fuer Admin-Skripte

TDD dreht den ueblichen Ablauf um: Erst den Test schreiben (der zwangslaeufig fehlschlaegt, weil die Funktion noch nicht existiert), dann den minimalen Code schreiben, der den Test bestehen laesst, danach refaktorieren. Der Zyklus heisst “Red – Green – Refactor”.

Fuer ein KMU-Umfeld ist strikte TDD selten praktikabel bei jedem Ein-Zeiler-Skript, aber fuer Skripte mit Business-kritischer Logik (Berechtigungsvergabe, Abrechnungsdaten, Backup-Validierung) lohnt sich der Ansatz:

  1. Red: Schreibe It 'lehnt Nutzer ohne gueltige Abteilung ab' { { New-EmployeeAccount -Abteilung 'XYZ' } | Should -Throw } – der Test schlaegt fehl, weil New-EmployeeAccount die Validierung noch nicht hat.
  2. Green: Implementiere die minimale Validierung (if ($Abteilung -notin $validDepartments) { throw }), bis der Test gruen ist.
  3. Refactor: Extrahiere die Validierungsliste in eine Konfigurationsdatei, ohne dass sich das Testverhalten aendert.

Der Vorteil in der Praxis: Du denkst Edge Cases (leere Eingabe, ungueltige Abteilung, doppelter SamAccountName) vor der Implementierung durch, statt sie erst im Produktivbetrieb zu entdecken.

Describe 'New-EmployeeAccount – Validierung' {
    BeforeAll {
        . $PSScriptRoot\New-EmployeeAccount.ps1
        $validDepartments = @('Finance', 'IT', 'Verkauf', 'Produktion')
    }

    It 'wirft bei unbekannter Abteilung eine Exception' {
        { New-EmployeeAccount -Vorname 'Test' -Nachname 'User' -Abteilung 'Marketing' } |
            Should -Throw '*ungueltige Abteilung*'
    }

    It 'akzeptiert alle bekannten Abteilungen' {
        foreach ($dept in $validDepartments) {
            { New-EmployeeAccount -Vorname 'Test' -Nachname 'User' -Abteilung $dept -WhatIf } |
                Should -Not -Throw
        }
    }
}

Tests in CI/CD-Pipelines automatisieren

Der eigentliche Wert von Pester entsteht, wenn Tests nicht nur lokal auf deinem Rechner laufen, sondern automatisch bei jedem Commit oder Pull Request. Zwei gaengige Varianten fuer ein Schweizer KMU: GitHub Actions (wenn ihr auf GitHub seid) oder Azure DevOps Pipelines (wenn ihr M365/Azure-zentriert seid).

GitHub Actions

name: PowerShell Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - name: Pester installieren
        shell: pwsh
        run: |
          Install-Module -Name Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck

      - name: Tests ausfuehren
        shell: pwsh
        run: |
          $config = New-PesterConfiguration
          $config.Run.Path = './Scripts'
          $config.Run.Exit = $true
          $config.CodeCoverage.Enabled = $true
          $config.CodeCoverage.OutputPath = 'coverage.xml'
          $config.TestResult.Enabled = $true
          $config.TestResult.OutputFormat = 'NUnitXml'
          $config.TestResult.OutputPath = 'testResults.xml'
          Invoke-Pester -Configuration $config

      - name: Testergebnisse veroeffentlichen
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Pester-Ergebnisse
          path: testResults.xml
          reporter: java-junit

$config.Run.Exit = $true sorgt dafuer, dass PowerShell mit einem Exit-Code ungleich null beendet wird, wenn Tests fehlschlagen – nur so kann die Pipeline den Build korrekt als “failed” markieren.

Azure DevOps

trigger:
  - main

pool:
  vmImage: 'windows-latest'

steps:
  - task: PowerShell@2
    displayName: 'Pester Tests ausfuehren'
    inputs:
      targetType: 'inline'
      script: |
        Install-Module -Name Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck
        $config = New-PesterConfiguration
        $config.Run.Path = '$(Build.SourcesDirectory)/Scripts'
        $config.TestResult.Enabled = $true
        $config.TestResult.OutputFormat = 'NUnitXml'
        $config.TestResult.OutputPath = '$(Build.ArtifactStagingDirectory)/testResults.xml'
        $config.CodeCoverage.Enabled = $true
        $config.CodeCoverage.OutputFormat = 'JaCoCo'
        $config.CodeCoverage.OutputPath = '$(Build.ArtifactStagingDirectory)/coverage.xml'
        Invoke-Pester -Configuration $config

  - task: PublishTestResults@2
    inputs:
      testResultsFormat: 'NUnit'
      testResultsFiles: '$(Build.ArtifactStagingDirectory)/testResults.xml'

  - task: PublishCodeCoverageResults@2
    inputs:
      summaryFileLocation: '$(Build.ArtifactStagingDirectory)/coverage.xml'

Entscheidungshilfe: Was und wie testen?

Nicht jedes Skript braucht die volle Testabdeckung. Diese Matrix hilft bei der Priorisierung:

Skript-TypTestprioritaetFokus
Einmal-Skript (Migration, Cleanup)niedrigManuelles Review reicht meist
Wiederkehrendes Reporting-SkriptmittelTests fuer Parsing-/Berechnungslogik
Automatisierte Provisionierung (Onboarding, Lizenzen)hochVollstaendige Mock-Abdeckung aller externen Cmdlets, Validierungslogik per TDD
Skripte mit Schreibzugriff auf Produktivsysteme (AD, Exchange, Firewall)sehr hochMocking Pflicht, Code Coverage ≥ 70%, CI-Pipeline als Merge-Gate
Wiederverwendbare Module/Funktionsbibliothekensehr hochJede oeffentliche Funktion mit eigenem Describe-Block

Haeufige Fallstricke in der Praxis

  • Dot-Sourcing vergessen: Ohne . $PSScriptRoot\Skript.ps1 in BeforeAll kennt Pester die zu testende Funktion nicht und wirft “command not found”.
  • Globale Variablen als versteckte Abhaengigkeit: Wenn dein Skript auf $env:COMPUTERNAME oder globale Variablen zugreift, wird der Test auf verschiedenen Rechnern/Runnern unterschiedlich verhalten. Injiziere solche Werte stattdessen als Parameter.
  • Zeitabhaengige Tests: (Get-Date) direkt im Test zu verwenden fuehrt zu Flaky Tests (heute gruen, morgen rot). Mocke Get-Date oder uebergib das Datum als Parameter.
  • Zu grosse It-Bloecke: Ein It-Block, der zehn Assertions gleichzeitig prueft, macht Fehleranalyse muehsam. Ein Test, eine klare Aussage.
  • Alte Pester-3-Syntax mit Pester 5 mischen: Assert-MockCalled ohne -Times/-Exactly verhaelt sich in Pester 5 anders als frueher erwartet. Bei Zweifeln die aktuelle Syntax (Should -Invoke) verwenden.

Weiterlernen

Verwandte Themen: PowerShell im IT-Alltag, PowerShell-Scripting vertieft, PowerShell-Klassen und Modul-Design, PowerShell Remoting, PSSession und JEA, Git fuer IT-Admins, Skript-Sicherheit mit AppLocker und WDAC

Videos

YouTube
Pester Crash Kurs | PowerShell deutsch

Kommentare

Frage, Verbesserungsvorschlag oder eigene Erfahrung zu diesem Artikel? Schreib einen Kommentar. Neue Beiträge erscheinen nach kurzer Moderation.

  • Lade Kommentare …
Kommentar schreiben