Chapter 18Lesson 05~235 minutes

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.

TestabilityPure functionsDependency injectionArchitecture

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.

Testability is an architectural property
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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.