Skip to content

Testing matrix — homelab-dr-blueprint

This repo is mostly documentation (a DR/orchestration blueprint) plus 11 small PowerShell scripts, 3 Docker Compose files, and 4 Kubernetes learning manifests. There is no C#, JS/TS, or Python anywhere in this repo — the house "4-layer" standard (mocked unit + mutation + churn + automation/E2E) is adapted here, honestly, to what a docs-and-PowerShell blueprint actually is: Pester for every script + a markdown link-checker + manifest sanity checks, which is exactly the substitution the house build process calls for when a repo is docs-first. This document is that adaptation, made explicit so nobody mistakes "adapted" for "skipped."

Pillar Tool Status here Gate
Unit Pester 5+ Implemented (tests/Scripts.Tests.ps1) test CI job
Coverage Pester code coverage Implemented, floor 25% (see below) fails build under threshold
Mutation Stryker N/A for PowerShell (see below) substituted by coverage + churn
Churn / flake Pester 5x loop Implemented (churn CI job) fails if any run differs
Automation / E2E Real -WhatIf script invocations + child-process runs Implemented (tests/Hermetic.Tests.ps1, tests/ChildProcess.Tests.ps1) runs in CI
Docs test layer Markdown link-checker Implemented (tests/MarkdownLinks.Tests.ps1) runs in CI
Manifest sanity YAML/compose structural checks Implemented (tests/Manifests.Tests.ps1) runs in CI, docker-live checks -Skip without a daemon

Why this repo's shape forces adaptation

Every one of the 11 scripts is infra glue: it either needs a running Docker daemon (not just the CLI), Administrator elevation, a real WSL2 distro/SSH target, or actual multi-machine hardware (Swarm join, k3s server+agent, cross-node failover) to do its real work. None of that is available, or safe to invoke unattended, in a CI runner or a dev shell. The brief's own escape hatch — "if structurally impossible (heredocs, network-only branches), set an honest lower floor and justify it" — is exactly this repo's situation, script by script:

Script What blocks full exercise
Deploy-ComposeStack.ps1 docker compose config runs unconditionally against a live daemon, even under -WhatIf
Backup-PostgresWarmStandby.ps1 needs the docker CLI (checked hermetically), but the actual dump needs a running container
Test-PostgresRestore.ps1 same: docker CLI check is hermetic, the restore itself needs a live daemon
Init-Swarm.ps1 docker info against a live daemon; safe without one, but does nothing interesting
Deploy-SwarmStack.ps1 requires this node to already be a Swarm manager
Set-HomelabHostsAliases.ps1 requires Administrator; real write path is C:\Windows\...\hosts
Install-K3sServer.ps1, Join-K3sAgent.ps1, Get-K3sKubeconfig.ps1, Teardown-K3s.ps1 need a real Linux node (WSL2 distro or SSH-reachable VM) — explicitly marked # REQUIRES REAL HARDWARE in-script
Test-NodeResolution.ps1 not blocked, but calls PowerShell's exit unconditionally, so it can only be tested as a child process (see below)
Promote-PostgresStandby.ps1 orchestrates all of the above; happens to be the one script whose -WhatIf path is fully hermetic (see below)

Unit (Pester 5+)

tests/Scripts.Tests.ps1 is hermetic — no Docker, no elevation, no network. It asserts, for every shipped .ps1 (11 scripts across foundation/, compose/scripts/, compose/swarm/, kubernetes/scripts/tests/*.ps1 itself is excluded from these structural checks, though it IS covered by the secret-literal scan below):

  • parses with zero AST errors ([System.Management.Automation.Language.Parser]::ParseFile);
  • no secret-shaped literal anywhere in the repo — council-token, Bearer <token>, AWS AKIA..., sk-..., GitHub gh[a-z]_..., Slack xox[a-z]-..., PEM private-key headers — scanned across every .ps1, .md, .yml/.yaml, .sql, .env, .env.example, and .json file, including archive/ (currently empty; the check still runs so it fires the moment something lands there);
  • every mutating script (12 of them) declares [CmdletBinding(SupportsShouldProcess = $true)], so -WhatIf is always available;
  • every script that needs real multi-machine hardware carries an in-script # REQUIRES REAL HARDWARE banner (this caught a real doc gap — see "Bugs this suite caught" below);
  • every script has comment-based help with a .SYNOPSIS block.

Run locally:

Invoke-Pester -CI -Path tests/

Coverage floor: 25%, not the house 70%

Pester's -CodeCoverage only credits lines that actually execute in the same PowerShell runspace as Pester itself (via the call operator &, not a child process). Given the table above, only a subset of each script's lines are reachable that way:

  • Set-PrimaryMarker.ps1 (SetPrimary/ClearPrimary branches — the Get branch always calls exit and is child-process-only), Promote-PostgresStandby.ps1 (fully hermetic under -WhatIf — see below), Install-K3sServer.ps1, Join-K3sAgent.ps1, Get-K3sKubeconfig.ps1, Teardown-K3s.ps1 (all -WhatIf-hermetic, no real hardware touched), Init-Swarm.ps1, Backup-PostgresWarmStandby.ps1, Test-PostgresRestore.ps1 (docker-CLI-gated, -WhatIf covers the pre-flight/logging lines, not the actual dump/restore), Deploy-SwarmStack.ps1 (covers the manager-check guard clause), and Set-HomelabHostsAliases.ps1 (-WhatIf, redirected to a scratch hosts file) are all included in the coverage run (tests/Invoke-CI.ps1's $cfg.CodeCoverage.Path list).
  • Deploy-ComposeStack.ps1 is excluded from the coverage-credited set entirely (its -WhatIf path still calls docker compose config against a live daemon, which isn't available in every dev/CI environment) — it is still parse-checked and structurally checked, just not coverage-instrumented.
  • Test-NodeResolution.ps1 is excluded from the coverage-credited set on purpose: it always calls exit, so it is tested via a real child process (tests/ChildProcess.Tests.ps1), and a child process's execution is invisible to Pester's in-process coverage tracer. It would only ever show as 0% if included, which would just be noise.

Measured coverage on the credited set, at time of writing: 34.3% (131 tests, all passing, run 3x locally with identical results — no flake observed). The floor in tests/Invoke-CI.ps1 is set to 25%, giving margin without pretending an infra-glue script can hit 70% without touching the infrastructure it glues.

pwsh -File tests/Invoke-CI.ps1                 # enforces the 25% floor
pwsh -File tests/Invoke-CI.ps1 -MinCoverage 0  # churn loop mode, no floor

Mutation — Stryker is N/A for PowerShell

Stryker has no PowerShell mutator. Stryker.NET (C#), StrykerJS (JS/TS), and Stryker4s (Scala) are the only supported targets, and this repo has none of those languages. Mutation coverage is substituted by the Pester line/branch coverage above plus the churn (flake) job below. Rule for any future non-PowerShell code added to this repo: it must run real Stryker.NET/StrykerJS with a real mutation-score threshold — this "N/A" applies only to the PowerShell-and-docs shape this repo has today.

Churn / flake detection

The churn CI job runs the full Pester suite 5 times and fails if any run's pass/fail/skip counts differ from the first. This matters more than usual here because several tests touch real (if fast-failing) infrastructure: Test-Connection to a deliberately-nonexistent alias in the Promote-PostgresStandby.ps1 test, DNS resolution in the Test-NodeResolution.ps1 child-process test, and temp-directory/GUID-based fixtures throughout. Run locally 3x with identical 131/131 pass results before this floor was set.

Automation / E2E — real invocations, not just parsing

Two files carry this pillar:

  • tests/Hermetic.Tests.ps1 invokes scripts in-process via the call operator (& $ScriptPath ..., never dot-sourced) with -WhatIf or a read-only -Action, using temp-directory fixtures so nothing touches real state (a scratch hosts file, a scratch marker path, a scratch backup dir with a fake LATEST.txt). This is what caught a real bug: see below.
  • tests/ChildProcess.Tests.ps1 runs Test-NodeResolution.ps1 and Set-PrimaryMarker.ps1 -Action Get as an actual child pwsh/powershell process, because both call PowerShell's exit statement unconditionally — calling exit inside a script invoked in-process (even via &, not just dot-sourcing) risks tearing down the whole Pester host. The child-process pattern is 100% safe at the cost of no coverage credit (see above).

Bugs this suite caught

  • Install-K3sServer.ps1 line 88 had $((if($Transport -eq 'wsl'){...})) — a double-wrapped subexpression that PowerShell parses as trying to invoke if as a command, throwing The term 'if' is not recognized... on every call, including -WhatIf. Parse-only checks (AST parsing) never caught this because the syntax is valid PowerShell; only an actual invocation surfaces it. Fixed to a single $(...). This is the concrete argument for "real -WhatIf invocation, not just parsing" as a test pillar.
  • Documentation gap: kubernetes/README.md's "Validation status" section claims every k3s script carries a # REQUIRES REAL HARDWARE banner; Get-K3sKubeconfig.ps1 and Teardown-K3s.ps1 didn't. Added, and the Scripts.Tests.ps1 structural check now enforces it going forward.

tests/MarkdownLinks.Tests.ps1 is this repo's substitute for "automation/E2E" on the documentation itself, since the primary deliverable here is documentation. It extracts every markdown-style link (bracketed link text immediately followed by a parenthesized target) from every .md file and asserts the relative target resolves to a real file on disk (external http(s):///mailto: links and same-file #anchor links are intentionally skipped — no network calls in a hermetic test). It also rejects bare TODO/TBD/FIXME/... placeholder link targets.

Manifest / compose sanity

tests/Manifests.Tests.ps1 checks, without Docker: no tab characters in any YAML file (YAML forbids them), the claude-mem stack declares its three expected services and a Postgres healthcheck, the sample-daemon stack and the Swarm stack are provably stateless (no volumes:, no known stateful image name), and every k3s learning manifest carries a visible not-for-prod/learning marker. A real docker compose config validation runs only when a Docker daemon is actually reachable (checked via docker info); otherwise it -Skips with a documented reason rather than failing the build in an environment with no live Docker Desktop.

Local quickstart

# full suite, no coverage floor
Invoke-Pester -CI -Path tests/

# with the coverage gate exactly as CI runs it
pwsh -File tests/Invoke-CI.ps1

# churn (what the CI churn job does, once)
pwsh -File tests/Invoke-CI.ps1 -MinCoverage 0