TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Comment-Based Help That Scales in PowerShell: Example-Driven, Tested, and CI-Friendly

You can turn your PowerShell functions into self-serve tools by writing concise, example-driven comment-based help and verifying it continuously. When your help is clear and your examples run as written, teammates succeed on the first try, onboarding gets faster, and support questions drop. This post shows how to author scalable help, keep examples runnable, and wire up CI to prevent regressions.

Design comment-based help that scales

The anatomy of great comment-based help

Place a help block directly above your function using <# ... #>. Focus on a crisp .SYNOPSIS, accurate .PARAMETER sections, and runnable .EXAMPLEs. Include .OUTPUTS when the function returns objects. Keep sentences short and imperative.

function Get-ServiceStatus {
  <#
  .SYNOPSIS
  Check the status of a Windows service quickly.

  .DESCRIPTION
  Returns a concise object describing the service by name. Use it in scripts or pipelines to gate deployments
  and troubleshoot startup issues.

  .PARAMETER Name
  The service name (not display name). Accepts pipeline input by property name (Name) for bulk checks.

  .EXAMPLE
  Get-ServiceStatus -Name 'Spooler'
  Queries the Print Spooler service and returns status, showing the minimal usage.

  .EXAMPLE
  'Spooler','Dhcp' | ForEach-Object { [pscustomobject]@{ Name = $_ } } | Get-ServiceStatus
  Demonstrates pipeline-by-property-name for checking multiple services.

  .OUTPUTS
  PSCustomObject

  .NOTES
  Run Get-Help Get-ServiceStatus -Full for details and parameter notes.
  #>
  [CmdletBinding()]
  param(
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [ValidateNotNullOrEmpty()]
    [Alias('ServiceName')]
    [string]$Name
  )
  process {
    try {
      $svc = Get-Service -Name $Name -ErrorAction Stop
      [pscustomobject]@{
        Name    = $svc.Name
        Status  = $svc.Status
        CanStop = $svc.CanStop
      }
    } catch {
      throw [System.ArgumentException]::new("Service '$Name' was not found.")
    }
  }
}

# Quick validation during development
Get-Help Get-ServiceStatus -Full | Out-Null
Get-Help Get-ServiceStatus -Examples | Out-Null

Style guidelines that reduce confusion

  • .SYNOPSIS: one sentence, 80-120 chars, start with a verb (“Check”, “Get”, “Set”).
  • .DESCRIPTION: 2-4 sentences explaining when to use the function and the shape of the output.
  • .PARAMETER: write what the user must supply, accepted formats, and gotchas. Avoid restating the parameter name.
  • .EXAMPLE: show real commands that copy/paste and run. Prefer one behavior per example. Comment the intent in one line.
  • .OUTPUTS: state the type (e.g., PSCustomObject) and important properties.
  • Consistency: reuse nouns and verbs across your tooling so Get-* and Set-* behave predictably.

Keep examples runnable and current

If your examples rot, users lose trust quickly. Treat examples as tests: parse them from help and run them in CI. When an API or parameter changes, the failing example pinpoints what to fix.

Harvest and execute .EXAMPLEs with Pester

The pattern below extracts examples from Get-Help and executes them. Use Skip logic for OS-specific examples, and prefer environment-driven values so CI remains deterministic.

# Tests/Help.Examples.Tests.ps1
param(
  [string]$CommandName = 'Get-ServiceStatus'
)

# Import your module if applicable
# Import-Module ./out/MyModule/MyModule.psd1 -Force

Describe "Help examples for $CommandName" -Tag 'help' {
  # Arrange: ensure a predictable value for CI
  if (-not (Test-Path Env:TEST_SERVICE)) { $env:TEST_SERVICE = 'Spooler' }

  $help = Get-Help $CommandName -Full
  It "has a synopsis" { $help.Synopsis | Should -Not -BeNullOrEmpty }
  It "has at least one example" { $help.examples.example.Count | Should -BeGreaterThan 0 }

  foreach ($ex in $help.examples.example) {
    $title = ($ex.title | Out-String).Trim()
    $code  = ($ex.code  | Out-String).Trim()

    It "example runs: $title" -Skip:(-not $IsWindows) {
      # Optionally patch OS-specific literals to env-driven values
      $patched = $code -replace "'Spooler'", "'$env:TEST_SERVICE'"

      # Act
      $result = Invoke-Expression $patched

      # Assert: just verify it didn't throw; add stricter checks per command
      $PSItem.Exception | Should -BeNullOrEmpty
    }
  }
}

Tips for resilient examples:

  • Avoid destructive examples; show -WhatIf variants when supported.
  • Prefer environment-driven placeholders ($env:TEST_SERVICE) over hard-coded values in CI.
  • For cross-platform modules, mark Windows-only tests with -Skip:(-not $IsWindows).
  • When examples emit objects, assert on shape (properties) rather than exact content.

Wire into GitHub Actions CI

Run Pester and fail the build if help or examples are missing or broken.

# .github/workflows/help-ci.yml
name: help-ci
on:
  push:
  pull_request:
jobs:
  test-help:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install PowerShell modules
        shell: pwsh
        run: |
          Set-PSRepository PSGallery -InstallationPolicy Trusted
          Install-Module Pester -Scope CurrentUser -Force
          Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
      - name: Lint scripts
        shell: pwsh
        run: |
          Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./ScriptAnalyzerSettings.psd1 -ReportSummary
      - name: Test help examples
        shell: pwsh
        env:
          TEST_SERVICE: Spooler
        run: |
          Invoke-Pester -CI -Path ./Tests -Output Detailed

Automate documentation drift checks with PSScriptAnalyzer

Use PSProvideCommentHelp to ensure exported functions have comment-based help, and add custom checks for synopsis and parameter coverage.

# ScriptAnalyzerSettings.psd1
@{
  IncludeRules = @(
    'PSProvideCommentHelp',
    'PSAvoidUsingWriteHost'
  )
  Rules = @{
    PSProvideCommentHelp = @{ Enable = $true; ExportedOnly = $false }
  }
}

You can also add a tiny guard to fail when help is missing or low quality.

# Build/Assert-HelpQuality.ps1
$errors = @()

Get-Command -Module . -CommandType Function | ForEach-Object {
  $cmd = $_.Name
  $help = Get-Help $cmd -Full
  if (-not $help.Synopsis -or $help.Synopsis.Trim().Length -lt 10) { $errors += "Weak synopsis for $cmd" }
  $declaredParams = (Get-Command $cmd).Parameters.Keys | Where-Object { $_ -ne 'WhatIf' -and $_ -ne 'Confirm' }
  foreach ($p in $declaredParams) {
    if (-not ($help.parameters.parameter | Where-Object { $_.name -eq $p })) {
      $errors += "Missing .PARAMETER documentation for $cmd:$p"
    }
  }
}

if ($errors) {
  $errors | ForEach-Object { Write-Error $_ }
  throw "Help quality checks failed: $($errors.Count) issue(s)."
}

Patterns for clarity and success on the first try

Parameter sets and targeted examples

If your function supports multiple modes, provide an example for each parameter set. Users should see exactly which switches work together and what output to expect.

function Invoke-Deploy {
  <#
  .SYNOPSIS
  Deploy an app to a target environment.

  .DESCRIPTION
  Supports dry runs and version pinning. Examples demonstrate each parameter set.

  .PARAMETER Environment
  The target environment name. Example: Dev, Test, Prod.

  .PARAMETER Version
  Optional image or package version to deploy. Defaults to latest.

  .PARAMETER WhatIf
  Shows what would happen without making changes.

  .EXAMPLE
  Invoke-Deploy -Environment Dev -WhatIf
  Dry run for Dev. Safe to copy/paste in CI smoke checks.

  .EXAMPLE
  Invoke-Deploy -Environment Prod -Version 1.4.3
  Pin a production deploy to a specific version.
  #>
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory)][ValidateSet('Dev','Test','Prod')][string]$Environment,
    [string]$Version
  )
  if ($PSCmdlet.ShouldProcess("$Environment", "Deploy version ${Version:-latest}")) {
    # deploy logic here
  }
}

Output-focused examples

Show the shape of the output so users can compose pipelines confidently:

# Good: demonstrate properties
Get-ServiceStatus -Name 'Spooler' | Format-List Name,Status,CanStop

# Good: demonstrate filtering
'Spooler','Dhcp' | ForEach-Object { [pscustomobject]@{ Name = $_ } } | Get-ServiceStatus |
  Where-Object Status -eq 'Running'

Security and reliability tips

  • Validate inputs with [ValidateSet], [ValidateScript], or [ValidatePattern] and document accepted values.
  • Prefer throwing meaningful exceptions ([ArgumentException]) and mention them in .DESCRIPTION if relevant.
  • Avoid secrets in examples. Show how to pass credentials securely (e.g., Get-Credential) without printing them.
  • Mark destructive commands with [CmdletBinding(SupportsShouldProcess)] and include a -WhatIf example.

A practical checklist

  1. Write a one-sentence .SYNOPSIS and a short .DESCRIPTION.
  2. Document every user-facing parameter with .PARAMETER.
  3. Add 2-4 runnable .EXAMPLEs that cover common scenarios and parameter sets.
  4. State .OUTPUTS type and key properties.
  5. Run Get-Help -Full and Get-Help -Examples as part of your local workflow.
  6. Add Pester tests that execute examples and fail fast on errors.
  7. Enforce help presence with PSScriptAnalyzer in CI.
  8. Refine examples when behavior changes; treat them as contracts with your users.

Document once, unblock many. With concise, example-driven help and automated checks, your PowerShell functions become reliable, discoverable tools that scale across your team.

← All Posts Home →