Design PowerShell That Can Be Tested—and Stopped
📦 Run it yourself — this post’s examples are in
companion/tests/Unit/Planning.Tests.ps1andProcessing.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:
- Inspect
- Decide
- Plan
- Approve
- Apply
- Verify
What you will learn
- Why testability and production safety are often the same design problem
- How to separate decisions from state changes
- How to return a change plan before applying it
- How to implement
SupportsShouldProcesscorrectly - What
-WhatIfproves and what it does not - How to verify that no state-changing dependency ran
The dangerous all-in-one function
Imagine a function that:
- Downloads a partner manifest.
- Decides whether the schema is supported.
- Publishes the payload.
- Sends the acknowledgement.
- 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 $processedIdsThe 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 $eligibilityFor 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:
- The production identity has permission
- The real API accepts the request
- The target still exists
- The payload is valid to the receiver
- The operation is reversible
- The follow-up verification works
- A nested script module honors
WhatIfPreference
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:$WhatIfPreferenceEven 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:
- A downstream queue rejects the item later
- A stale module handled the call
- The acknowledgement was accepted but never recorded
- Only part of the batch became visible
Design a separate verification operation:
$verification = Test-PfgPublishedBatch `
-BatchId $batch.BatchId `
-CorrelationId $correlationIdThis 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 $BatchIdNot 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:
- A result object for the decision
- A result object for the plan
- Correct
SupportsShouldProcessbehavior around the apply call - A Pester test proving
-WhatIfprevents the dependency call
Common mistakes
Treating
-WhatIfas an integration test. It does not prove the real operation succeeds.
Declaring
SupportsShouldProcesswithout callingShouldProcess. 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:
- Inspect
- Decide
- Plan
- Approve
- Apply
- Verify
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
- Everything you wanted to know about ShouldProcess
- PSScriptAnalyzer ShouldProcess rule
- Pester 6 mocking