Design for Testability: Pure Logic, Side-Effect Boundaries, and Dependency Injection
Refactor monolithic PowerShell into pure logic, explicit side-effect adapters, injected dependencies, stable result contracts, idempotent operations, and a project structure that stays easy to test and analyze.
Learning objectives
- Identify hidden dependencies and testability problems in monolithic automation.
- Separate input parsing, pure transformations, adapters, and orchestration.
- Use simple dependency injection for clients, clocks, paths, and configuration.
- Design stable structured output and explicit failure contracts.
- Explain how idempotency and WhatIf complement automated testing.
- Complete a mini-project with Pester tests, analyzer checks, and a maintainability review.
1. Testability is an architectural property
If a script reads global variables, parses input, calls three APIs, writes files, mutates services, logs, and formats terminal output inside one giant function, testing becomes expensive because every behavior is entangled with every side effect. Testability means important behavior can be exercised with controlled inputs and observable outputs without recreating the entire production environment.
The goal is not “write code for tests.” The goal is to make responsibilities explicit. That generally improves maintainability, incident diagnosis, reuse, and change safety even before a test is written.
flowchart TD;
I["Input parsing"] --> P["Pure planning / transformation"];
P --> O["Orchestration"];
O --> A["External-effect adapters"];
A --> S["System / API / Files"];
O --> R["Stable result object"];
2. A monolithic script hides dependencies
Consider this intentionally compressed example. It reads configuration, builds an API body, contacts a service, writes a report, and emits display text in one place. To unit-test the planning rule, you would also have to control disk, network, and current environment state.
# Monolithic shape -- shown for refactoring, not recommended architecture.
param([string]$ConfigPath,[string]$ApiUri)
$config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json
$body = @{
app = $config.name
replicas = if ($config.environment -eq 'prod') { 3 } else { 1 }
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri $ApiUri -Method Post -Body $body -ContentType 'application/json'
$response | ConvertTo-Json | Set-Content -LiteralPath './last-deploy.json'
Write-Host "Deployment submitted for $($config.name)"
The business rule “production gets three replicas” is buried between unrelated effects. The first refactoring target is to extract that rule into pure logic.
3. Move deterministic business logic into a pure function
A pure planning function should accept ordinary values/objects and return a stable object. It should not know where configuration came from or how the deployment will be sent.
function ConvertTo-DeploymentPlan {
[CmdletBinding()]
param([Parameter(Mandatory)]$Config)
if ([string]::IsNullOrWhiteSpace($Config.name)) {
throw 'Config.name is required.'
}
$replicas = if ($Config.environment -eq 'prod') { 3 } else { 1 }
[pscustomobject]@{
App = [string]$Config.name
Environment = [string]$Config.environment
Replicas = $replicas
}
}
$config = [pscustomobject]@{name='api';environment='prod'}
ConvertTo-DeploymentPlan -Config $config
This function can be tested with in-memory objects. No test needs a network or temporary file just to verify replica selection and validation.
4. Put side effects behind small adapters
An adapter is a small function that translates your domain operation into an external command or API. The adapter is where retries, authentication injection, filesystem semantics, or platform-specific behavior belongs.
function Send-DeploymentPlan {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Plan,
[Parameter(Mandatory)][uri]$Uri,
[hashtable]$Headers = @{}
)
$body = $Plan | ConvertTo-Json -Depth 4
Invoke-RestMethod -Uri $Uri -Method Post -Headers $Headers -Body $body -ContentType 'application/json'
}
function Save-DeploymentReport {
[CmdletBinding()]
param([Parameter(Mandatory)]$Result,[Parameter(Mandatory)][string]$Path)
$Result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $Path
Get-Item -LiteralPath $Path
}
Adapters can have focused integration tests. Most business rules should stay outside them.
5. Dependency injection means passing a capability instead of hiding it
Dependency injection sounds architectural, but the PowerShell idea is small: if orchestration needs a sender, clock, path, or client, pass it in rather than reaching for a global singleton or hard-coded endpoint. The caller decides which implementation to provide.
function Invoke-DeploymentWorkflow {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$Config,
[Parameter(Mandatory)][scriptblock]$Sender,
[scriptblock]$Clock = { [datetime]::UtcNow }
)
$started = & $Clock
try {
$plan = ConvertTo-DeploymentPlan -Config $Config
$remote = & $Sender $plan
[pscustomobject]@{
Success = $true
Plan = $plan
Remote = $remote
StartedAt = $started
Error = $null
}
} catch {
[pscustomobject]@{
Success = $false
Plan = $null
Remote = $null
StartedAt = $started
Error = $_.Exception.Message
}
}
}
Production can inject a sender that calls
Invoke-RestMethod. A unit test can inject a script
block that returns a fixed object. The orchestration contract is
identical.
6. Injected dependencies make orchestration tests small and deterministic
Describe 'Invoke-DeploymentWorkflow' {
It 'returns a stable success record without a network call' {
$config = [pscustomobject]@{name='api';environment='prod'}
$sender = { param($plan) [pscustomobject]@{id='deploy-123';accepted=$true} }
$clock = { [datetime]'2026-08-11T12:00:00Z' }
$result = Invoke-DeploymentWorkflow -Config $config -Sender $sender -Clock $clock
$result.Success | Should-BeTrue
$result.Plan.Replicas | Should-Be 3
$result.Remote.id | Should-Be 'deploy-123'
$result.StartedAt | Should-Be ([datetime]'2026-08-11T12:00:00Z')
}
It 'returns an explicit failure contract when the dependency fails' {
$sender = { param($plan) throw 'simulated API outage' }
$result = Invoke-DeploymentWorkflow -Config ([pscustomobject]@{name='api';environment='dev'}) -Sender $sender
$result.Success | Should-BeFalse
$result.Error | Should-MatchString 'simulated API outage'
}
}
The tests do not know whether the production sender uses REST, remoting, a queue, or a cloud SDK. They verify the orchestration contract.
7. Stable output contracts make scripts composable and testable
Returning ad hoc strings forces callers and tests to parse
presentation text. Prefer small structured records with explicit
fields such as Success, Target,
Changed, Duration, and Error.
The UI can format them later.
function New-OperationResult {
param([string]$Target,[bool]$Success,[bool]$Changed,[string]$Message)
[pscustomobject]@{
Target = $Target
Success = $Success
Changed = $Changed
Message = $Message
}
}
$result = New-OperationResult -Target 'api' -Success $true -Changed $false -Message 'Already converged'
$result | ConvertTo-Json -Compress
A stable object contract helps Pester assertions, JSON output, logging, CI summaries, and downstream PowerShell pipelines all consume the same semantics.
8. Idempotency and WhatIf reduce the cost of safe testing
An idempotent operation converges to the desired
state without making unnecessary repeated changes.
SupportsShouldProcess adds -WhatIf/-Confirm
preview semantics for state changes. Both properties make automation
easier to test because “no change needed” and “preview only” become
explicit states.
function Set-TrainingConfig {
[CmdletBinding(SupportsShouldProcess)]
param([string]$Path,[string]$DesiredContent)
$current = if (Test-Path -LiteralPath $Path) {
Get-Content -LiteralPath $Path -Raw
} else { $null }
if ($current -eq $DesiredContent) {
return [pscustomobject]@{Path=$Path;Changed=$false;Reason='AlreadyDesired'}
}
if ($PSCmdlet.ShouldProcess($Path,'Write desired training configuration')) {
Set-Content -LiteralPath $Path -Value $DesiredContent -NoNewline
return [pscustomobject]@{Path=$Path;Changed=$true;Reason='Updated'}
}
[pscustomobject]@{Path=$Path;Changed=$false;Reason='WhatIfOrDeclined'}
}
Unit tests can verify the decision logic; TestDrive integration
tests can verify filesystem behavior; -WhatIf provides
an additional operational safety contract.
9. Organize source, tests, fixtures, and integration work by responsibility
One reasonable project shape keeps production source separate from tests and test data. There is no single mandatory layout; the important point is that boundaries are visible.
MyAutomation/
├── src/
│ ├── MyAutomation.psm1
│ ├── Public/
│ └── Private/
├── tests/
│ ├── unit/
│ ├── integration/
│ └── fixtures/
├── build/
│ └── Test-Quality.ps1
├── artifacts/ # generated, normally ignored by Git
└── PSScriptAnalyzerSettings.psd1
Module structure from Chapter 15 fits naturally here: public functions form the supported surface, private helpers remain internal, and tests target behavior at the narrowest useful boundary.
10. Mini-project — assemble a maintainable deployment planner
The capstone combines the chapter's ideas: pure planning, an injected sender, stable results, Pester tests, analyzer checks, and one repeatable quality command.
# 1. Pure logic
$config = [pscustomobject]@{name='api';environment='prod'}
$plan = ConvertTo-DeploymentPlan -Config $config
# 2. Controlled local sender for development/testing
$fakeSender = {
param($p)
[pscustomobject]@{id="local-$($p.App)";accepted=$true}
}
# 3. Orchestration
$result = Invoke-DeploymentWorkflow -Config $config -Sender $fakeSender
$result
# 4. Quality checks
Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1
Invoke-Pester -Path ./tests -Output Detailed
Production deployment changes only the adapter injection. The planner and orchestration tests remain useful because their contracts do not depend on the external implementation.
11. Review maintainability with questions, not line-count dogma
| Review question | Healthy sign |
|---|---|
| Can I test important logic without network/files/admin rights? | Pure logic and injected adapters exist. |
| Are external effects obvious? | They live in small named adapters. |
| Can callers rely on outputs? | Functions return documented structured contracts. |
| Can a failed dependency be simulated? | Tests inject or mock controlled failures. |
| Can a state change be previewed? | SupportsShouldProcess/WhatIf is used where appropriate. |
| Can quality run locally and in CI? | One repository quality script owns the gate. |
| Are platform differences isolated? | Windows/Linux/macOS adapters are separated from shared logic. |
12. Common design-for-testability mistakes
| Mistake | Consequence | Better design |
|---|---|---|
| Hidden globals and hard-coded endpoints | Tests require process-wide mutation or real infrastructure. | Pass configuration/clients explicitly. |
| Catch everything and return “OK” strings | Failures become ambiguous and unassertable. | Use explicit structured success/error contracts. |
| Mock internal helpers heavily | Tests lock implementation in place. | Test public behavior and mock external boundaries. |
| One function owns every responsibility | Every test needs every dependency. | Split parsing, pure logic, adapters, and orchestration. |
| Treat WhatIf as a unit-test replacement | Preview does not prove behavior or rollback. | Use WhatIf plus Pester plus disposable integration tests. |
13. Lab — complete the chapter quality loop
Use the module/workspace from the earlier lessons and complete these
steps: place pure planning logic under src; move
filesystem/network calls into adapters; inject the remote sender and
clock; add Pester tests for success and controlled failure; add one
TestDrive integration test; run PSScriptAnalyzer with project
settings; then run the Chapter 18 quality script.
# Expected local quality sequence
Import-Module PSScriptAnalyzer
Import-Module Pester
$diagnostics = @(Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1)
$tests = Invoke-Pester -Path ./tests -PassThru -Output Detailed
[pscustomobject]@{
AnalyzerDiagnostics = $diagnostics.Count
Tests = $tests.TotalCount
FailedTests = $tests.FailedCount
MaintainabilityGate = ($diagnostics.Count -eq 0 -and $tests.FailedCount -eq 0)
}
Do not optimize for a particular count of functions or tests. The review succeeds when important logic is isolated, side effects are explicit, failure contracts are testable, and the quality command is reproducible.
14. Verification checklist
- You can identify hidden dependencies in a monolithic script.
- You can extract pure transformations from external-effect code.
- You can define small adapters around APIs/files/platform commands.
- You can inject script blocks/clients/clocks instead of hiding globals.
- You can return stable structured success/failure contracts.
- You can combine Pester, PSScriptAnalyzer, WhatIf, and project layout into a maintainable workflow.
15. Knowledge check
Question 1. What does dependency injection mean in simple PowerShell terms?
Question 2. Why separate pure logic from adapters?
Question 3. Why prefer structured output over status strings?
Question 4. How does idempotency improve testability?
Question 5. Does SupportsShouldProcess replace tests?
16. Chapter summary and next bridge
Chapter 18 made quality part of PowerShell development: Pester 6 expresses behavior, mocks and TestDrive isolate effects, PSScriptAnalyzer catches static risks, coverage and reports feed CI, and testable architecture keeps those practices affordable as the codebase grows.
Chapter 19 takes this maintainable automation into the wider DevOps toolchain: Git repositories, CI runners, Docker, Kubernetes CLIs, cloud modules, artifacts, and release orchestration.
17. Authoritative references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.