Fast, Focused Unit Tests with Pester: Small Fixtures, AAA, and CI in Seconds
You can trust your PowerShell scripts when you prove their behavior with small, repeatable tests. With Pester (PowerShell 7+, Pester 5), you can write fast unit tests that run locally in seconds and in CI on every push. In this post, you’ll learn a practical strategy: start with pure functions, keep fixtures tiny, arrange–act–assert clearly, fail fast on edge cases, and integrate your tests into a speedy CI loop.
Principles for Fast, Focused Pester Tests
Start with pure functions
Pure functions are easy to test because they have no external side effects (no network, file system, or global state). Given the same inputs, they always return the same output. You can write dozens of unit tests that execute in milliseconds. When you start with pure logic first and push I/O to the edges, your test suite remains fast and reliable.
- Pass data in via parameters, not globals.
- Return values rather than writing to the pipeline for core logic; wrap with thin I/O layers later.
- Use small, explicit types (e.g.,
[int[]]) so invalid inputs fail early.
Arrange–Act–Assert and fail fast
Clarity beats cleverness in tests. Use the Arrange–Act–Assert (AAA) pattern to make intent obvious:
- Arrange: set up minimal inputs/fixtures.
- Act: invoke a single function under test.
- Assert: verify a single behavior with
Should.
Fail fast by testing edge cases first: nulls, empty arrays, boundaries, and invalid types. You’ll catch more defects with fewer tests.
Keep fixtures tiny
Tiny fixtures run faster and are simpler to reason about:
- Prefer inline literals over loading files or modules.
- When the file system is needed, use Pester’s
TestDrive:to isolate I/O without touching the real disk. - Mock external commands only when you must; for pure functions, don’t mock at all.
Example: From Function to Focused Tests
Here’s a small function and a fast, focused test set that demonstrates the principles above.
function Get-Sum {
param([int[]]$Values)
if ($null -eq $Values) { throw [ArgumentNullException]::new('Values') }
if ($Values.Count -eq 0) { return 0 }
($Values | Measure-Object -Sum).Sum
}
# Pester tests (PowerShell 7+, Pester 5)
Describe 'Get-Sum' {
It 'returns 0 for empty input' {
Get-Sum -Values @() | Should -Be 0
}
It 'adds positive integers' {
Get-Sum -Values @(2,3,5) | Should -Be 10
}
It 'throws on null input' {
{ Get-Sum -Values $null } | Should -Throw -ErrorType System.ArgumentNullException
}
}
Why this works well:
- Pure function: No file/network calls — only deterministic math.
- Edge-first: Null and empty cases are asserted up front.
- Fast: The whole suite executes in milliseconds.
Adding boundary and negative cases
Extend your coverage with clear, focused cases — still small and fast:
Describe 'Get-Sum - edge cases' {
It 'handles negative integers' {
Get-Sum -Values @(-2, 5, -3) | Should -Be 0
}
It 'sums a single value' {
Get-Sum -Values @(42) | Should -Be 42
}
It 'handles large sums' {
$vals = 1..1000
Get-Sum -Values $vals | Should -Be 500500
}
}
Keep each It to a single assertion when possible. When an assertion fails, you immediately know which behavior broke, enabling faster fixes.
AAA structure in practice
Describe 'Get-Sum - AAA style' {
It 'returns 0 for empty input (AAA)' {
# Arrange
$input = @()
# Act
$result = Get-Sum -Values $input
# Assert
$result | Should -Be 0
}
}
AAA makes your tests read like documentation and helps future you (or your teammates) understand the intent at a glance.
Running Tests Locally and in CI in Seconds
Local test workflow
Keep a short feedback loop with a few commands you run repeatedly:
# Run all tests with minimal noise
Invoke-Pester -CI
# Run only unit tests (tagging strategy below)
Invoke-Pester -Tag Unit -CI
# Run tests in parallel (Pester 5.3+)
Invoke-Pester -Tag Unit -CI -Parallel -Throttle 4
# Run a single file or specific test names
Invoke-Pester -Path tests\Unit\Get-Sum.Tests.ps1 -CI
Invoke-Pester -Path tests\Unit -TestName 'adds positive integers' -CI
Tips for speed:
- Prefer PowerShell 7+ for better performance and built-in Pester 5 on most CI images.
- Use
-CIto optimize output and fail fast for pipelines. - Pre-import modules your tests need in your
BeforeAllblocks to avoid repeated autoload overhead.
Tagging and layout for fast selection
Organize tests so you can run only what you need:
- Put unit tests under
tests/Unit, integration undertests/Integration. - Tag unit tests with
[Unit]and slower tests with[Integration]so you can select quickly in CI.
# Example tagging
Describe 'Get-Sum' -Tag Unit {
It 'adds positive integers' {
Get-Sum -Values @(2,3,5) | Should -Be 10
}
}
Lightweight Pester configuration
Use a configuration object for consistent local and CI runs:
$conf = New-PesterConfiguration
$conf.Run.Path = 'tests/Unit'
$conf.Run.Parallel.Enabled = $true
$conf.Output.Verbosity = 'Normal' # or 'Detailed' locally
$conf.TestResult.Enabled = $true
$conf.TestResult.OutputPath = 'TestResults.xml'
Invoke-Pester -Configuration $conf
This produces a JUnit-style result file you can publish from most CI systems.
GitHub Actions: run in seconds on every push
Here’s a minimal, fast workflow that installs Pester, runs your unit tests, and publishes results:
name: tests
on:
push:
branches: [ main ]
pull_request:
jobs:
pester:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PowerShell 7
uses: PowerShell/PowerShell@v1
with:
version: '7.4.x'
- name: Install Pester 5
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck
- name: Run unit tests (fast)
shell: pwsh
run: |
$conf = New-PesterConfiguration
$conf.Run.Path = 'tests/Unit'
$conf.Run.Parallel.Enabled = $true
$conf.TestResult.Enabled = $true
$conf.TestResult.OutputPath = 'TestResults.xml'
Invoke-Pester -Configuration $conf
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: pester-results
path: TestResults.xml
For Windows-specific behavior, add a matrix with windows-latest. For even faster runs, cache your $HOME/.local/share/powershell/Modules between jobs.
Testing with tiny fixtures (TestDrive)
When a unit really must touch the file system, isolate it in a disposable test drive:
Describe 'Export-Report - unit-ish with TestDrive' -Tag Unit {
BeforeEach {
$path = Join-Path TestDrive: 'report.txt'
}
It 'writes a header line' {
Export-Report -Path $path -Data @('a','b')
(Get-Content -Path $path -Raw) | Should -Match '^Header'
}
}
TestDrive: is fast, isolated, and automatically cleaned up by Pester, keeping your tests deterministic.
Mock only when necessary
For pure functions, avoid mocks entirely. If you must isolate external calls, mock at the edge and assert the outcomes, not the implementation:
Describe 'Get-Orders - isolates HTTP call' -Tag Unit {
BeforeAll {
Mock Invoke-RestMethod { @{ orders = @(1,2,3) } }
}
It 'returns order count' {
(Get-Orders).Count | Should -Be 3
}
}
Keep mocks minimal and scoped to the specific Describe or Context to avoid cross-test coupling.
Common speed killers and quick fixes
- Heavy module imports in each
Itblock → move imports toBeforeAll. - Network or disk I/O in unit tests → refactor to pure logic; use
TestDrive:or mocks for edges. - Verbose output → run with
-CIor set lower verbosity in config. - Non-determinism (randomness, time, env vars) → inject a clock/random source; seed randomness; pin environment variables in
BeforeAlland restore inAfterAll.
Refactor safely with confidence
When your suite runs in seconds, you’ll run it constantly. That enables safer refactors, clearer intent, faster fixes, and more reliable code. Edge cases break early; regressions don’t ship.
Checklist to build the habit
- Write a pure function for core logic before I/O.
- Add 3–5 unit tests: empty, null/invalid, happy path, boundary.
- Use AAA and one assertion per
Itwhen possible. - Run locally with
Invoke-Pester -Tag Unit -CIon every save. - Wire up CI so the same fast suite runs on every push.
Build testing habits you can trust. Read more patterns and recipes in the PowerShell Advanced CookBook: PowerShell Advanced CookBook →
What you get: safer refactors, clearer intent, faster fixes, reliable code.