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.
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:
| Operator | Bedeutung | Beispiel |
|---|---|---|
-Be | Exakte Gleichheit (Wert) | $result | Should -Be 'aktiv' |
-BeExactly | Gleichheit inkl. Gross-/Kleinschreibung | $result | Should -BeExactly 'ADMIN' |
-BeNullOrEmpty | Wert ist null, leerer String oder leeres Array | $user.Mail | Should -Not -BeNullOrEmpty |
-Contain | Array enthaelt Element | $groups | Should -Contain 'IT-Admins' |
-Match | Regex-Abgleich | $upn | Should -Match '^\w+@firma\.ch$' |
-Throw | Codeblock wirft eine Exception | { Remove-Item nichtvorhanden } | Should -Throw |
-BeOfType | Typpruefung | $result | Should -BeOfType [System.String] |
-BeGreaterThan / -BeLessThan | Numerische Vergleiche | $count | Should -BeGreaterThan 0 |
-InvokeVerifiable | Prueft als -Verifiable markierte Mocks | Should -InvokeVerifiable |
-Invoke | Prueft, wie oft ein Mock aufgerufen wurde | Should -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:
- Scope beachten: Seit Pester 5 gilt ein Mock nur im Block, in dem er definiert wurde (
It,BeforeEach) oder – bei Definition inBeforeAll– fuer den gesamten umschliessendenDescribe/Context. Ein Mock in einemContextwirkt nicht automatisch im Elternscope. - ParameterFilter: Damit kannst du unterschiedliches Verhalten je nach Aufrufparametern simulieren, z.B. einen “User gefunden”- und einen “User nicht gefunden”-Fall im selben Test.
- Should -Invoke statt Assert-MockCalled: Die neue Syntax ab Pester 5 ist
Should -Invoke -CommandName Xyz -Times N -Exactly. Das aeltereAssert-MockCalledfunktioniert weiterhin, ist aber Legacy-Syntax. - 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-ModuleMembermusst du ggf.-ModuleNameanMockuebergeben.
# 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:
- Red: Schreibe
It 'lehnt Nutzer ohne gueltige Abteilung ab' { { New-EmployeeAccount -Abteilung 'XYZ' } | Should -Throw }– der Test schlaegt fehl, weilNew-EmployeeAccountdie Validierung noch nicht hat. - Green: Implementiere die minimale Validierung (
if ($Abteilung -notin $validDepartments) { throw }), bis der Test gruen ist. - 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-Typ | Testprioritaet | Fokus |
|---|---|---|
| Einmal-Skript (Migration, Cleanup) | niedrig | Manuelles Review reicht meist |
| Wiederkehrendes Reporting-Skript | mittel | Tests fuer Parsing-/Berechnungslogik |
| Automatisierte Provisionierung (Onboarding, Lizenzen) | hoch | Vollstaendige Mock-Abdeckung aller externen Cmdlets, Validierungslogik per TDD |
| Skripte mit Schreibzugriff auf Produktivsysteme (AD, Exchange, Firewall) | sehr hoch | Mocking Pflicht, Code Coverage ≥ 70%, CI-Pipeline als Merge-Gate |
| Wiederverwendbare Module/Funktionsbibliotheken | sehr hoch | Jede oeffentliche Funktion mit eigenem Describe-Block |
Haeufige Fallstricke in der Praxis
- Dot-Sourcing vergessen: Ohne
. $PSScriptRoot\Skript.ps1inBeforeAllkennt Pester die zu testende Funktion nicht und wirft “command not found”. - Globale Variablen als versteckte Abhaengigkeit: Wenn dein Skript auf
$env:COMPUTERNAMEoder 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). MockeGet-Dateoder 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-MockCalledohne-Times/-Exactlyverhaelt sich in Pester 5 anders als frueher erwartet. Bei Zweifeln die aktuelle Syntax (Should -Invoke) verwenden.
Weiterlernen
- Pester – Quick Start – offizielle Einstiegsdokumentation
- Pester – Mocking – vollstaendige Referenz zu Mock, ParameterFilter und Should -Invoke
- Pester – Code Coverage – Konfiguration von PesterConfiguration.CodeCoverage
- Pester GitHub Repository – Quellcode, Issues, Release Notes
- Microsoft Learn – Pester Testing Overview – Einordnung im PowerShell-Oekosystem
- PowerShell Gallery – Pester – aktuelle Versionen und Installation
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
Kommentare
Frage, Verbesserungsvorschlag oder eigene Erfahrung zu diesem Artikel? Schreib einen Kommentar. Neue Beiträge erscheinen nach kurzer Moderation.
- Lade Kommentare …