TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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:

  1. Arrange: set up minimal inputs/fixtures.
  2. Act: invoke a single function under test.
  3. 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 -CI to optimize output and fail fast for pipelines.
  • Pre-import modules your tests need in your BeforeAll blocks 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 under tests/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 It block → move imports to BeforeAll.
  • Network or disk I/O in unit tests → refactor to pure logic; use TestDrive: or mocks for edges.
  • Verbose output → run with -CI or set lower verbosity in config.
  • Non-determinism (randomness, time, env vars) → inject a clock/random source; seed randomness; pin environment variables in BeforeAll and restore in AfterAll.

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 It when possible.
  • Run locally with Invoke-Pester -Tag Unit -CI on 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.

← All Posts Home →