TB

MoppleIT Tech Blog

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

Mock External Calls for Reliable PowerShell Tests with Pester

Slow, flaky tests are almost always caused by external dependencies: networks, disks, environment state, and time. The fastest path to stable, maintainable PowerShell tests is to isolate those dependencies behind small functions and replace them with mocks in your unit tests. In this guide, youll learn how to design your code for testability, use Pesters Mock to replace HTTP and file I/O, and focus your assertions on outcomes so refactors remain easy.

Why mock external calls?

Unit tests should be fast and deterministic. Real HTTP calls and disk writes introduce latency, flakiness, and state leakage. By mocking external calls you get:

  • Faster feedback: tests run in milliseconds instead of seconds.
  • Fewer regressions: tests fail for real logic changes, not network hiccups.
  • Safer refactors: assertions target behavior, not implementation details.
  • Clearer intent: tests document the contract of your functions.

Common targets for mocking include:

  • HTTP: Invoke-RestMethod, Invoke-WebRequest
  • File I/O: Get-Content, Set-Content, Test-Path, Get-ChildItem
  • Process and system: Start-Process, environment variables, time (Get-Date)

Design for testability: wrap I/O behind small functions

The foundation of reliable tests is a clean separation between pure logic and impure I/O. Keep your logic pure and wrap any external call behind a thin boundary you control. That boundary is what you mock in unit tests.

Pattern: your own HTTP wrapper

Instead of calling Invoke-RestMethod all over your code, create a tiny function. This gives you a single seam for mocks, logging, retries, and headers.

function Invoke-Api {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Uri,
    [hashtable] $Headers,
    [ValidateSet('GET','POST','PUT','PATCH','DELETE')] [string] $Method = 'GET',
    [object] $Body
  )
  $params = @{ Uri = $Uri; Method = $Method; ErrorAction = 'Stop' }
  if ($Headers) { $params.Headers = $Headers }
  if ($Body)    { $params.Body    = $Body }
  Invoke-RestMethod @params
}

function Get-User {
  [CmdletBinding()]
  param([Parameter(Mandatory)][int] $Id)
  $r = Invoke-Api -Uri ("/api/users/{0}" -f $Id)
  [pscustomobject]@{ Id = $r.id; Name = $r.name }
}

This keeps your domain logic small and lets your tests mock Invoke-Api rather than a built-in cmdlet. You still can mock Invoke-RestMethod if needed, but mocking your own seam increases flexibility.

Pattern: delegate I/O via parameters (optional)

For deeper isolation, inject the dependency as a parameter with a sensible default. This is a lightweight form of dependency injection without classes.

function Get-User {
  [CmdletBinding()]
  param(
    [int] $Id,
    [scriptblock] $Http = { param($u) Invoke-RestMethod -Uri $u -ErrorAction Stop }
  )
  $r = & $Http "/api/users/$Id"
  [pscustomobject]@{ Id = $r.id; Name = $r.name }
}

In tests, pass a stubbed scriptblock. In production, the default uses the real Invoke-RestMethod.

Pattern: isolate file I/O

Wrap reads/writes in tiny functions. Keep transformation logic separate and unit-testable.

function Save-Report {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][object] $Data)
  $json = $Data | ConvertTo-Json -Depth 5
  Set-Content -Path $Path -Value $json -Encoding UTF8
}

function Load-Report {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string] $Path)
  (Get-Content -Path $Path -Raw) | ConvertFrom-Json
}

Mocking with Pester: practical examples

Pesters Mock replaces commands within the scope of your test. You can control return values, throw errors, and filter which calls are replaced via -ParameterFilter. Focus your assertions on outcomes: returned objects, side effects in state, and observable behavior.

HTTP: map fields and handle errors

Here is a simple test that mocks the HTTP call and asserts behavior.

Describe 'Get-User' {
  It 'maps fields' {
    Mock Invoke-RestMethod { @{ id = 7; name = 'Grace' } }
    (Get-User 7).Name | Should -Be 'Grace'
  }

  It 'throws on error' {
    Mock Invoke-RestMethod { throw '404' }
    { Get-User 9 } | Should -Throw
  }
}

Prefer mocking your seam (e.g., Invoke-Api) when available:

Describe 'Get-User with Invoke-Api seam' {
  It 'builds the correct URI and maps fields' {
    Mock Invoke-Api -ParameterFilter { $Uri -eq '/api/users/7' } { @{ id = 7; name = 'Grace' } }
    $u = Get-User -Id 7
    $u | Should -BeOfType psobject
    $u.Id   | Should -Be 7
    $u.Name | Should -Be 'Grace'
  }

  It 'bubbles up HTTP failures' {
    Mock Invoke-Api { throw [System.Net.WebException]::new('Not Found') }
    { Get-User -Id 9 } | Should -Throw -Because 'Consumers should handle failures explicitly'
  }
}

Notice the assertions target outcomes, not whether a function was called N times. That keeps tests resilient to refactors (e.g., adding retries internally) as long as behavior remains the same.

File I/O: use TestDrive or Mock

For file operations you have two great options:

  • Use Pesters TestDrive: to exercise real file I/O in a disposable, isolated filesystem.
  • Mock the file cmdlets (Get-Content, Set-Content) when you want pure-of-I/O unit tests.

Using TestDrive: keeps tests close to reality while staying fast and isolated.

Describe 'Save-Report and Load-Report' {
  It 'writes and reads JSON round-trip using TestDrive' {
    $path = Join-Path TestDrive: 'report.json'
    $data = @{ Project = 'Apollo'; Success = $true; Items = 3 }
    Save-Report -Path $path -Data $data
    $roundTrip = Load-Report -Path $path
    $roundTrip.Project | Should -Be 'Apollo'
    $roundTrip.Success | Should -BeTrue
    $roundTrip.Items   | Should -Be 3
  }

  It 'surfaces disk write failures via mock' {
    Mock Set-Content { throw 'Disk full' }
    { Save-Report -Path 'C:\\out.json' -Data @{ X = 1 } } | Should -Throw
  }
}

Selective mocking with ParameterFilter

When the same command is used in multiple places, restrict the mock to just the call you care about:

Mock Invoke-Api -ParameterFilter { $Method -eq 'GET' -and $Uri -match '/api/users/\d+$' } { @{ id = 42; name = 'Ada' } }

This avoids accidentally altering other code paths and keeps tests focused.

Testing strategy: outcomes over calls

Resilient tests assert what matters to users of your code: returned data, emitted errors, and visible side effects. Call-count assertions (Assert-MockCalled) are sometimes necessary, but they tend to lock in implementation details. Prefer these patterns:

Good outcomes to assert

  • Return shape and values: types, required fields, transformations.
  • Error behavior: throws on failure, specific error types/ids/messages when relevant.
  • State changes: files written to expected paths, content format, idempotency.

When to assert calls

  • External effects must be prevented: ensure a function never reaches the network by asserting your seam was called instead of a raw cmdlet.
  • Auditing or logging paths where you must guarantee an event was emitted.
It 'does not call raw Invoke-RestMethod (safety net)' {
  Mock Invoke-RestMethod { throw 'Should not be called directly' }
  Mock Invoke-Api { @{ id = 5; name = 'Lin' } }
  (Get-User -Id 5).Name | Should -Be 'Lin'
}

Putting it all together

Heres a compact example that shows the approach end-to-end.

function Get-User {
  param([int] $Id)
  $r = Invoke-RestMethod -Uri ('/api/users/{0}' -f $Id) -ErrorAction Stop
  [pscustomobject]@{ Id = $r.id; Name = $r.name }
}

Describe 'Get-User' {
  It 'maps fields' {
    Mock Invoke-RestMethod { @{ id = 7; name = 'Grace' } }
    (Get-User 7).Name | Should -Be 'Grace'
  }
  It 'throws on error' {
    Mock Invoke-RestMethod { throw '404' }
    { Get-User 9 } | Should -Throw
  }
}

Thats fast, isolated, and focused on behavior. For production code, prefer adding a wrapper seam (e.g., Invoke-Api) so your tests dont depend on mocking built-in cmdlets everywhere.

Practical tips and pitfalls

  • Scope your mocks: in Pester v5, mocks apply to the scope where theyre defined. Use InModuleScope to mock private functions inside a module, and -ModuleName to target a specific module function name.
  • Reset between tests: each It block is isolated, but if you set state in BeforeAll, ensure you clean up in AfterAll or use BeforeEach for per-test setup.
  • Prefer TestDrive: over temp folders for file tests. Its fast and automatically cleaned up.
  • Make seams tiny: small wrapper functions (one purpose, minimal parameters) are easier to mock and reason about.
  • Keep examples realistic: mocking should return data shaped like real responses. Capture a sample once and use it in tests.
  • Measure what matters: add a few integration tests that hit real services or disks behind feature flags or a separate pipeline stage; keep unit tests fully mocked.

Benefits youll see

  • Fewer regressions: changes in HTTP contracts or file formats are caught by focused tests.
  • Faster feedback: developers get answers in milliseconds, not minutes.
  • Safer refactors: you can reorganize internals while preserving observable behavior.
  • Clearer tests: intent is codified in outcomes, not incidental call graphs.

Hone your PowerShell testing practice by embracing small seams, Pester Mock, and outcome-based assertions. Your tests will be faster, clearer, and more reliableand your refactors will be far less stressful.

Further reading: PowerShell Advanced Cookbook  https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →