Design PowerShell That Can Be Tested—and Stopped

PesterForge · July 2026 · 8 min read

📦 Run it yourself — this post’s examples are in companion/tests/Unit/Planning.Tests.ps1 and Processing.Tests.ps1.

Pester cannot rescue PowerShell code that was designed as one unbroken chain of discovery, decision, and destruction.

You can mock the chain. You can wrap it in Describe. You can produce a lovely XML result file. But if the function finds targets, decides what they mean, changes them immediately, swallows errors, and returns formatted text, the tests will spend most of their energy fighting the design.

Production-safe automation is easier to test because it is easier to stop.

The pattern we will use throughout this series is:

  1. Inspect
  2. Decide
  3. Plan
  4. Approve
  5. Apply
  6. Verify

What you will learn

The dangerous all-in-one function

Imagine a function that:

  1. Downloads a partner manifest.
  2. Decides whether the schema is supported.
  3. Publishes the payload.
  4. Sends the acknowledgement.
  5. Writes a log line.

All inside one loop.

function Start-PartnerBatch {
    param([string]$BatchId)

    $batch = Get-PartnerBatch -BatchId $BatchId

    if ($batch.SchemaVersion -notin @('2.0', '2.1')) {
        Write-Error 'Unsupported schema'
        return
    }

    Publish-PartnerBatch -Batch $batch
    Send-PartnerAcknowledgement -BatchId $BatchId
    Write-Host "Processed $BatchId"
}

This can be tested, but every useful test must intercept multiple dependencies and infer the decision from which command happened to be called.

The function also gives an operator no safe preview of the intended work. The first time production sees the full path may be the first time the code actually changes something.

Separate the decision

First, make eligibility its own operation:

$eligibility = Test-PfgBatchEligibility `
    -Batch $batch `
    -SupportedSchemas @('2.0', '2.1') `
    -ProcessedBatchIds $processedIds

The result is evidence:

BatchId : NW-1042
Status  : Rejected
Reasons : {UnsupportedSchema}

No publication has occurred. We can test this repeatedly and safely.

Build a plan

Next, convert an approved decision into an explicit plan:

$plan = New-PfgBatchPlan -Batch $batch -Eligibility $eligibility

For an approved batch, the plan might contain:

Sequence Action          Target
-------- ------          ------
1        Publish         NORTHWIND
2        Acknowledge     NORTHWIND
3        RecordEvidence  NW-1042

For a rejected batch:

Status     : Blocked
Operations : {}
Reasons    : {UnsupportedSchema}

This is more than a testing convenience. It gives operators, reviewers, and logs a stable description of the intended change before the change occurs.

Test the plan, not PowerShell itself

Describe 'New-PfgBatchPlan' -Tag 'Layer.Unit', 'Gate.PullRequest' {
    It 'creates an ordered plan for an approved batch' {
        $eligibility = [pscustomobject]@{
            Status  = 'Approved'
            Reasons = @()
        }

        $plan = New-PfgBatchPlan -Batch $batch -Eligibility $eligibility

        $plan.Status | Should-Be 'Ready'
        $plan.Operations.Action |
            Should-BeCollection @('Publish', 'Acknowledge', 'RecordEvidence')
    }

    It 'creates no operations for a rejected batch' {
        $eligibility = [pscustomobject]@{
            Status  = 'Rejected'
            Reasons = @('UnsupportedSchema')
        }

        $plan = New-PfgBatchPlan -Batch $batch -Eligibility $eligibility

        $plan.Status | Should-Be 'Blocked'
        $plan.Operations | Should-BeCollection @()
    }
}

We are not testing that arrays exist or that PowerShell can sort numbers. We are protecting an operational guarantee:

Rejected input produces no executable change plan.

Add SupportsShouldProcess

The applying function should support PowerShell’s standard safety interface:

function Invoke-PfgBatch {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)]
        [psobject]$Batch,

        [Parameter(Mandatory)]
        [psobject]$Eligibility
    )

    if ($Eligibility.Status -ne 'Approved') {
        return [pscustomobject]@{
            BatchId = $Batch.BatchId
            Status  = 'Blocked'
        }
    }

    if ($PSCmdlet.ShouldProcess(
        $Batch.BatchId,
        'Publish and acknowledge validated partner batch'
    )) {
        Publish-PfgBatch -Batch $Batch
        Send-PfgAcknowledgement -Batch $Batch
    }
}

Declaring SupportsShouldProcess adds the common -WhatIf and -Confirm parameters. That declaration is not enough by itself. The function must actually call $PSCmdlet.ShouldProcess() around the state-changing operation.

A function that advertises -WhatIf and then changes production outside the ShouldProcess block has created a false sense of safety.

Test that -WhatIf blocks the dependency

The useful test is not that PowerShell printed a sentence beginning with “What if.”

The useful test is that the state-changing command was not called.

Describe 'Invoke-PfgBatch' -Tag 'Layer.Unit', 'Risk.StateChanging' {
    BeforeEach {
        Mock -ModuleName PartnerFeedGuardian Get-PfgOperationState { 'NotStarted' }
        Mock -ModuleName PartnerFeedGuardian Set-PfgOperationState { }
        Mock -ModuleName PartnerFeedGuardian Publish-PfgBatch { }
        Mock -ModuleName PartnerFeedGuardian Send-PfgAcknowledgement { }
        Mock -ModuleName PartnerFeedGuardian Write-PfgEvidence { }
    }

    It 'does not call a state-changing adapter when WhatIf is used' {
        $result = Invoke-PfgBatch `
            -Batch $batch `
            -Eligibility $approved `
            -WhatIf

        $result.Status | Should-Be 'WhatIf'
        Should-NotInvoke -ModuleName PartnerFeedGuardian Publish-PfgBatch
        Should-NotInvoke -ModuleName PartnerFeedGuardian Send-PfgAcknowledgement
        Should-NotInvoke -ModuleName PartnerFeedGuardian Set-PfgOperationState
    }
}

This proves that, in this code path, the dependencies Pester intercepted were not invoked.

What -WhatIf does not prove

-WhatIf does not prove that:

It proves intended guarded behavior. It is not a substitute for integration testing.

PowerShell usually propagates -WhatIf through calls in the same scope and through binary cmdlets. Script-module-to-script-module calls are a known place where preference propagation should not be trusted blindly. When calling another module, be explicit where the command supports it:

Publish-ExternalBatch `
    -Batch $Batch `
    -WhatIf:$WhatIfPreference

Even then, test the boundary.

Place ShouldProcess close to the change

Do not ask once at the top of a function and then make twenty unrelated changes afterward.

This is too broad:

if ($PSCmdlet.ShouldProcess('Production', 'Apply everything')) {
    # Hundreds of lines and multiple independent changes
}

Prefer a clear target and action immediately around the change:

foreach ($operation in $plan.Operations) {
    if ($PSCmdlet.ShouldProcess($operation.Target, $operation.Action)) {
        Invoke-PfgOperation -Operation $operation
    }
}

The operator can understand what is being proposed, and the test can assert the exact calls.

Return results instead of narrating success

Write-Host is useful for humans. It is poor evidence for the next automation layer.

Return a stable object:

[pscustomobject]@{
    PSTypeName    = 'PartnerFeedGuardian.ProcessingResult'
    BatchId       = $Batch.BatchId
    CorrelationId = $CorrelationId
    Status        = 'Completed'
    Published     = $true
    Acknowledged  = $true
    Error         = $null
}

A deployment gate, test, monitoring adapter, or incident tool can consume that object without scraping prose.

You can still format it for humans later.

Approval is not the same as planning

A plan answers:

What would this command do?

Approval answers:

Is it allowed to do it now?

Do not hide approval inside a query that is difficult to test. Make it a clear boundary:

$plan = New-PfgBatchPlan -Batch $batch -Eligibility $eligibility
$approval = Get-PfgApproval -Plan $plan -ChangeId $ChangeId

if (-not $approval.Approved) {
    return [pscustomobject]@{
        Status = 'Blocked'
        Reason = 'ApprovalMissing'
    }
}

Then test that no apply dependency runs when approval is missing.

Verification is a separate operation

A successful command invocation is not proof of the final state.

The apply stage may return without an error while:

Design a separate verification operation:

$verification = Test-PfgPublishedBatch `
    -BatchId $batch.BatchId `
    -CorrelationId $correlationId

This makes post-deployment and production validation possible without rerunning the change itself.

The complete shape

A production command should read more like a controlled workflow than an improvised pipeline:

$inspection  = Get-PfgBatchInspection -BatchId $BatchId
$decision    = Test-PfgBatchEligibility -Batch $inspection.Batch @rules
$plan        = New-PfgBatchPlan -Batch $inspection.Batch -Eligibility $decision
$approval    = Get-PfgApproval -Plan $plan -ChangeId $ChangeId
$result      = Invoke-PfgBatch -Batch $inspection.Batch -Eligibility $decision
$verification = Test-PfgPublishedBatch -BatchId $BatchId

Not every project needs six public commands. Some stages can be private helpers. The important part is that the responsibilities remain separable and testable.

What these tests prove

The plan tests prove that approved and rejected inputs produce the intended operation list.

The -WhatIf test proves that the intercepted state-changing dependencies are not called in preview mode.

What they do not prove

They do not prove the real publication works, the production identity has access, or the final state is correct. Those claims belong to integration and verification suites later in the series.

Try it yourself

Take one state-changing function and mark each line as one of these:

Inspect
Decide
Plan
Approve
Apply
Verify
Presentation

If one function performs all seven, extract the decision and plan first.

Then add:

  1. A result object for the decision
  2. A result object for the plan
  3. Correct SupportsShouldProcess behavior around the apply call
  4. A Pester test proving -WhatIf prevents the dependency call

Common mistakes

Treating -WhatIf as an integration test. It does not prove the real operation succeeds.

Declaring SupportsShouldProcess without calling ShouldProcess. The switch exists, but the protection does not.

Putting the entire function inside one broad confirmation. Keep the target and action close to each change.

Returning only formatted text. Return structured evidence and format it separately.

Combining apply and verify. A successful request is not always a successful final state.

Recap

Pester works best when production PowerShell is designed in separable stages:

That design gives us fast decision tests, meaningful change-plan tests, enforceable -WhatIf behavior, and independent verification.

Next up: Part 3 — The Fast Gate: Decisions, Boundaries, and Regressions. We will build the suite that should run on every meaningful code change without contacting a production dependency.

Technical references


← All posts