Mert Özen Her Satırda Daha İleri
DevOps PowerShell Scripting — #08

Script'i Test Etmek ve CI'da Çalıştırmak: Pester 6, PSScriptAnalyzer, Cross-Platform ve 5.1'den 7.x'e Taşımak

Mert Özen 15 Kas 2026 16 dk 5 görüntülenme
Script'i Test Etmek ve CI'da Çalıştırmak: Pester 6, PSScriptAnalyzer, Cross-Platform ve 5.1'den 7.x'e Taşımak

GitHub Actions'ta shell: pwsh adımı yalnız $ErrorActionPreference = 'stop' ekler; ortadaki dotnet test kırılsa da son native komut 0 dönerse adım yeşil kalır, bu yüzden run bloğunu tek bir script çağrısı yap. Bookstore.Ops için Pester 6 testleri ve mock'lar, PSScriptAnalyzer kuralları, Windows ve Ubuntu matrix'i, cross-platform tuzaklar, 5.1'den 7.x'e ve 7.6'dan 7.7'ye geçişteki farklar.

Sürüm notu: Bu yazıdaki örnekler PowerShell 7.6.10 (LTS) ve PowerShell 7.7.0 ile, Ubuntu 24.04 üzerinde Pester 6.2.0, PSScriptAnalyzer 1.25.0 ve actionlint 1.7.12 ile denenmiştir. Workflow GitHub'da ve act ile koşulmadı: actionlint ile doğrulandı, adımları runner'ın kaynak kodundaki sarmalayıcıyla lab'da çalıştırdım. Windows runner'ı, Azure Pipelines ve Windows PowerShell 5.1 kısmı Microsoft Learn ve GitHub dokümanlarından aktarıldı, çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) GitHub Actions workflow'unda testler shell: pwsh ile çalışan bir adımda:

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

GitHub'ın dokümanına göre runner, pwsh adımlarının başına $ErrorActionPreference = 'stop' ekliyor. Bir unit test kırılırsa bu adım kırmızı olur mu?

Serinin son yazısı. Önceki yazılarda yazdığımız script'leri ve Bookstore.Ops module'ünü test altına alıyoruz: Pester 6 ile unit test ve mock, PSScriptAnalyzer kuralları, aynı script'lerin Windows ve Ubuntu runner'larında çalışması, cross-platform tuzaklar ve sürüm geçişleri.

shell: pwsh Gerçekte Ne Çalıştırır?

actions/runner'ın kaynak kodunda pwsh adımı için yapılan iş açık: run gövdesinin başına $ErrorActionPreference = 'stop', sonuna da $LASTEXITCODE varsa onunla çıkan bir satır ekleniyor ve dosya pwsh -command ". '{0}'" ile çalıştırılıyor. Aynı dosyayı lab'da kurup testleri bilerek kırdım:

# Lab yardımcısı: "shell: pwsh" için GitHub Actions runner'ının yaptığı gibi bir script dosyası kurar
# (actions/runner'daki ScriptHandlerHelpers.FixUpScriptContents): başa ekle + run gövdesi + sona ekle.
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

Tahminin cevabı: hayır. dotnet test 1 ile bitti, ama dördüncü yazıdan bildiğimiz gibi Stop native komutların exit code'unu hata saymaz. Sonra dotnet --version çalıştı, $LASTEXITCODE 0 oldu ve sondaki satır adımı 0 ile bitirdi. Adımın sonucu, son native komutun sonucu. Gövdenin başına $PSNativeCommandUseErrorActionPreference = $true eklenince adım kırmızı oldu. Kural: YAML (workflow ve pipeline tanımlarının yazıldığı metin biçimi) dosyasındaki run bloğunu tek satırlık bir script çağrısı olarak tut ve hata davranışını script'in içinde, dördüncü yazıdaki üç satırla kur.

Pester 6 ile Unit Test

Pester, PowerShell'in test framework'ü. Describe ve It blokları, BeforeAll/BeforeEach kurulumu, Mock ile komutları sahtesiyle değiştirme ve TestDrive ile her test için geçici bir klasör sağlıyor. Pester 6'nın en görünür yeniliği Should-Be, Should-BeTrue, Should-Throw gibi ayrı assertion komutları; klasik Should -Be syntax'ı da çalışmaya devam ediyor. Bookstore.Ops için testler:

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0' }
# Pester, BeforeEach ile It arasında variable paylaşır; PSScriptAnalyzer bunu göremez
[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' {
        # Klasik Should syntax'ı Pester 6'da hâlâ çalışıyor
        { 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, mock'u module'ün içinden yapılan çağrılara uyguluyor; module function'ı Invoke-WebRequest'i kendi scope'unda çağırdığı için bu parametre şart. Should-Invoke mock'un kaç kez çağrıldığını doğruluyor, Wait-BookstoreHealthy testinde Start-Sleep'i de mock'layıp testin beklemeden bitmesini sağladık. -Skip:$IsWindows, yalnız IIS (Windows'un web sunucusu) olmayan platformda anlamlı olan testi Windows'ta atlıyor.

Testleri yazarken iki gerçek sorun çıktı. Birincisi, yedinci yazıya kadar Get-BookstoreHealth, Invoke-RestMethod'u -StatusCodeVariable ile çağırıyordu. İlk testim:

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

Mock bir function'dır; Invoke-RestMethod'un çağıranın scope'unda variable oluşturan -StatusCodeVariable davranışını taklit etmiyor. Durum kodu 0 kaldı ve sağlıklı API sağlıksız göründü. Function'ı, durum kodunu object'te döndüren Invoke-WebRequest'e çevirdim; test edilebilirlik için küçük bir tasarım değişikliği:

        try {
            # Invoke-WebRequest durum kodunu object'te döndürür; -StatusCodeVariable mock'lanamaz
            $response = Invoke-WebRequest -Uri $url -TimeoutSec $TimeoutSec -SkipHttpErrorCheck
            $statusCode = [int]$response.StatusCode
            $status = ($response.Content | ConvertFrom-Json).status
        }

İkincisi gerçek bir bug. Invoke-BookstoreBackup dosya adında saniyeye kadar zaman kullanıyordu; aynı saniyede iki yedek alınınca (örneğin başarısız bir deploy'un hemen ardından tekrar denendiğinde) ikinci yedek hata veriyordu. Test bunu buldu:

$ 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

Dosya adına milisaniyeyi ekleyince dokuz testin hepsi geçti. Bu iki değişiklikle module sürümü 0.3.0 oldu.

PSScriptAnalyzer: Kurallar ve Settings Dosyası

PSScriptAnalyzer, PowerShell kodunu çalıştırmadan inceleyen bir linter. Serinin önceki yazılarında söz verdiğimiz kuralları bir taslak üzerinde görelim:

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, ikinci yazıdaki $null'ı sola yazma kuralı; PSAvoidUsingWriteHost, birinci yazının ilk taslağında gördüğümüz uyarı. Plural noun ve alias kullanımı da yakalandı. Kuralları repo'da bir settings dosyasıyla sabitliyoruz:

@{
    # Bütün varsayılan kurallar, bütün seviyeler; CI her bulguda kırılır
    Severity     = @('Error', 'Warning', 'Information')
    IncludeDefaultRules = $true
    ExcludeRules = @()
    Rules        = @{
        PSUseCompatibleSyntax = @{
            Enable         = $true
            TargetVersions = @('7.0')
        }
    }
}

Bookstore script'lerinde üç bulgu çıktı ve üçü de öğretici. New-ModuleManifest'in ürettiği manifest'te satır sonu boşlukları vardı (PSAvoidTrailingWhitespace); temizledim. Rollout script'inde -SitePath ve -HealthUrl "kullanılmıyor" diye işaretlendi, çünkü PSScriptAnalyzer $using: ile uzak bloğa giden kullanımı görmüyor. Pester testindeki $backups da aynı şekilde: BeforeEach'te atanıp It'te okunan variable'ı göremiyor. İkisini kod içinde SuppressMessageAttribute ve bir gerekçeyle bastırdım; kuralı bütün repo için kapatmak, gerçek bir kullanılmayan parametreyi de gizlerdi.

Tek Script, İki Ortam

CI'ın çağıracağı tek bir script, geliştiricinin makinesinde de aynı kontrolleri çalıştırıyor: önce PSScriptAnalyzer, bulgu yoksa Pester; sonuç JUnit XML (CI araçlarının okuduğu test sonucu biçimi) olarak artifacts/test-results'a yazılıyor ve exit code başarısız test varsa 1:

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

[CmdletBinding()]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# #Requires değil Import-Module: #Requires -Modules Pester ile pwsh -File lab'da exit code'u kaybetti
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)

Script'in ilk hâlinde Pester'ı #Requires -Modules ile istemiştim ve testler kırıldığı hâlde exit code 0 geldi. Küçültünce:

$ 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

#Requires -Modules Pester satırı olan bir script pwsh -File ile çalışınca, iki PowerShell sürümünde de exit 3 kayboldu; aynı modülü Import-Module ile yükleyince kod korundu. Lab'da PSScriptAnalyzer ve SecretManagement ile bu olmadı; sebebini araştırmadım, ama ders açık: CI script'inin her başarısızlık yolunu exit code'uyla birlikte dene.

CI: GitHub Actions ve Azure Pipelines

Workflow, aynı adımları Windows ve Ubuntu runner'larında çalıştırıyor. Actions sürümleri sitedeki GitHub Actions serisiyle aynı:

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/

Runner image'ları 2026'da PowerShell 7.6'ya geçti; #Requires -Version 7.6 daha eski bir runner'da adımı hemen kırar, bu yüzden sürümü bir adımda yazdırıyoruz. Workflow'u actionlint ile doğruladım ve Ubuntu tarafındaki adımları, yukarıdaki sarmalayıcıyla lab'da sırayla çalıştırdım:

$ 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

Azure Pipelines'ta aynı script PowerShell@2 task'ı ile çalışır. Dokümana göre bu task Windows agent'ında varsayılan olarak Windows PowerShell 5.1 kullanıyor; pwsh: true yazılmazsa #Requires -Version 7.6 adımı kırar. Task da $ErrorActionPreference satırını başa, $LASTEXITCODE satırını sona ekliyor (errorActionPreference ve ignoreLASTEXITCODE girdileri). Bu kısım çalıştırılmadı:

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

Cross-Platform: Path, Büyük/Küçük Harf ve Satır Sonları

Yalnız Windows'ta denenmiş bir script, Linux runner'ında en çok bu üç yerden kırılır:

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

Linux'ta dosya sistemi büyük/küçük harfe duyarlı: Windows'ta çalışan ./Modules, Linux'ta yok. Path'leri elle birleştirmek yerine Join-Path kullan; ayırıcıyı platforma göre seçiyor. Satır sonlarında, altıncı yazıdaki Windows'tan gelen CRLF'li (satır sonunda carriage return ve line feed) depo dosyası örneği: Get-Content satırları doğru ayırdı, ama -Raw ile okuyup "`n" ile bölünce her satırın sonunda görünmeyen bir `r kaldı ve uzunluk 26 oldu. Böyle bir ISBN (kitabın uluslararası standart numarası) hiçbir karşılaştırmada eşleşmez. Platforma özgü kod için $IsWindows/$IsLinux kullan; birinci yazıdaki .gitattributes da script'lerin satır sonlarını sabitliyor.

Sürümler: 5.1'den 7.x'e ve 7.6'dan 7.7'ye

Sürüm.NETDestek
Windows PowerShell 5.1.NET FrameworkWindows'un parçası, Windows'un yaşam döngüsüyle
PowerShell 7.4 (LTS).NET 810 Kasım 2026'da bitti
PowerShell 7.5.NET 910 Kasım 2026'da bitti
PowerShell 7.6 (LTS).NET 1014 Kasım 2028'e kadar
PowerShell 7.7.NET 11Stable; bir sonraki LTS'ten yaklaşık altı ay sonrasına kadar

5.1'den 7.x'e geçişte en büyük soru module'ler. 7.x, Windows'ta 7 ile uyumlu olduğunu belirtmeyen bir Windows PowerShell module'ünü arka planda bir 5.1 process'inde (WinPSCompatSession) yükleyebiliyor; Import-Module -UseWindowsPowerShell bunu açıkça istiyor. Bu, yedinci yazıdaki remoting ile aynı mekanizma: object'ler deserialize edilmiş kopyalar olarak geliyor ve bütün bu module'ler tek bir runspace'i paylaşıyor. Varsayılan encoding, &&/|| ve ternary gibi dil farklarını birinci yazıda görmüştük. Microsoft'un module uyumluluk listesi, hangi Windows module'ünün 7'de doğrudan çalıştığını gösteriyor (çalıştırılmadı).

7.6'dan 7.7'ye geçiş daha küçük, ama sessiz farklar var. Aynı komutlar iki sürümde:

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

İkinci yazıda gördüğümüz [decimal] dönüşüm farkı .NET 11'den geliyor ve fiyat hesaplayan bir script'in sonucunu değiştiriyor; [decimal]::Parse ile InvariantCulture iki sürümde de tam sonuç veriyor. New-Guid 7.7'de varsayılan olarak sürüm 7 GUID (benzersiz kimlik; bu sürümde zamana göre sıralanabilen) üretiyor. En sinsi olanı Join-Path -Extension: 7.7'de yeni bir parametre, 7.6'da ise -ChildPath'in kalan argümanları toplayan yapısı yüzünden hata vermeden yanlış bir path üretti. Yeni bir parametre kullanan script'in başına #Requires -Version 7.7 yaz.

Pester 5'ten 6'ya geçişte, release notlarına göre Pester 6 Windows PowerShell 5.1 ve PowerShell 7.4+ istiyor; Assert-MockCalled kaldırıldı (Should -Invoke kullan), -Focus ve Set-ItResult -Pending gitti, boş bir -ForEach discovery'de hata veriyor ve test dosyaları artık birbirinden bağımsız keşfedilip çalıştırılıyor. Pester 4'ün Should Be (tiresiz) syntax'ı 5'te zaten kaldırılmıştı; böyle eski testler 6'da hiç çalışmaz.

Üretimde Dikkat

Test ve analiz aracı sürümlerini sabitle. Workflow'da Install-PSResource -Version ile belirli Pester ve PSScriptAnalyzer sürümleri kuruluyor; yeni bir sürümün kuralları ya da davranışı, kodun değişmediği bir gün CI'ı kırmasın. Güncellemeyi bilinçli bir commit ile yap.

Windows'a özgü kodu test edilebilir sınırların arkasına koy. IIS, scheduled task ve Windows Service komutları Linux runner'ında yok; onları ince function'lara ayırıp -Skip:(-not $IsWindows) ile yalnız Windows'ta çalışan testlerle, geri kalan mantığı mock'larla iki platformda test et.

Exit code'u her katmanda dene. Bu seride exit code'un dört farklı yerde kaybolduğunu gördük: Write-Error ile biten script, -Command'ın 1'e çevirmesi, CI adımındaki son native komut ve #Requires -Modules Pester. Her CI script'ini lab'da bilerek kırıp exit code'una bak.

Sık Düşülen Tuzaklar

Eski Pester syntax'ı. Should Be (Pester 4) ve Assert-MockCalled (Pester 5'te eskimiş, 6'da kaldırılmış) ile yazılmış testler, Pester 6 kurulu bir runner'da çalışmaz. #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0' } test dosyasında, hangi sürüme göre yazıldığını belgeler.

Yalnız Windows'ta denenmiş script. Büyük/küçük harf, path ayırıcı, CRLF ve Windows'a özgü cmdlet'ler Linux runner'ında ilk çalıştırmada ortaya çıkar. Matrix'e Ubuntu'yu ilk günden ekle.

Mock'un gerçek komutu tam taklit ettiğini sanmak. Mock, parametreleri alan ama yan etkileri olmayan bir function. -StatusCodeVariable, -OutVariable ya da global state değiştiren davranışlar mock'ta yoktur; test geçiyorsa, gerçek komutla en az bir kez lab'da da dene.

Küçük Bir Deneme

★☆☆ Test-BookstoreScripts.ps1'i çalıştır ve artifacts/test-results/pester.xml dosyasını aç. Bir testi bilerek kır; XML'de başarısız testi ve hata mesajını bul.

★★☆ Invoke-BookstoreDotnet için bir test yaz: dotnet'u mock'layamazsın, çünkü native komut tercihi function'lara değil, gerçek bir process'in exit code'una bakar. Bunun yerine küçük bir sahte dotnet script'i (Linux'ta çalıştırma izni olan bir dosya, Windows'ta bir .cmd) üretip PATH'in başına koy ve exit code'u 1 olunca function'ın exception fırlattığını doğrula.

★★★ Workflow'a üçüncü bir job ekle: windows-latest üzerinde, shell: powershell (Windows PowerShell 5.1) ile yalnız Import-Module ./modules/Bookstore.Ops çalıştırsın ve manifest'teki CompatiblePSEditions = 'Core' yüzünden başarısız olmasını beklesin. Beklenen bir başarısızlığı CI'da nasıl test edersin?

Seri burada bitiyor. Etkileşimli PowerShell'den başlayıp exit code'u güvenilir script'lere, module'e, gerçek bir deploy'a, uzak sunuculara ve test edilen bir CI hattına geldik. Bu script'lerin çalıştığı pipeline'ın kendisi için GitHub Actions ve Azure DevOps Pipelines serilerine, aynı işlerin bash tarafı için Bash Scripting serisine, deploy'un stratejisi için Deployment Stratejileri serisine bakabilirsin.