TB

MoppleIT Tech Blog

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

Reliable Unit Tests with Pester: Behavior-Driven, Fast, and Safe Refactors

Great unit tests let you move fast without breaking things. With Pester, you can prove behavior before you refactor, keep tests small and focused, and avoid slow, flaky dependencies like the network or filesystem. The result is fewer regressions, faster refactors, clearer contracts, and safer releases. This post shows how to write behavior-driven, reliable Pester tests that fit naturally into your day-to-day PowerShell development.

Core Principles for Reliable Pester Tests

Prove behavior before you refactor

  • Write or update tests that describe the behavior you want to protect.
  • Refactor the implementation while keeping tests green.
  • Let tests serve as executable specifications and change detection.

Keep tests small and focused

  • One behavior per test: when the test fails, the reason should be obvious.
  • Prefer fast in-memory checks: strings, objects, small lists.
  • Split large scenarios into multiple, independent examples.

Use Arrange-Act-Assert (AAA)

  • Arrange: set up inputs and environment.
  • Act: run the unit under test.
  • Assert: state expected outcomes with Should.

Name tests for intent, not implementation

  • Good: returns a greeting for a name (describes observable behavior).
  • Poor: calls FormatName helper (locks you to internal structure and breaks on refactor).

Avoid touching the network and global state

  • Mock I/O: network, filesystem, environment, time, random.
  • Use TestDrive:\ for temporary files and Mock for external commands.
  • Separate pure logic from boundary code so you can test logic easily.

Behavior-First Examples with Pester

Example 1: The simplest happy path

This tiny function shows a straightforward AAA layout and an intent-revealing test name.

function Get-Greeting {
  param([Parameter(Mandatory)][string]$Name)
  "Hello, $Name"
}

Describe 'Get-Greeting' {
  It 'returns a greeting for a name' {
    # Arrange
    $name = 'Morten'
    # Act
    $res = Get-Greeting -Name $name
    # Assert
    $res | Should -Be 'Hello, Morten'
  }

  It 'does not throw on empty input' {
    { Get-Greeting -Name '' } | Should -Not -Throw
  }
}

Notes:

  • Test names describe what you expect, not how the function is implemented.
  • There is no external dependency—fast, reliable, and deterministic.

Example 2: Avoid network flakiness with Mock

When your function calls external services, mock them so your tests remain fast and offline.

function Get-UserName {
  param([Parameter(Mandatory)][string]$Uri)
  $res = Invoke-RestMethod -Method Get -Uri $Uri
  return $res.name
}

Describe 'Get-UserName' -Tag 'Unit' {
  It 'returns the name from the API response without hitting the network' {
    # Arrange: prevent real HTTP calls and return predictable data
    Mock -CommandName Invoke-RestMethod \
      -ParameterFilter { $Uri -eq 'https://api.example.com/users/42' } \
      -MockWith { [pscustomobject]@{ name = 'Morten' } }

    # Act
    $name = Get-UserName -Uri 'https://api.example.com/users/42'

    # Assert
    $name | Should -Be 'Morten'
    Assert-MockCalled Invoke-RestMethod -Times 1 \
      -ParameterFilter { $Uri -eq 'https://api.example.com/users/42' }
  }
}

Tips:

  • Mock only at the boundary (Invoke-RestMethod, message queues, databases).
  • Return minimal objects that match the shape your function expects.
  • Use Assert-MockCalled to verify interaction if the behavior depends on it (for example, retries or different branches).

Example 3: Filesystem isolation with TestDrive

TestDrive:\ gives you a fresh, disposable filesystem sandbox per test. No cleanup needed.

function Write-Report {
  param(
    [Parameter(Mandatory)][string]$Path,
    [Parameter(Mandatory)][string]$Content
  )
  Set-Content -Path $Path -Value $Content -Encoding UTF8
}

Describe 'Write-Report' -Tag 'Unit' {
  It 'writes content to an isolated path' {
    # Arrange
    $path = 'TestDrive:\\report.txt'

    # Act
    Write-Report -Path $path -Content 'OK'

    # Assert
    (Get-Content -Path $path -Raw) | Should -Be 'OK'
  }
}

Choosing the right assertions

  • Should -Be: exact value comparison.
  • Should -BeExactly: case-sensitive string compare.
  • Should -Match: regex on strings.
  • Should -BeNullOrEmpty: null or empty string/collection.
  • Should -HaveCount: collection length.
  • { ... } | Should -Throw / -Not -Throw: error behavior.

Structuring tests for clarity

  • One Describe per function or feature. Use Context for scenarios and It for individual behaviors.
  • Prefer Given-When-Then in names: Given invalid JSON, when parsing, then an informative error is thrown.
  • Co-locate tests with code: src\Module.psm1 and tests\Module.Tests.ps1 keep navigation obvious.

From Local to CI: Fast Feedback at Scale

Run and filter tests locally

Tag your tests so you can run quick unit tests on every save and defer slower integration tests:

# Run only unit tests
Invoke-Pester -Configuration @{ Run = @{ Path = 'tests'; Tag = 'Unit' } }

# Run everything, but exclude Integration
Invoke-Pester -Configuration @{ Run = @{ Path = 'tests'; ExcludeTag = 'Integration' } }

Collect results and (optional) coverage

Most CI systems can ingest NUnit XML. Pester 5 lets you emit test results and enable simple coverage:

Invoke-Pester -Configuration @{
  Run = @{ Path = 'tests' }
  TestResult = @{
    Enabled = $true
    OutputPath = 'TestResults.xml'
    OutputFormat = 'NUnitXml'
  }
  CodeCoverage = @{
    Enabled = $true
    Path = @('src\\*.ps1', 'src\\*.psm1')
  }
}

Coverage is most useful to spot untested critical paths, not as a vanity metric. Prioritize behaviors that guard risky or complex code.

Example: GitHub Actions workflow

This minimal workflow sets up PowerShell, installs the latest Pester 5, and publishes results:

name: tests
on: [push, pull_request]
jobs:
  pester:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup PowerShell
        uses: PowerShell/PowerShell@v1
        with:
          pwsh-version: '7.4.x'
      - name: Install Pester
        shell: pwsh
        run: |
          Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck
          Import-Module Pester -Force
      - name: Run unit tests
        shell: pwsh
        run: |
          Invoke-Pester -Configuration @{
            Run = @{ Path = 'tests'; Tag = 'Unit' }
            TestResult = @{
              Enabled = $true
              OutputPath = 'TestResults.xml'
              OutputFormat = 'NUnitXml'
            }
          }
      - name: Upload test results
        uses: actions/upload-artifact@v4
        with:
          name: pester-results
          path: TestResults.xml

Practical Habits that Pay Off

  • Design for testability: inject dependencies (e.g., URIs, paths) via parameters so you can swap them in tests.
  • Keep logic pure where possible: pure functions are trivial to test and refactor.
  • Mock only boundaries: don’t mock your own internal helpers unless the behavior depends on interaction.
  • Use InModuleScope to test module internals when needed, but prefer testing through public functions.
  • Stabilize flaky tests immediately: remove external calls, add TestDrive:\, fix race conditions, and tighten assertions.
  • Commit to intent-revealing names: the test name is documentation for your future self and teammates.

Adopting these practices will help you build a disciplined feedback loop: you capture the intended behavior in a small, fast test; you refactor with confidence; and your CI enforces the contract on every change.

Build testable PowerShell habits. Read the PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

What you get: fewer regressions, faster refactors, clearer contracts, safer releases.

← All Posts Home →