PowerShell TDD 起步走

老鮑伯

Agenda

  • Visual Studio Code 建置 PowerShell TDD 開發環境
  • PowerShell 測試工具框架 Pester介紹、為既有 PowerShell script加上可執行測試案例
  • PowerShell + C# binary cmdlet、ASP.NET Core 整合 PowerShell Hosting Script 的 TDD 範例

Slide URL

Visual Studio Code 建置 PowerShell TDD 開發環境

VSCode as PowerShell TDD IDE

VSCode as PowerShell TDD IDE

Example VSCode Environment

由於 .NET SDK 會根據作業系統&CPU架構不同而有不同的安裝方式,提供以下版本的 Portable VSCode + PowerShell Extension + C# 相關開發擴充套件 的範例開發環境:

Example VSCode Environment - Windows(x64)

除了要手動開啟Windows『開發者模式』設定以及將同捆的 .NET SDK 的執行檔路徑到環境變數之外,其他的都是使用 Portable 版本的開發環境:

  • Windows 環境的 relative symbolic link 需要啟用開發者模式才能讓 PowerShell 以不需管理員權限的方式設定。
    否則甚至連Windows內建的解壓縮功能都會被拒絕存取。
  • .NET SDK 的執行路徑建議以 DOTNET_ROOT 環境變數設定,以便於切換不同版本 dotnet 指令
    (使用系統安裝的 .NET SDK 8.0 也👌🆗)。

Example VSCode Environment - Windows(x64)

開發環境的檔案目錄架構:

  • 使用獨立的 PowerShell Module 目錄避免因為不同專案需要的各種版本 PowerShell Module 互衝,或是和系統層級的 Module 混淆。

  • 因此要在 PowerShell Module 目錄內下載並安裝 “ModuleFast” 這個工具型 PowerShell Module,用此命令列工具安裝 PowerShell Module 時可下指令指定安裝來源、安裝位置、模組版本。

例如以下範例,從 nuget.org 安裝 Pester v5.6.1 版本到 D:_portable_modules 目錄:

 @{ModuleName='Pester';ModuleVersion='5.6.1'} | Install-ModuleFast -Source api.nuget.org/v3 -Destination D:\vscode_portable\pwsh_modules


另外還有一個好用的開發用 PowerShell 指令列工具模組 PowerShell.dumPS,可以用來在 VSCode 中斷點除錯時,使用 Debug Console 快速查看 PowerShell 物件的類別、屬性值資訊。

$AnyObject | Out-Dump

Reset 專案開發環境

如果當此 VSCode 開發環境因為一些手誤使得開啟某些專案時工作區(worksapce) UI 配置跑掉,或是有不正常的 debug 選項在 Run and Debug 頁籤出現,需要重置時,只需要將 vscode_data\user-data\User\workspaceStorage 中,workspace.json 檔案指到原本專案項目的整個子目錄刪除即可:

假如是正常安裝版本的 Visual Studio Code,則可在下列位置中找到相同檔案。

OS 路徑
Windows %APPDATA%\Code\User\workspaceStorage
macOS ~/Library/Application Support/Code/User/workspaceStorage
Linux ~/.config/Code/User/workspaceStorage

Example VSCode Environment - Windows(x64)

在 pwsh-scripts 目錄的開發環境設定用 script 檔案:

  • PSScriptAnalyzerSettings.psd1
    用來控制 VSCode 官方 PowerShell Extension 內建的 PSScriptAnalyzer(PowerShell Syntax Linter) 設定檔。

  • Set-PwshTools.ps1
    替代 PowerShell Profile 的設定檔,用來設定 VSCode 整合終端機中 PowerShell 命令列的自動完成功能相關設定以及添加一些類似 Unix 環境的檔案命令。

      "terminal.integrated.profiles.windows": {
        "PowerShell": {
          "title": "Bundled pwsh",
          "path": "D:\\vscode_portable\\PowerShell-x64\\pwsh.exe",
          "args": [
            "-nol",
    1        "-nop",
            "-noe",
            "-ex",
            "RemoteSigned",
    2        "-Command",
            "Invoke-expression \". D:\\vscode_portable\\pwsh-scripts\\Set-PwshTools.ps1\""
          ]
        }
      },
    1
    使用 -nop 參數避免執行目前系統/使用者等級的 PowerShell Profile 設定檔
    2
    執行 Invoke-Expression ". D:\vscode_portable\pwsh-scripts\Set-PwshTools.ps1" 來替代。
  • Update-SymLinks.ps1
    用來更新/恢復兩個 symbolic link 檔案-1. 根目錄的 VSCode 主程式啟動連結 2. VSCode 可攜模式設定的使用者資料檔案目錄連結。
    (Windows 10 symbolic link 會因搬移到不同磁碟機/電腦而失效)

PowerShell 測試工具框架 Pester 介紹

Pester - PowerShell Test Framework

Pester 撰寫測試案例程式碼重點

  • Describe 來描述測試案例的主題,以 Context 來描述測試案例的子主題,以 It 來描述測試案例的斷言(Test Case)。
  • 雖然 Pester 自己預設行為是去搜尋執行目錄下以 .Tests.ps1 檔案名結尾的 PowerShell Script 檔案作為測試案例程式碼執行,但是得自行處理待測試 PowerShell Script 檔案( .ps1/.psm1 )的模組載入問題。
  • 透過 Mock 來模擬外部依賴的PowerShell函式呼叫/原生應用程式執行,以達到隔離測試的目的:
    • Mock 會在測試案例執行前將原本的函式/程式替換成 Mock 所定義的行為,測試案例執行完畢後再還原。
      • Pester 的 Mock 功能在 PowerShell v7+ 支援較好,建議測試案例的 script 程式碼儘量用 PowerShell v7+ 的環境來執行。
      • Pester 底層使用 PowerShell Alias 功能實現 Mock 命令實作。
      • 當宣告 Mock 時,要蓋掉的 PowerShell Cmdlet/函式命令 必須已存在於待測試程式碼的 Module Scope 內,否則 Pester 會無法蓋掉,轉而拋出執行錯誤。
      • 宣告的 Mock 在使用命令的程式碼所在的 Module 作用域(Module Scope) 內有效,而非全域有效。
    • Mock 會記錄 Mock 物件的呼叫次數、參數、回傳值等資訊,方便撰寫測試案例時檢查。
    • Mock 無法蓋掉使用 binary cmdlet 載入的 C# Cmdlet 函式,因為同名模組中的函式呼叫優先順序 C# >>> PowerShell
  • 透過 TestDrive: 來建立虛擬磁碟空間,以達到隔離測試的目的。
  • 透過 TestRegistry: 來建立虛擬 Windows Registry 空間,以達到隔離測試的目的。
  • 必要時可建立 Customer Pester Assertion 以便簡化驗證測試結果的程式碼區塊的撰寫。

Pester 為既有程式撰寫測試案例程式碼範例

Demo

範例程式碼 GitHub

  • 系統性的制式 Data-Driven 測試案例架構來驗證各模組函式的輸入參數名稱、型別、回傳值。
  • 由於 PowerShell 對於 .ps1 & .psm1 模組載入機制的不同,會造成某些待測模組的程式碼寫法,很不容易撰寫測試程式碼。
    • InModuleScope 以便在待測模組載入時的環境變數設定自定義邏輯(塞入固定參數/Mock/Assertion程式碼)。
    • 藉由先宣告一些 Dummy function 來『先佔位』以便順利進行宣告 Mock 語法,然後再載入 PowerShell 模組以便進行測試。
  • Pester Custom Assertion 的撰寫及註冊使用。

系統性制式 Data-Driven 測試案例架構驗證
模組函式 輸入參數名稱/型別/回傳型別

雖然 PowerShell 是 Strongly Typed Language,但是在專案寫大寫複雜時,仍然需要一些驗證機制來確定層層呼叫函式的輸入參數名稱、型別、回傳值型別是否正確。
( 避免跑script時被一堆 Command/Parameter Not Found 搞死)

先說結論:

藉由自行定義儲存 key-value 物件的 PowerShell 陣列,
描述函式的名稱、輸入參數、回傳值,透過 Pester 的測試案例來驗證載入之模組有這些函式。

Demo1to1MappingTestModule\Production_test\ProductFeatures\MathFeatureModule\MathFeature.Tests.ps1
Describe "Math function API declaration" -Tag "MathFeature", "FunctionDeclaration" {
    BeforeDiscovery {
        #region API definitions
        $ApiDefinition = @(
            @{
                Name        = 'Invoke-Add'
                CommandType = [System.Management.Automation.CommandTypes]::Function;
                Inputs      = @{
                    a = [int]
                    b = [int]
                }
                Outputs     = @([int])
            }
            @{
                Name        = 'Invoke-Sub'
                CommandType = [System.Management.Automation.CommandTypes]::Function;
                Inputs      = @{
                    a = [int]
                    b = [int]
                }
                Outputs     = @([int])
            }
            @{
                Name        = 'Invoke-Mul'
                CommandType = [System.Management.Automation.CommandTypes]::Function;
                Inputs      = @{
                    a = [int]
                    b = [int]
                }
                Outputs     = @([int])
            }
            @{
                Name        = 'Invoke-Div'
                CommandType = [System.Management.Automation.CommandTypes]::Function;
                Inputs      = @{
                    a = [int]
                    b = [int]
                }
                Outputs     = @([int])
            }
        )
        #endregion
    }
    BeforeAll {
        . (Resolve-Path $UtiltiyModulePath\VerifyPsDefApi.ps1)
    }

    It "Should have API `'<Name>`' defined in ApiDefinition" -ForEach $ApiDefinition {
        VerifyApiDefinition -Name $Name -CommandType $CommandType
    }
}

原理是使用 Get-Command Cmdlet 來取得函式的 CommandInfo 物件,再透過 Pester 的各項屬性之測試案例來驗證各項資訊:

Step 1: 驗證此函式是否存在於目前可使用的PowerShell模組之中。

Demo1to1MappingTestModule\Production_test\VerifyPsDefApi.ps1
function VerifyApiType() {
    param(
        [string] $Name,
        [System.Management.Automation.CommandTypes] $CommandType = [System.Management.Automation.CommandTypes]::Function
    )

    Process {
1        $targetExists = Get-Command -Name $Name -CommandType $CommandType -ErrorAction SilentlyContinue
        $targetExists | Should -Not -BeNullOrEmpty
        return $targetExists
    }
}
1
使用 Get-Command Cmdlet 來取得函式的參數資訊 CommandInfo 物件,然後透過 Pester 的測試案例確認物件存在,否則 test failed。

Step 2: 驗證此函式的輸入參數名稱、型別是否正確。

Demo1to1MappingTestModule\Production_test\VerifyPsDefApi.ps1
function VerifyApiInputParameters {
    param(
        [ValidateNotNull()]
        [System.Management.Automation.CommandInfo]
        $commandInfo,
        [System.Collections.Hashtable]$designedParameters
    )
    # Note: since PowerShell's built-in Hashtable is case insensitive, we can't use it to exactly check function parameters
    $parameterTable = New-Object 'System.Collections.Hashtable'
    foreach ($key in $designedParameters.Keys) {
        $parameterTable.Add($key, $designedParameters[$key])
    }

    $cmdletBuiltInParameters =
    @('Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'WarningAction', 'WarningVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'ProgressAction',
        'WhatIf', 'Confirm')

    foreach ($parameter in $commandInfo.Parameters.Values.GetEnumerator()) {
        $parameterName = $parameter.Name
        if ( $commandInfo.CmdletBinding -and $cmdletBuiltInParameters -contains $parameterName) {
            continue
        }
        $parameterTable.ContainsKey($parameterName) | Should -Be $true -Because "Parameter '$parameterName' should be exist"
        $expectedType = $parameterTable[$parameterName]
        # We compare type by its full name string, not rely on Pester's -BeOfType assertion
        # see https://github.com/pester/Pester/issues/1315#issuecomment-756700291
        $parameterTypeName = $parameter.ParameterType.FullName
        $parameterTypeName | Should -Be $expectedType.ToString() -Because "Parameter '`$$parameterName' should be of type '$expectedType'"
    }
}

Pester Mock 功能在 InModuleScope 的敏捷使用

在撰寫測試案例程式碼時,有時候會遇到待測試的 PowerShell Script 檔案中的函式呼叫,會依賴於其他函式的回傳值,這時候就需要使用 Pester 的 Mock 功能來模擬外部函式的回傳值。 而定義在模組內但沒有 export 的函式,外界摸不到,這時候就需要使用 InModuleScope 區塊功能來正確定義要操作的內部函式 Mock。

例如以下待測 PowerShell Script 檔案:

DemoScope\DemoUseAndRemoveMock\MyScript.psm1
function Get-CurrentTime {
    param()
    $output = (Get-Date).ToString()
    Write-Information -MessageData "In Real Get-CurrentTime() function, `$output=$output" -InformationAction Continue
    return $output
}

function MyDateTime {
    param()
    $time = Get-CurrentTime
    return "The current time is $time"
}

Export-ModuleMember MyDateTime

Tip

由於 Get-CurrentTime 函式沒有 export,所以外部無法直接對其 Mock 以避免測試結果會每次執行時都不同
(Get-CurrentTime 內部有呼叫 Get-Date 取得實際時間),
需使用 InModuleScope 區塊在正確的PowerShell module 作用域內部定義 Mock。

測試程式碼:

DemoScope\DemoUseAndRemoveMock\MyScript.Tests.ps1
Describe "MyFunction" {
    BeforeAll {
        Import-Module "$PSScriptRoot/MyScript.psm1" -Verbose -Force
    }
    It "should return the mocked time" {
1        InModuleScope MyScript {
            Mock -CommandName Get-CurrentTime -MockWith {
                $output = "2024-11-14 08:00:00"
                Write-Information -MessageData `
                    "In Mocked Get-CurrentTime(), output=$output" `
                    -InformationAction Continue
                return $output
            }

2            $result = MyDateTime
            $result | Should -Be "The current time is 2024-11-14 08:00:00"

            # Remove mocked Get-CurrentTime Function
3            Remove-Alias Get-CurrentTime
        }

        # Call the function again and check the result without the mock
4        $result = MyDateTime
        $result | Should -Not -Be "The current time is 2024-11-14 08:00:00"
    }
}
1
使用 InModuleScope 區塊來定義 Mock 的函式,這樣就可以在正確的 PowerShell module 作用域內部定義 Mock。
2
InModuleScope 區塊內部執行待測試的函式,並驗證其回傳值是否為前一步驟 Mock 的回傳值。
3
移除 Mock 的方法就是呼叫 Remove-Alias Cmdlet。
4
移除 Mock 後再次執行待測試的函式,並驗證其回傳值是否正確。

所以在更為複雜的實際PowerShell Script專案架構中,可以使用此技巧撰寫測試案例程式碼提高 code coverage rate。

Note

更複雜的如:
載入 .ps1 script檔時會立刻執行的函式如何 Mock ?
參閱此範例專案的
Demo1to1MappingTestModule/Production_test/ProductFeatures/StringFeatureModule/FormatDateTime.Tests.ps1
測試案例程式碼

Pester Custom Assertion

Pester 沒有提供浮點數值精確度比較的 Assertion Cmdlet, 所以就自己動手寫一個:

Demo1to1MappingTestModule\Production_test\ProductFeatures\MathFeatureModule\MathFeature.Tests.ps1
1function Should-BeEqualWithPrecision(
2  [double] $ActualValue, [double] $ExpectValue,
  [uint] $Precision,
  [switch] $Negate,
  [string] $Because
) {
3    $pass = [math]::Round($ActualValue, $Precision) -eq [math]::Round($ExpectValue, $Precision)
4    if ($Negate) {
        $pass = -not $pass
    }

5    if (-not $pass) {
        if ($Negate) {
            $failureMessage =
              "Expected '$ActualValue' to not be equal to $ExpectValue$(if($Because) { " because $Because"})."
        }
        else {
            $failureMessage =
              "Expected '$ActualValue' to be equal to $ExpectValue$(if($Because) { " because $Because"})."
        }
    }

6    return [pscustomobject]@{
        Succeeded      = $pass
        FailureMessage = $failureMessage
    }
}
1
Pester Custom Assertion 就是一個自己寫的 PowerShell Simple FunctionVerb-Noun 的命名風格是 VerbShould 不可改動,Noun BeEqualWithPrecision 為我們自己定義的名稱。
2
輸入和輸出的格式得配合 Pester 的規範:$ActualValue 是實際值,$Negate 是設定是否反轉布林邏輯,$Because 是 Assertion 失敗時印出的訊息,這三個輸入參數的名稱一定要正確;而$ExpectValue 預期值和設定浮點數值精確度的 $Precision 是我們自己定義的額外輸入參數,可於撰寫測試程式碼的 Assertion statements 時多指定在呼叫程式碼中。
3
這個 Custom Assertion 的實作是透過將兩個浮點數值四捨五入到指定精確度後再比較是否相等,以達到比較浮點數值的精確度比較。
4
假如有指定 $Negate 參數,則反轉判斷結果的布林值。
5
為不同的 Assertion 結果設定不同的錯誤訊息。
6
最後回傳一個 PSCustomObject 物件,其中包含了 Assertion 的結果布林值(Succeeded)和錯誤訊息(FailureMessage)。

然後在測試案例程式碼中先呼叫 Add-ShouldOperator註冊該 Custom Assertion:

Demo1to1MappingTestModule/Production_test/ProductFeatures/MathFeatureModule/MathFeature.Tests.ps1
Add-ShouldOperator -Name BeEqualWithPrecision -Test ${Function:Should-BeEqualWithPrecision}

就可以在撰寫測試案例程式碼時使用 Should -BeEqualWithPrecision 這個 Custom Assertion 了:

Demo1to1MappingTestModule/Production_test/ProductFeatures/MathFeatureModule/MathFeature.Tests.ps1
$actual | Should -BeEqualWithPrecision -ExpectValue $expect -Precision 2

PowerShell + C# binary cmdlet

PowerShell + C# binary cmdlet

  • PowerShell v6+ & Windows PowerShell 5.1+ 都支援使用 C# 來撰寫 binary cmdlet 來擴充 PowerShell 的功能。
  • 透過 C# 的強型別、高效能、多執行緒等特性來撰寫 PowerShell 的 binary cmdlet,可以提高 PowerShell script的執行效能。
  • 一些 PowerShell 原生沒有提供的功能可透過 C# 來撰寫 binary cmdlet 來擴充 PowerShell 的使用範圍。

but… C# binary cmdlet 的測試案例撰寫相對較困難😵

PowerShell C# binary cmdlet 的簡單範例

Demo

範例程式碼 GitHub

Tip

使用 VSCode 來跑偵錯 PowerShell binary cmdlet 時,注意 launch.json 的 console 需設定為 “integratedTerminal”,否則無法正確停在 debug point。

.vscode/launch.json
{
     "configurations": [
        {
            "name": "NetStandard module Pwsh CLI",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build netstandard module",
            "program": "pwsh",
            "args": [
                "-NoExit",
                "-NoProfile",
                "-Command",
                "Import-Module ${workspaceFolder}/src/PwshStandardModule/bin/Debug/netstandard2.0/PwshStandardModule.dll"
            ],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "console": "integratedTerminal"
        },
        {
            // NOTE: this task is Windows only
            "name": "NetStandard module Windows PowerShell CLI",
            "type": "clr",
            "request": "launch",
            "preLaunchTask": "build netstandard module",
            "program": "powerShell",
            "args": [
                "-NoExit",
                "-NoProfile",
                "-Command",
                "Import-Module ${workspaceFolder}/src/PwshStandardModule/bin/Debug/netstandard2.0/PwshStandardModule.dll"
            ],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "console": "integratedTerminal"
        }
    ]
}

(PowerShell + C# binary cmdlet) call gRPC API

Demo

範例程式碼 GitHub

  • 使用 gRPC-web 的 C# Client Nuget 套件以便在不支援 HTTP/2 的 Windows PowerShell 環境中呼叫 gRPC 服務。
    • 如何為 Windows PowerShell 的 C# binary cmdlet 撰寫 Assembly Resolver 以便成功載入 gRPC-Web client Library。
  • 繼承 PSCmdlet 類別所撰寫 PowerShell Advanced function 的 binary cmdlet 如何使用 xUnit 測試框架來撰寫測試案例。
  • 使用混合 PowerShell module (.psd1 & .psm1) + C# binary cmdlet 的方式來撰寫混合型 PowerShell Module 專案。
    • 如何於 Windows PowerShell v5.1 & PowerShell v7+ 跑偵錯。
    • 如何針對 PowerShell + C# 程式碼的專案撰寫 PowerShell 的 Pester 測試案例。

PowerShell extension for Visual Studio Code 提供可從 PowerShell script 偵錯到 C# binary cmdlet 的功能:

.vscode/launch.json
{
    "configurations": [
        // other configurations
        {
            "name": "(Pwsh v7)Debug both Pwsh & C#",
            "type": "PowerShell",
            "request": "launch",
            "preLaunchTask": "build PowerShell Cmdlet",
            "script": "${workspaceFolder}/Pwsh/debug_scripts/debug-pwsh.ps1",
            "createTemporaryIntegratedConsole": true,
            "attachDotnetDebugger": true
        }
    ]
}

gRPC-Web 的相依 Assembly dll 版本比原本 Windows PowerShell v5.1 所使用的版本新,需要寫額外的 Assembly Resolver 程式碼控制 .NET Framework CLR 載入 dll 機制。

Pwsh\GreeterCmdlet\WindowsPowerShellModuleInitializer.cs
#if NETSTANDARD2_0
using System.Management.Automation;
using System.Reflection;

namespace GreeterCmdlet;

1public class WindowsPowerShellModuleInitializer : IModuleAssemblyInitializer
{
    public void OnImport()
    {
        AppDomain.CurrentDomain.AssemblyResolve += DependencyResolution.ResoleAssembly;
    }
}

2public class WindowsPowerShellModuleCleanup : IModuleAssemblyCleanup
{
    public void OnRemove(PSModuleInfo psModuleInfo)
    {
        AppDomain.CurrentDomain.AssemblyResolve -= DependencyResolution.ResoleAssembly;
    }
}

internal static class DependencyResolution
{
    private static readonly string ModulePath = 
                                Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;

    public static Assembly ResoleAssembly(object? sender, ResolveEventArgs args)
    {
        var assemblyName = new AssemblyName(args.Name);
3        if (assemblyName.Name == "System.Buffers")
        {
            return Assembly.LoadFrom(Path.Combine(ModulePath, "System.Buffers.dll"));
        }
        
4        if (assemblyName.Name == "System.Runtime.CompilerServices.Unsafe")
        {
            return Assembly.LoadFrom(Path.Combine(ModulePath, "System.Runtime.CompilerServices.Unsafe.dll"));
        }

        return null!;
    }
}
#endif
1
實作 IModuleAssemblyInitializer 介面註冊 Assembly Resolver 事件處理函式。
2
實作 IModuleAssemblyCleanup 介面移除 Assembly Resolver 事件處理函式。
3
針對 gRPC-Web client 套件相依 Systems.Buffers.dll 的 Assembly Resolver 實作,直接載入同目錄由 MSBuild 一起 copy 過來的 dll 版本。
4
針對 Systems.Buffers 相依 System.Runtime.CompilerServices.Unsafe.dll 的 Assembly Resolver 實作,直接載入同目錄由 MSBuild 一起 copy 過來的 dll 版本。

將實際呼叫 gRPC 服務的 C# 程式與 Binary Cmdlet 專案的分開並定義介面 IGreetingGrpcClient,以便於稍後撰寫測試案例。

Pwsh\GreeterCmdlet\CallGreetingClientLibCmdlet.cs
[Cmdlet(VerbsCommunications.Send, "GreeterGrpcApi")]
[OutputType(typeof(GreetingResponse))]
public class CallGreetingClientLibCmdlet : PSCmdlet
{
    [Parameter(Mandatory = true, Position = 0)]
    public string Server { get; set; } = string.Empty;

    [Parameter(Mandatory = true)] public GreetingRequest Request { get; set; } = null!;

    [Parameter(Mandatory = false)] public IGreetingGrpcClient ApiClient { get; set; } = new GreetingClient();

    protected override void BeginProcessing()
    {
        if (ApiClient is GreetingClient greetingClient)
        {
            greetingClient.ServerUrl = $"https://{Server}";
        }
    }

    protected override void ProcessRecord()
    {
        try
        {
            var reply = ApiClient.GetGreeting(Request);
            WriteObject(reply);
        }
        catch (Exception ex) {
            /* handle exception */
        }
    }

    /// <summary>
    /// This method is used for testing purposes only.
    /// </summary>
    internal void ProcessInternalForTest()
    {
        BeginProcessing();
        ProcessRecord();
        EndProcessing();
    }
}

Note

35~40行是為了要在測試程式碼中模擬 PSCmdlet 的 PowerShell runtime 執行環境,以便讓測試程式呼叫 PowerShell Cmdlet 提供的 BeginProcessing(), ProcessRecord(), EndProcessing() 這三個 API 實作方法依序執行的內藏函式,並在此專案的 .csproj 設定檔中宣告 <InternalVisible>...</InternalVisible>讓 xUnit 測試專案有權限可以執行此方法。

實際寫 xUnit test case 時,可用 Moq nuget 套件提供的功能產生 Mock<IGreetingGrpcClient> 物件來模擬呼叫 gRPC 服務的行為。

tests\csharp\GreeterCmdletTests\CallGreetingClientLibCmdletTests.cs
[Fact]
public void ProcessRecord_ShouldWriteObject_WhenApiClientReturnsResponse()
{
    // Arrange
1    var mockApiClient = new Mock<IGreetingGrpcClient>();
    var request = new GreetingRequest("Test");
    var response = new GreetingResponse("Hello, Test!");
    mockApiClient.Setup(client => client.GetGreeting(request))
                    .Returns(response);
                    
    var cmdlet = new CallGreetingClientLibCmdlet
    {
        Server = "localhost",
        Request = request,
        ApiClient = mockApiClient.Object
    };

    // Act
2    var pipelineEmulator = new CommandRuntimeEmulator();
    cmdlet.CommandRuntime = pipelineEmulator;
    cmdlet.ProcessInternalForTest();

    // Assert
    var results = pipelineEmulator.OutputObjects;
    Assert.Single(results);
    var actualResponse = results.First() as GreetingResponse;
    Assert.NotNull(actualResponse);
    Assert.Equal(response.Message, actualResponse.Message);
}
1
建立 Mock<IGreetingGrpcClient> 來模擬呼叫 gRPC 服務的行為,就不需在跑測試時使用實際的 gRPC Server 服務。
2
使用 CommandRuntimeEmulator 這個自製的實作 ICommandRuntime 介面類別來模擬 Cmdlet 的 PowerShell runtime 實際執行環境,並呼叫預留的 ProcessInternalForTest() 方法來依序執行此Cmdlet 的 BeginProcessing(), ProcessRecord(), EndProcessing() 這三個 API 實作。

ASP.NET Core 整合 PowerShell Hosting Environment

ASP.NET Core 整合 PowerShell Hosting Environment

ASP.NET Core Minimal API 整合執行 PowerShell Script 範例

Demo

範例程式碼 GitHub

  • 此範例程式為一個檢查執行環境中兩個目錄是否有同樣檔案的 Web API,透過 PowerShell script 來執行真正檔案比對的工作。
  • 如何在 .NET Core / .NET 5+ 專案使用 PowerShell Hosting Environment API 指派輸入參數給 PowerShell script 執行,並取得執行結果。
  • xUnit 測試專案 test method 間接執行用 Pester 撰寫的 PowerShell test case 驗證 script 行為。

此 PowerShell script 在先前的 Pester test case 範例專案中提供並且已有驗證行為的測試案例

使用 PowerShell 來撰寫取得所有有特定副檔名的檔案邏輯,比單純使用 C# 簡單:

src\DemoWebApi\PwshScripts\DuplicateFilesFinder.psm1
function Get-Files {
    [SuppressMessage('PSUseSingularNouns', '')]
    param (
        [string]$Path,
        [string]$Extension
    )
    return Get-ChildItem -Path $Path -Recurse -File -Filter "*.$Extension"
}

也可用PowerShell 的 Get-FileHash Cmdlet 取得檔案的 Hash 值來快速判斷檔案內容是否相同:

src\DemoWebApi\PwshScripts\DuplicateFilesFinder.psm1
function Get-FileContentHash {
    [SuppressMessage('PSAvoidUsingBrokenHashAlgorithms', '', 
        Justification = 'MD5 is just used for file hash comparison')]
    param (
        [Parameter(Mandatory = $true)]
        [System.IO.FileInfo]$FilePath
    )
    return Get-FileHash -Algorithm MD5 -Path $FilePath # -SilentlyContinue
}

在 ASP.NET Core Minimal API 中使用 PowerShell Hosting Environment 來執行 PowerShell script/指令,需要先建立相關的 PowerShell, Runspace 等 API 物件,以及使用 .AddScript()/.AddCommand()/.AddParameter() 等 chainable fluent API 的方式來設定 PowerShell script 的執行環境:

src\DemoWebApi\Services\DuplicateFileFinderInvokeService.cs
public DuplicateFileFinderInvokeService(ILogger<DuplicateFileFinderInvokeService> logger)
{
1    _runspacePool = RunspaceFactory.CreateRunspacePool();
    _runspacePool.Open();
    _logger = logger;
}

public async Task<IList<DuplicateFileResult>> RunScriptAsync(ApiInputDto funcInput,
    CancellationToken cancellationToken)
{
    using var ps = PowerShell.Create();
    ps.RunspacePool = _runspacePool;
    cancellationToken.Register(() =>
    {
2        if (ps.InvocationStateInfo.State == PSInvocationState.Running)
        {
            ps.Stop();
        }
    });

    /* ... */

    try
    {
3        ps.AddScript("Import-Module ./PwshScripts/DuplicateFilesFinder.psm1 -Verbose")
            .AddStatement();
        ps.AddCommand("Get-DuplicateFile")
            .AddParameter("SourcePath", funcInput.SourceFolder)
            .AddParameter("ComparePath", funcInput.CompareFolder);
        if (!string.IsNullOrEmpty(funcInput.CompareType))
        {
            ps.AddParameter("CompareType", funcInput.CompareType);
        }
        /* run above arranged commands via ps.Invoke(...) or ps.InvokeAsync(...) */
    }
    catch (Exception ex)
    {
        // handle exception
    }
}
1
使用 RunspacePool 來管理 PowerShell 的 Runspace 執行環境,記得要呼叫 .Open() 後才能後續在 PowerShell 物件上執行 .Invoke()/.InvokeAsync() 實際跑 PowerShell 程式。
2
設定當 CancellationToken 觸發時,停止目前 PowerShell runtime 的執行。
3
.AddScript() 用來載入 PowerShell script 檔案,.AddStatement()同等於 PowerShell script 檔案裡換行,.AddCommand() 表示執行 PowerShell function/Cmdlet,.AddParameter()/.AddParameters() 用來設定前面預計要執行的 PowerShell function/Cmdlet 的輸入參數值。

原本在 PowrShell script 裡用的是 PowerShell 定義自訂 Class 類別,因此在執行完畢回傳的結果值物件直接使用 dynamic keyword 寫法簡化取得 FilePath1 & FilePath2 屬性值程式碼。

src\DemoWebApi\Services\DuplicateFileFinderInvokeService.cs
var result =
    ps.Invoke(input: null, settings: psInvocationSettings)
1        .FirstOrDefault()?.BaseObject as object[];

var duplicateFileResults = new List<DuplicateFileResult>(result.Length);
// use dynamic to make simpler coding style to avoid reflection
2foreach (dynamic duplicateFileInfo in result)
{
    var duplicateFileResult = new DuplicateFileResult
    {
3        SourceFilePath = duplicateFileInfo.FilePath1.ToString(),
        CompareFilePath = duplicateFileInfo.FilePath2.ToString()
    };
    duplicateFileResults.Add(duplicateFileResult);
}

return duplicateFileResults;
1
由於原本 PowerShell script function 的回傳值是單個物件陣列,所以取得回傳值順便先轉型成 object[] 陣列。
2
使用 dynamic 關鍵字來簡化原本 PowerShell 程式碼中定義的自訂 class 型別。
3
就可以直接使用物件的屬性值(C#編譯器不檢查,如果寫錯就會有 runtime exception)。

執行 Pester 測試的 xUnit 程式碼

在 xUnit Test method 中呼叫 PowerShell runtime 執行 Pester 測試案例,使用 Pester 框架提供的 Invoke-Pester Cmdlet 的 -PassThru 命令列參數,可使 Pester 將執行測試結果傳出,再於 C# 中驗證測試結果是否符合預期:

tests\DemoWebApi.Tests\PwshModuleTest.cs
[Fact]
public void TestDuplicateFileFinderPwshFunction()
{
    // Arrange
    using var runspace = RunspaceFactory.CreateRunspace();
    runspace.Open();
    using var ps = PowerShell.Create(runspace);
    var psInvocationSettings = new PSInvocationSettings
    {
        AddToHistory = false,
        ErrorActionPreference = ActionPreference.Stop,
    };
    #region TestScript
    const string testScript = 
    """
1    $tempModulePath = $env:TEMP + "\PwshModule$((Get-Date).Ticks)"
    if(-not (Test-Path $tempModulePath)) {
        New-Item -Path $tempModulePath -ItemType Directory -ErrorAction Stop | Out-Null
    }
    else {
        Remove-Item -Path $tempModulePath -Recurse -Force -ErrorAction Stop | Out-Null
        New-Item -Path $tempModulePath -ItemType Directory -ErrorAction Stop | Out-Null
    }
2    Save-Module -Name Pester -Path $tempModulePath -ErrorAction Stop
    Import-Module $tempModulePath\Pester -Force -ErrorAction Stop

3    $currentDir = [System.IO.Directory]::GetCurrentDirectory()
    $TestScriptPath = "$currentDir\..\..\..\PwshScripts\DuplicateFilesFinder.Tests.ps1"
    if(-not (Test-Path $TestScriptPath)) {
        throw "Test script not found at $TestScriptPath"
    }
4    $result = Invoke-Pester -Path $TestScriptPath -PassThru
    return $result
    """;
    #endregion
    ps.AddScript(testScript);

    // Act
    var psResult = ps.Invoke(input: null, settings: psInvocationSettings).FirstOrDefault();

    // Assert
    Assert.NotNull(psResult);
5    var testResult = psResult.Properties["Result"].Value as string;
    var totalCount = psResult.Properties["TotalCount"].Value as int?;
    var passedCount = psResult.Properties["PassedCount"].Value as int?;
    var failedCount = psResult.Properties["FailedCount"].Value as int?;
    Assert.NotNull(testResult);
    Assert.NotNull(totalCount);
    Assert.NotNull(passedCount);
    Assert.NotNull(failedCount);
    Assert.Equal("Passed", testResult);
    Assert.True(passedCount > 0);
    Assert.Equal(totalCount, passedCount);
    Assert.Equal(0, failedCount);
}
1
建立暫存目錄來在稍後步驟用來放最新版 Pester PowerShell module。
2
使用 PowerShellGet 的 Save-Module 命令將最新版 Pester 從 PowerShell Gallery 下載至暫存目錄,然後用 Import-Module 載入該版本的 Pester 模組。(參考官方手動安裝說明文件)
3
取得目前執行測試程式的目錄,並且設定要執行的 Pester 測試案例 script 檔案路徑。
4
執行 Pester 測試案例 script 檔案,並將測試結果回傳給 C# 程式碼。
5
由於 Pester 測試結果回傳的是 PowerShell PSCustomObject 型態物件,所以透過 Properties 屬性來取得各個測試結果的屬性值。

如此我們就可以將 PowerShell 的 Pester 單元測試整合至 xUnit 測試專案中,讓 C# code & PowerShell script 一起執行測試驗證程式邏輯。👍

參考資料🏫

Q & A

Any Questions?
🙋‍♂️🙋‍♀️🙋