Mert Özen Further With Every Line
DevOps PowerShell Scripting — #08

Testing Scripts and Running Them in CI: Pester 6, PSScriptAnalyzer, Cross-Platform and Moving from 5.1 to 7.x

Mert Özen Nov 15, 2026 16 min 6 views
Testing Scripts and Running Them in CI: Pester 6, PSScriptAnalyzer, Cross-Platform and Moving from 5.1 to 7.x

In GitHub Actions, a shell: pwsh step only adds $ErrorActionPreference = 'stop'; even if dotnet test fails in the middle, the step stays green when the last native command returns 0, so keep the run block a single script call. Pester 6 tests and mocks for Bookstore.Ops, PSScriptAnalyzer rules, a Windows and Ubuntu matrix, cross-platform traps, and the differences when moving from 5.1 to 7.x and from 7.6 to 7.7.

Version note: The examples in this post were run with PowerShell 7.6.10 (LTS) and PowerShell 7.7.0 on Ubuntu 24.04 with Pester 6.2.0, PSScriptAnalyzer 1.25.0 and actionlint 1.7.12. The workflow was not run on GitHub or with act: it was checked with actionlint, and I ran its steps in the lab with the wrapper from the runner's source code. The Windows runner, Azure Pipelines and Windows PowerShell 5.1 parts come from the Microsoft Learn and GitHub documentation and were not run.

Guess First

In the GitHub Actions workflow of Bookstore, the online bookstore used as the shared example across this site's series, the tests run in a step with shell: pwsh:

      - name: Test
        shell: pwsh
        run: |
          dotnet test ./Bookstore.slnx --nologo -v quiet
          dotnet --version

According to GitHub's documentation, the runner adds $ErrorActionPreference = 'stop' to the top of pwsh steps. If a unit test breaks, does this step turn red?

This is the last post of the series. We put the scripts from the earlier posts and the Bookstore.Ops module under test: unit tests and mocks with Pester 6, PSScriptAnalyzer rules, the same scripts on Windows and Ubuntu runners, cross-platform traps and version upgrades.

What Does shell: pwsh Really Run?

In the source code of actions/runner, what happens to a pwsh step is clear: $ErrorActionPreference = 'stop' is added to the top of the run body, a line that exits with $LASTEXITCODE when it exists is added to the end, and the file is run with pwsh -command ". '{0}'". I built the same file in the lab and broke the tests on purpose:

# Lab helper: builds a script file the way the GitHub Actions runner does for "shell: pwsh"
# (ScriptHandlerHelpers.FixUpScriptContents in actions/runner): prepend + run body + append.
param([string] $Body, [string] $OutFile)
$prepend = "`$ErrorActionPreference = 'stop'"
$append = 'if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }'
Set-Content -Path $OutFile -Value $prepend, (Get-Content -Path $Body -Raw).TrimEnd(), $append
$ sed -i 's/(i % 2 == 0 ? 1 : 3)/(i % 2 == 0 ? 3 : 1)/' src/Bookstore.Api/Catalog.cs
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/run-body.ps1 -OutFile ./lab08/step.ps1
$ cat ./lab08/step.ps1
$ErrorActionPreference = 'stop'
dotnet test ./Bookstore.slnx --nologo -v quiet
dotnet --version
if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }
$ pwsh -command ". './lab08/step.ps1'"; echo "step exit=$?"
Test run for /home/user/bookstore/tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.06]     Bookstore.Api.Tests.IsbnTests.Valid_isbn_passes(isbn: "978-0-14-143958-7") [FAIL]

Failed!  - Failed:     1, Passed:     3, Skipped:     0, Total:     4, Duration: 12 ms - Bookstore.Api.Tests.dll (net10.0)
10.0.404
step exit=0
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/run-body-fixed.ps1 -OutFile ./lab08/step.ps1
$ pwsh -command ". './lab08/step.ps1'"; echo "step exit=$?"
Test run for /home/user/bookstore/tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll (.NETCoreApp,Version=v10.0)
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.06]     Bookstore.Api.Tests.IsbnTests.Valid_isbn_passes(isbn: "978-0-14-143958-7") [FAIL]

Failed!  - Failed:     1, Passed:     3, Skipped:     0, Total:     4, Duration: 11 ms - Bookstore.Api.Tests.dll (net10.0)
NativeCommandExitException: /home/user/bookstore/lab08/step.ps1:3
Line |
   3 |  dotnet test ./Bookstore.slnx --nologo -v quiet
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Program "dotnet" ended with non-zero exit code: 1 (0x01).
step exit=1
$ cp ~/Catalog.cs.good src/Bookstore.Api/Catalog.cs

The answer to the guess: no. dotnet test ended with 1, but as we know from the fourth post, Stop does not treat the exit code of native commands as an error. Then dotnet --version ran, $LASTEXITCODE became 0, and the line at the end finished the step with 0. The step's result is the result of the last native command. With $PSNativeCommandUseErrorActionPreference = $true at the top of the body, the step turned red. The rule: keep the run block in YAML a one-line script call, and set up the error behaviour inside the script with the three lines from the fourth post.

Unit Tests with Pester 6

Pester is PowerShell's test framework. It provides Describe and It blocks, BeforeAll/BeforeEach setup, Mock to replace commands with fakes, and TestDrive, a temporary folder for each test. The most visible addition in Pester 6 is a set of separate assertion commands such as Should-Be, Should-BeTrue and Should-Throw; the classic Should -Be syntax keeps working. The tests for Bookstore.Ops:

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0' }
# Pester shares variables between BeforeEach and It; PSScriptAnalyzer cannot see that
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester scoping')]
param()

BeforeAll {
    Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..', 'modules', 'Bookstore.Ops') -Force
}

Describe 'Get-BookstoreHealth' {
    It 'reports a healthy API' {
        Mock -ModuleName Bookstore.Ops Invoke-WebRequest {
            [pscustomobject]@{ StatusCode = 200; Content = '{"status":"Healthy"}' }
        }

        $result = Get-BookstoreHealth -BaseUrl 'http://api.bookstore.test'

        $result.Url | Should-Be 'http://api.bookstore.test/health'
        $result.Healthy | Should-BeTrue
        Should-Invoke -ModuleName Bookstore.Ops Invoke-WebRequest -Times 1 -Exactly
    }

    It 'reports 503 as unhealthy' {
        Mock -ModuleName Bookstore.Ops Invoke-WebRequest {
            [pscustomobject]@{ StatusCode = 503; Content = '{"status":"Unhealthy"}' }
        }

        $result = Get-BookstoreHealth -BaseUrl 'http://api.bookstore.test'

        $result.Healthy | Should-BeFalse
        $result.StatusCode | Should-Be 503
    }

    It 'turns a connection error into a result instead of throwing' {
        Mock -ModuleName Bookstore.Ops Invoke-WebRequest {
            throw [System.Net.Http.HttpRequestException]::new('Connection refused')
        }

        $result = Get-BookstoreHealth -BaseUrl 'http://api.bookstore.test'

        $result.Healthy | Should-BeFalse
        $result.Error | Should-Be 'Connection refused'
    }

    It 'rejects an address without http or https' {
        { Get-BookstoreHealth -BaseUrl 'api.bookstore.test' } |
            Should-Throw -ExceptionType ([System.Management.Automation.ParameterBindingException])
    }
}

Describe 'Wait-BookstoreHealthy' {
    It 'tries again until the API is healthy' {
        $script:calls = 0
        Mock -ModuleName Bookstore.Ops Start-Sleep { }
        Mock -ModuleName Bookstore.Ops Get-BookstoreHealth {
            $script:calls++
            [pscustomobject]@{ Url = 'u'; StatusCode = 503; Status = 'Unhealthy'; Healthy = $script:calls -ge 3; Error = $null }
        }

        $result = Wait-BookstoreHealthy -BaseUrl 'http://api.bookstore.test' -TimeoutSec 60

        $result.Healthy | Should-BeTrue
        Should-Invoke -ModuleName Bookstore.Ops Get-BookstoreHealth -Times 3 -Exactly
    }
}

Describe 'Invoke-BookstoreBackup' {
    BeforeEach {
        $site = New-Item -Path (Join-Path -Path $TestDrive -ChildPath 'site') -ItemType Directory -Force
        Set-Content -Path (Join-Path -Path $site -ChildPath 'Bookstore.Api.dll') -Value 'fake dll'
        $backups = Join-Path -Path $TestDrive -ChildPath 'backups'
    }

    It 'creates nothing with -WhatIf' {
        Invoke-BookstoreBackup -Path $site -Destination $backups -WhatIf

        Test-Path -Path $backups | Should-BeFalse
    }

    It 'keeps only the newest backups' {
        1..3 | ForEach-Object { $null = Invoke-BookstoreBackup -Path $site -Destination $backups -KeepLast 2 }

        Get-ChildItem -Path $backups -Filter '*.zip' | Should-BeCollection -Count 2
    }
}

Describe 'Restart-BookstorePool' {
    It 'does not touch IIS with -WhatIf' {
        # The classic Should syntax still works in Pester 6
        { Restart-BookstorePool -Name BookstoreApi -WhatIf } | Should -Not -Throw
    }

    It 'refuses to run without IIS' -Skip:$IsWindows {
        { Restart-BookstorePool -Confirm:$false } | Should-Throw -ExceptionType ([System.PlatformNotSupportedException])
    }
}

Mock -ModuleName Bookstore.Ops applies the mock to calls made from inside the module; this parameter is required because the module function calls Invoke-WebRequest in its own scope. Should-Invoke checks how many times the mock was called, and in the Wait-BookstoreHealthy test we also mocked Start-Sleep so that the test finishes without waiting. -Skip:$IsWindows skips on Windows the test that only makes sense on a platform without IIS.

Two real problems came up while writing the tests. First, up to the seventh post Get-BookstoreHealth called Invoke-RestMethod with -StatusCodeVariable. My first test:

PS> Invoke-Pester -Path ./lab08/Health.v07.Tests.ps1 -Output Detailed
Pester v6.2.0

Running tests from 1 files.

Running tests from '/home/user/bookstore/lab08/Health.v07.Tests.ps1'
Describing Get-BookstoreHealth (0.2.0)
  [-] reports a healthy API 156ms
   Expected [bool] $true, but got: [bool] $false.
   at $result.Healthy | Should-BeTrue, /home/user/bookstore/lab08/Health.v07.Tests.ps1:11
Tests completed in 348ms
Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0

A mock is a function; it does not imitate -StatusCodeVariable, which makes Invoke-RestMethod create a variable in the caller's scope. The status code stayed 0, and the healthy API looked unhealthy. I switched the function to Invoke-WebRequest, which returns the status code on the object; a small design change for testability:

        try {
            # Invoke-WebRequest returns the status code on the object; -StatusCodeVariable cannot be mocked
            $response = Invoke-WebRequest -Uri $url -TimeoutSec $TimeoutSec -SkipHttpErrorCheck
            $statusCode = [int]$response.StatusCode
            $status = ($response.Content | ConvertFrom-Json).status
        }

The second one is a real bug. Invoke-BookstoreBackup used the time down to the second in the file name; when two backups were taken in the same second (for example when a failed deploy is retried right away), the second backup failed. The test found it:

$ sed -i 's/HHmmss-fff/HHmmss/' modules/Bookstore.Ops/Public/Invoke-BookstoreBackup.ps1
$ pwsh-7.7 -NoProfile -File ./scripts/Test-BookstoreScripts.ps1; echo "exit=$?"
PSScriptAnalyzer: no findings
Pester v6.2.0

Running tests from 1 files.

Running tests from '/home/user/bookstore/tests/Bookstore.Ops.Tests.ps1'
Describing Get-BookstoreHealth
  [+] reports a healthy API 136ms
  [+] reports 503 as unhealthy 18ms
  [+] turns a connection error into a result instead of throwing 12ms
  [+] rejects an address without http or https 11ms

Describing Wait-BookstoreHealthy
  [+] tries again until the API is healthy 30ms

Describing Invoke-BookstoreBackup
What if: Performing the operation "Back up /tmp/Pester_4fku/site" on target "/tmp/Pester_4fku/backups/bookstore-20261115-062840.zip".
  [+] creates nothing with -WhatIf 6ms
  [-] keeps only the newest backups 57ms
   IOException: The archive file /tmp/Pester_4fku/backups/bookstore-20261115-062840.zip already exists. Use the -Update parameter to update the existing archive file or use the -Force parameter to overwrite the existing archive file.
   at ThrowTerminatingErrorHelper, /opt/microsoft/powershell/7.7/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1:1273
   at Compress-Archive<Begin>, /opt/microsoft/powershell/7.7/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1:150
   at Invoke-BookstoreBackup, /home/user/bookstore/modules/Bookstore.Ops/Public/Invoke-BookstoreBackup.ps1:36
   at <ScriptBlock>, /home/user/bookstore/tests/Bookstore.Ops.Tests.ps1:81
   at <ScriptBlock>, /home/user/bookstore/tests/Bookstore.Ops.Tests.ps1:81

Describing Restart-BookstorePool
What if: Performing the operation "Recycle" on target "application pool BookstoreApi".
  [+] does not touch IIS with -WhatIf 12ms
  [+] refuses to run without IIS 6ms
Tests completed in 446ms
Tests Passed: 8, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0
exit=1

After adding milliseconds to the file name, all nine tests passed. With these two changes the module version became 0.3.0.

PSScriptAnalyzer: Rules and a Settings File

PSScriptAnalyzer is a linter that checks PowerShell code without running it. Let us see the rules we promised in the earlier posts of the series on a draft:

function Get-LowStockBooks {
    param($Books, $Threshold = 5)
    $result = @()
    foreach ($book in $Books) {
        if ($book.Stock -eq $null) { continue }
        if ([int]$book.Stock -lt $Threshold) { $result += $book.Title }
    }
    Write-Host "Found $($result.Count) titles"
    $result
}

$books = ipcsv ./data/books.csv
Get-LowStockBooks $books
PS> Invoke-ScriptAnalyzer -Path ./lab08/draft.ps1 | Format-Table RuleName, Severity, Line, Message -Wrap

RuleName                              Severity Line Message
--------                              -------- ---- -------
PSAvoidUsingWriteHost                  Warning    8 File 'draft.ps1' uses Write-Host. Avoid using Write-Host because it
                                                     might not work in all hosts, does not work when there is no host,
                                                    and (prior to PS 5.0) cannot be suppressed, captured, or redirected
                                                    . Instead, use Write-Output, Write-Verbose, or Write-Information.
PSPossibleIncorrectComparisonWithNull  Warning    5 $null should be on the left side of equality comparisons.
PSUseSingularNouns                     Warning    1 The cmdlet 'Get-LowStockBooks' uses a plural noun. A singular noun
                                                    should be used instead.
PSAvoidUsingCmdletAliases              Warning   12 'ipcsv' is an alias of 'Import-Csv'. Alias can introduce possible p
                                                    roblems and make scripts hard to maintain. Please consider changing
                                                     alias to its full content.

PSPossibleIncorrectComparisonWithNull is the rule about putting $null on the left from the second post; PSAvoidUsingWriteHost is the warning we saw on the first draft in the first post. The plural noun and the alias were caught as well. We pin the rules in the repo with a settings file:

@{
    # All default rules, all severities; CI fails on any finding
    Severity     = @('Error', 'Warning', 'Information')
    IncludeDefaultRules = $true
    ExcludeRules = @()
    Rules        = @{
        PSUseCompatibleSyntax = @{
            Enable         = $true
            TargetVersions = @('7.0')
        }
    }
}

The Bookstore scripts had three findings, and all three are instructive. The manifest generated by New-ModuleManifest had trailing spaces (PSAvoidTrailingWhitespace); I removed them. In the rollout script, -SitePath and -HealthUrl were flagged as unused, because PSScriptAnalyzer does not see their use through $using: in the remote block. $backups in the Pester test is the same story: it cannot see a variable assigned in BeforeEach and read in It. I suppressed both in code with SuppressMessageAttribute and a justification; turning the rule off for the whole repo would also hide a really unused parameter.

One Script, Two Environments

A single script that CI calls also runs the same checks on a developer machine: PSScriptAnalyzer first, then Pester if there are no findings; the result is written as JUnit XML to artifacts/test-results, and the exit code is 1 when a test fails:

#!/usr/bin/env pwsh
#Requires -Version 7.6

[CmdletBinding()]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Import-Module, not #Requires: with #Requires -Modules Pester, pwsh -File lost the exit code in the lab
Import-Module -Name Pester -MinimumVersion 6.0.0
Import-Module -Name PSScriptAnalyzer -MinimumVersion 1.25.0

$root = Split-Path -Path $PSScriptRoot -Parent
$settings = Join-Path -Path $root -ChildPath 'PSScriptAnalyzerSettings.psd1'

$findings = foreach ($folder in 'scripts', 'modules', 'tests') {
    Invoke-ScriptAnalyzer -Path (Join-Path -Path $root -ChildPath $folder) -Recurse -Settings $settings
}
if ($findings) {
    $findings | Format-Table -Property RuleName, Severity, ScriptName, Line, Message -AutoSize -Wrap | Out-String -Width 160
    Write-Output "PSScriptAnalyzer: $(@($findings).Count) finding(s)"
    exit 1
}
Write-Output 'PSScriptAnalyzer: no findings'

$config = New-PesterConfiguration
$config.Run.Path = Join-Path -Path $root -ChildPath 'tests'
$config.Run.PassThru = $true
$config.Output.Verbosity = 'Detailed'
$config.Output.RenderMode = 'Plaintext'
$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = 'JUnitXml'
$config.TestResult.OutputPath = Join-Path -Path $root -ChildPath 'artifacts', 'test-results', 'pester.xml'

$result = Invoke-Pester -Configuration $config
exit ($result.FailedCount -gt 0 ? 1 : 0)

In the first version of the script I had asked for Pester with #Requires -Modules, and the exit code came back 0 even though tests were failing. Reduced to the minimum:

$ cat ./lab08/requires-exit.ps1
#Requires -Modules Pester
exit 3
$ pwsh -NoProfile -File ./lab08/requires-exit.ps1; echo "exit=$?"
exit=0
$ pwsh-7.7 -NoProfile -File ./lab08/requires-exit.ps1; echo "exit=$?"
exit=0
$ pwsh -NoProfile -File ./lab08/import-exit.ps1; echo "exit=$?"
exit=3

A script with a #Requires -Modules Pester line, run with pwsh -File, lost its exit 3 on both PowerShell versions; loading the same module with Import-Module kept the code. It did not happen with PSScriptAnalyzer and SecretManagement in the lab; I did not investigate the cause, but the lesson is clear: try every failure path of a CI script together with its exit code.

CI: GitHub Actions and Azure Pipelines

The workflow runs the same steps on Windows and Ubuntu runners. The action versions match the GitHub Actions series on this site:

name: powershell

on:
  push:
    branches: [main]
  pull_request:

jobs:
  scripts:
    strategy:
      fail-fast: false
      matrix:
        os: [windows-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        shell: pwsh
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.0.x

      - name: Show PowerShell version
        run: $PSVersionTable.PSVersion.ToString()

      - name: Install Pester and PSScriptAnalyzer
        run: |
          Install-PSResource -Name Pester -Version 6.2.0 -Repository PSGallery -TrustRepository -Scope CurrentUser
          Install-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Scope CurrentUser

      - name: Analyze and test scripts
        run: ./scripts/Test-BookstoreScripts.ps1

      - name: Build Bookstore.Api
        run: ./scripts/Invoke-BookstoreBuild.ps1 -Configuration Release

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: pester-${{ matrix.os }}
          path: artifacts/test-results/

The runner images moved to PowerShell 7.6 in 2026; #Requires -Version 7.6 breaks the step right away on an older runner, which is why we print the version in a step. I checked the workflow with actionlint and ran the Ubuntu-side steps one after the other in the lab with the wrapper above:

$ actionlint .github/workflows/powershell.yml; echo "exit=$?"
exit=0
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/step-version.body.ps1 -OutFile ./lab08/step-version.ps1
$ pwsh -command ". './lab08/step-version.ps1'" > ./lab08/step-version.log 2>&1; echo "version: exit=$?"
version: exit=0
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/step-install.body.ps1 -OutFile ./lab08/step-install.ps1
$ pwsh -command ". './lab08/step-install.ps1'" > ./lab08/step-install.log 2>&1; echo "install: exit=$?"
install: exit=0
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/step-test.body.ps1 -OutFile ./lab08/step-test.ps1
$ pwsh -command ". './lab08/step-test.ps1'" > ./lab08/step-test.log 2>&1; echo "test: exit=$?"
test: exit=0
$ pwsh -NoProfile -File ./lab08/make-step.ps1 -Body ./lab08/step-build.body.ps1 -OutFile ./lab08/step-build.ps1
$ pwsh -command ". './lab08/step-build.ps1'" > ./lab08/step-build.log 2>&1; echo "build: exit=$?"
build: exit=0

In Azure Pipelines the same script runs with the PowerShell@2 task. According to the documentation, this task uses Windows PowerShell 5.1 by default on Windows agents; without pwsh: true, #Requires -Version 7.6 breaks the step. The task also adds the $ErrorActionPreference line to the top and the $LASTEXITCODE line to the end (the errorActionPreference and ignoreLASTEXITCODE inputs). This part was not run:

- task: PowerShell@2
  displayName: Analyze and test scripts
  inputs:
    targetType: filePath
    filePath: scripts/Test-BookstoreScripts.ps1
    pwsh: true

Cross-Platform: Paths, Letter Case and Line Endings

A script that was only tried on Windows breaks on a Linux runner mostly in these three places:

PS> $IsWindows, $IsLinux
False
True
PS> [System.IO.Path]::DirectorySeparatorChar
/
PS> Join-Path -Path modules -ChildPath Bookstore.Ops, Public
modules/Bookstore.Ops/Public
PS> Test-Path -Path ./Modules/Bookstore.Ops
False
PS> Test-Path -Path ./modules/Bookstore.Ops
True
PS> (Get-Content -Path ./lab06/warehouse-stock.csv)[1].Length
25
PS> ((Get-Content -Path ./lab06/warehouse-stock.csv -Raw) -split "`n")[1].Length
26

On Linux the file system is case-sensitive: ./Modules, which works on Windows, does not exist on Linux. Use Join-Path instead of joining paths by hand; it picks the separator for the platform. For line endings, take the warehouse file with CRLF endings from Windows in the sixth post: Get-Content split the lines correctly, but reading it with -Raw and splitting on "`n" left an invisible `r at the end of every line, and the length became 26. Such an ISBN never matches in any comparison. Use $IsWindows/$IsLinux for platform-specific code; the .gitattributes from the first post also pins the line endings of the scripts.

Versions: From 5.1 to 7.x and from 7.6 to 7.7

Version.NETSupport
Windows PowerShell 5.1.NET FrameworkPart of Windows, follows the Windows lifecycle
PowerShell 7.4 (LTS).NET 8Ended on 10 November 2026
PowerShell 7.5.NET 9Ended on 10 November 2026
PowerShell 7.6 (LTS).NET 10Until 14 November 2028
PowerShell 7.7.NET 11Stable; until about six months after the next LTS

When moving from 5.1 to 7.x, the biggest question is modules. On Windows, 7.x can load a Windows PowerShell module that does not declare compatibility with 7 in a background 5.1 process (WinPSCompatSession); Import-Module -UseWindowsPowerShell asks for this explicitly. It is the same mechanism as the remoting in the seventh post: objects arrive as deserialized copies, and all these modules share a single runspace. We saw language differences such as the default encoding, &&/|| and the ternary operator in the first post. Microsoft's module compatibility list shows which Windows modules work directly in 7 (not run).

Moving from 7.6 to 7.7 is smaller, but there are silent differences. The same commands on both versions:

PS> '{0} on .NET {1}' -f $PSVersionTable.PSVersion, [System.Environment]::Version
7.6.10 on .NET 10.0.15
PS> [decimal]'12.99'
12.99
PS> (New-Guid).Version
4
PS> Join-Path -Path ./artifacts -ChildPath bookstore-api -Extension .zip
./artifacts/bookstore-api/-Extension/.zip
PS> (Get-Command -Name New-TemporaryDirectory -ErrorAction SilentlyContinue).Name
PS> '{0} on .NET {1}' -f $PSVersionTable.PSVersion, [System.Environment]::Version
7.7.0 on .NET 11.0.0
PS> [decimal]'12.99'
12.990000000000000213162820728
PS> (New-Guid).Version
7
PS> Join-Path -Path ./artifacts -ChildPath bookstore-api -Extension .zip
./artifacts/bookstore-api.zip
PS> (Get-Command -Name New-TemporaryDirectory -ErrorAction SilentlyContinue).Name
New-TemporaryDirectory

The [decimal] conversion difference we saw in the second post comes from .NET 11 and changes the result of a script that calculates prices; [decimal]::Parse with InvariantCulture gives the exact result on both versions. In 7.7, New-Guid produces version 7 (time-sortable) GUIDs by default. The sneakiest one is Join-Path -Extension: a new parameter in 7.7, while in 7.6 it produced a wrong path without any error, because Join-Path collects the remaining arguments as path parts. Put #Requires -Version 7.7 at the top of a script that uses a new parameter.

For moving from Pester 5 to 6, according to the release notes Pester 6 requires Windows PowerShell 5.1 or PowerShell 7.4+; Assert-MockCalled was removed (use Should -Invoke), -Focus and Set-ItResult -Pending are gone, an empty -ForEach fails during discovery, and test files are now discovered and run independently of each other. Pester 4's Should Be syntax (without the dash) was already removed in 5; such old tests do not run on 6 at all.

In Production

Pin the versions of your test and analysis tools. The workflow installs specific Pester and PSScriptAnalyzer versions with Install-PSResource -Version; the rules or behaviour of a new version should not break CI on a day when the code did not change. Upgrade with a deliberate commit.

Put Windows-specific code behind testable boundaries. IIS, scheduled task and Windows Service commands do not exist on a Linux runner; split them into thin functions, test them with tests that run only on Windows via -Skip:(-not $IsWindows), and test the rest of the logic with mocks on both platforms.

Try the exit code at every layer. In this series we saw the exit code get lost in four different places: a script that ends with Write-Error, -Command turning it into 1, the last native command in a CI step, and #Requires -Modules Pester. Break every CI script on purpose in the lab and look at its exit code.

Common Pitfalls

Old Pester syntax. Tests written with Should Be (Pester 4) and Assert-MockCalled (deprecated in Pester 5, removed in 6) do not run on a runner with Pester 6 installed. #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0' } in a test file documents which version it was written for.

A script only tried on Windows. Letter case, path separators, CRLF and Windows-specific cmdlets show up on the first run on a Linux runner. Add Ubuntu to the matrix from day one.

Assuming a mock fully imitates the real command. A mock is a function that takes the parameters but has none of the side effects. Behaviours such as -StatusCodeVariable, -OutVariable or changing global state are not in a mock; when a test passes, also try it at least once with the real command in the lab.

A Small Experiment

★☆☆ Run Test-BookstoreScripts.ps1 and open artifacts/test-results/pester.xml. Break a test on purpose; find the failing test and its error message in the XML.

★★☆ Write a test for Invoke-BookstoreDotnet: you cannot mock dotnet, because the native command preference looks at the exit code of a real process, not at functions. Instead, create a small fake dotnet script (a file with the executable permission on Linux, a .cmd on Windows), put it at the front of PATH, and check that the function throws when the exit code is 1.

★★★ Add a third job to the workflow: on windows-latest, with shell: powershell (Windows PowerShell 5.1), it runs only Import-Module ./modules/Bookstore.Ops and expects it to fail because of CompatiblePSEditions = 'Core' in the manifest. How do you test an expected failure in CI?

This is where the series ends. We started from interactive PowerShell and came to scripts with reliable exit codes, a module, a real deploy, remote servers and a tested CI pipeline. For the pipeline these scripts run in, see the GitHub Actions and Azure DevOps Pipelines series; for the bash side of the same jobs, the Bash Scripting series; and for the deploy strategy, the Deployment Strategies series.