建立可擴展的 PowerShell Pester 測試框架

如果你發現自己花更多時間在維護 Pester 測試上,而不是實際創建新測試,那麼這篇文章就是為你準備的。在這篇文章中,我將分享我與 Devolutions 合作的一個項目。

背景故事

我們在 Devolutions 遇到了一個問題。旗艦的 PowerShell 模組 Devolutions.PowerShell 缺少 Pester 測試。我知道,我知道。他們有 C# 測試,但那是另一回事。

作為一名專注於 PowerShell 的 Devolutions 顧問,我被要求創建一套 Pester 測試,以便在 CI/CD 管道中使用並在生產部署之前執行。我想,這沒問題。我使用過這個模組幾次,應該不會太困難。結果我錯了。

2025-01-29_09-27-59.png

在沒有任何具體要求的情況下,只是“建立一些測試”,我開始為整個模組構建測試,結果發現它有將近 500 個命令!這將需要一段時間。

因為我一直想要構建不僅僅是為了今天而是為了長期的解決方案,我不想將一堆測試放入一個腳本中就算了。那個腳本會非常龐大!

相反,在寫第一個測試之前,我想設計一個可以使用的框架,它應該:

  1. 可擴展 – 我自己或其他人可以輕鬆地將測試添加到框架中,而不需要太多考慮。
  2. 為了防止代碼重複,這個項目需要遵循 DRY 原則(不要重複自己)。我不想重複任何代碼,因此我使用了共享腳本、函數等。
  3. 要使用數據驅動測試:數據驅動測試通過在數據結構中定義測試來促進代碼重用,然後讓測試參考該結構,而不是為每個測試重複測試代碼。
  4. 模塊化 – 所有測試必須分拆成某種結構,以防止需要滾動過多不必要的代碼來找到所需的內容。
  5. 支持環境設置和拆解場景 – 這些測試是端到端(E2E)/集成/驗收測試,無論你想稱之為什麼。它們測試最終用戶的使用情況;沒有單元測試,因此必須在環境中設置各種內容才能運行。
  6. 支持無需一次性運行所有測試的測試組運行。

這些要求導致了Devolutions的Pester測試框架。

先決條件

如果你想使用這個框架,我不能保證它100%有效,除非你在與我相同的環境中工作。要使用這個框架,我使用了:

  • Windows上的PowerShell v7.4.5
  • Pester 5.6.1

這仍然可以在Windows PowerShell和早期版本的Pester v5上運行,但不能保證!

框架概述

這個框架由四個組件組成:一個調用腳本、各種測試定義腳本、一個可選的存儲輔助函數的腳本,以及可選的before all / before each腳本。這些組件在文件系統中的結構如下:

📁 root/
   📄 caller.tests.ps1
   📄 helper_functions.ps1
   📁 test_definitions/
      📁 group1/
         📄 beforeall.tests.ps1
         📄 beforeeach.tests.ps1
         📄 core.tests.ps1
         📄 subgroup.tests.ps1

當你調用調用腳本時:

Invoke-Pester -Path root/caller.tests.ps1

調用腳本:

  1. 在每個組中查找所有測試定義腳本。
  2. 查找每個組的所有 beforeeachbeforeall 腳本。
  3. 為每個組和子組創建 Pester 上下文。
    上下文 'group' {
        上下文 'subgroup' {
    
        }
    }
  4. 在組中任何測試運行之前調用一次 beforeall 腳本。
  5. 在組中的每個測試之前調用 beforeeach 腳本。
  6. 最後,它調用每個測試定義中定義的測試斷言。

調用測試腳本

調用腳本是 Pester 的調用點。這是 Invoke-Pester 在需要調用框架中的任何測試時所調用的腳本。

調用腳本分為 X 個區域:

BeforeDiscovery 區塊

調用腳本的一個任務是找到所有測試定義以及 Pester 的發現階段的目的。在腳本的 BeforeDiscovery 區塊中,收集測試定義,以及任何 before eachbefore all 腳本。

BeforeDiscovery {

    # Initialize hashtable to store all test definitions
    $tests = @{}

    # Iterate through test group directories (e.g. datasources, entries)
    Get-ChildItem -Path "$PSScriptRoot\test_definitions" -Directory -PipelineVariable testGroupDir | ForEach-Object {
        $testGroup = $testGroupDir.BaseName
        $tests[$testGroup] = @{}

        # Load beforeall script for test group if it exists
        # This script runs once before all tests in the group
        $testGroupBeforeAllScriptPath = Join-Path -Path $testGroupDir.FullName -ChildPath 'beforeall.ps1'
        if (Test-Path -Path $testGroupBeforeAllScriptPath) {
            $tests[$testGroup]['beforeall'] += . $testGroupBeforeAllScriptPath
        }

        # Load beforeeach script for test group if it exists
        # This script runs before each individual test in the group
        $testGroupBeforeEachScriptPath = Join-Path -Path $testGroupDir.FullName -ChildPath 'beforeeach.ps1'
        if (Test-Path -Path $testGroupBeforeEachScriptPath) {
            $tests[$testGroup]['beforeeach'] += . $testGroupBeforeEachScriptPath
        }

        # Load all test definition files in the group directory
        Get-ChildItem -Path $testGroupDir.FullName -Filter '*.ps1' -PipelineVariable testDefinitionFile | ForEach-Object {
            $tests[$testGroup][$testDefinitionFile.BaseName] = @()
            $tests[$testGroup][$testDefinitionFile.BaseName] += . $testDefinitionFile.FullName
        }
    }
}

BeforeAll 區塊

BeforeAll 區塊運行以使任何輔助函數可用於調用腳本或測試定義。在 Pester v5 中,這個任務不能在 BeforeDiscovery 中;否則,它將無法對測試可用。

# Load helper functions used across all tests
BeforeAll {
    . (Join-Path $PSScriptRoot -ChildPath "_helper_functions.ps1")
}

Describe 區塊和 Contexts

您需要運行測試的每個環境配置被拆分為上下文,並在每個上下文下有測試組。

對於 Devolutions PowerShell 模組,由於我們需要針對許多不同類型的數據源進行測試,我按數據源創建了上下文,但您可以在這裡使用任何內容。然後,使用 Pester 的 ForEach 參數,每個測試定義文件夾都是一個上下文,每個子組也是一個上下文。然後,每個測試定義在其中作為 it 塊。


請注意 it 塊上的 where() 方法。 Where({ $_.environments -contains 'xxx' -or $_.environments.count -eq 0})。這是每個定義結構發揮作用的地方。這部分是我們指定哪些測試在哪個環境運行的地方。


# Main test container for all tests
Describe 'RDM' {

    # Tests that run against an environment
    Context 'Environment1' -Tag 'Environment1' {

        BeforeEach {
            ## Do stuff to execute before each test in this content. For example, this is used to set up a specific data source for Devolutions Remote Desktop Manager
        }

        # Clean up
        AfterAll {

        }

        # Run each test group against the environment
        Context '<_>' -ForEach $tests.Keys -Tag ($_) {
            $testGroup = $_

            # Run each test subgroup (core, properties, etc)
            Context '<_>' -ForEach $tests[$testGroup].Keys -Tag ($_) {
                $testSubGroup = $_

                # Run tests marked for this environment or all
                It '<name>' -ForEach ($tests[$testGroup][$testSubGroup]).Where({ $_.environments -contains 'xxx' -or $_.environments.count -eq 0}) {
                    & $_.assertion
                }
            }
        }
    }

    ## Other environments. If you have specific configurations some tests must have, you can define them here by creating more context blocks.
}

測試定義

接下來,您將看到最重要的部分:測試!測試是在每個組文件夾內部的子組(subgroup.tests.ps1)中創建的,必須按以下結構創建為哈希表數組:

@(
    @{
        'name' = 'creates a thing'
        'environments' = @() ## Nothing means all environments
        'assertion' = {
            ## Teating something
            $true | should -Be $true
        }
    }
)

在這裡,您可以在調用腳本中定義環境以執行腳本。例如,如果您為 Devolutions RDM 使用的每個數據源設置一個環境,我的環境是 xmlsqllite 等。

輔助函數

最後,我們有輔助函數腳本。此腳本包含我們在調用腳本中的 BeforeAll 塊中點源的函數。這是您可以放置任何要重複使用的函數的地方。在我的示例中,我有一些設置數據源和刪除所有數據源的函數。

# Helper function to remove all entries from the current data source
# Used for cleanup in test scenarios
function Remove-AllEntries {
    try {
        # Get all entries and their IDs
        # Using ErrorAction SilentlyContinue to handle case where no entries exist
        $entries = @(Get-RDMEntry -ErrorAction SilentlyContinue)

        # If no entries found, just return silently
        # No cleanup needed in this case
        if ($entries.Count -eq 0) {
            return
        }

        # Delete entries one at a time
        # Using foreach loop to handle errors for individual entries
        foreach ($entry in $entries) {
            try {
                # Remove entry and refresh to ensure UI is updated
                Remove-RDMEntry -ID $entry.ID -Refresh -ErrorAction Stop
            } catch [System.Management.Automation.ItemNotFoundException] {
                # Silently ignore if entry is already gone
                # This can happen if entry was deleted by another process
                continue
            }
        }
    } catch {
        # Only warn about unexpected errors
        # Ignore "Connection not found" as this is expected in some cases
        if ($_.Exception.Message -ne 'Connection not found.') {
            Write-Warning "Error during cleanup: $_"
        }
    }
}

# Helper function to remove all data sources and their files
# Used for cleanup in test scenarios
function Remove-AllDatasources {
    # Remove any data sources currently loaded in memory
    # This ensures clean state for tests
    Get-RDMDataSource | ForEach-Object { Remove-RDMDataSource -DataSource $_ }

    # Delete any existing data source folders on disk
    # These are identified by GUIDs in the RDM application data folder
    Get-ChildItem $env:LOCALAPPDATA\Devolutions\RemoteDesktopManager | 
        Where-Object { $_.Name -match '^[0-9a-fA-F\-]{36}
}

如何構建您的框架

這個框架看起來實用嗎?你認為它對你的組織會有幫助嗎?如果是,這裡有幾個提示和問題可以思考。

  1. 你預計需要多少測試?你的使用案例足以支持這個框架,而不僅僅是幾個測試腳本嗎?
  2. 在編寫任何 PowerShell 之前,描述一下你預計如何定義群組和子群組。讓它們成為你正在測試的任何元素的邏輯抽象。
  3. 考慮一下每組測試所需的環境和配置;將這些作為上下文創建在調用腳本中。
  4. 在構建測試定義時,如果你發現自己重複代碼,為它創建一個函數並將其放入 helper_functions.ps1。

構建一個可擴展的 Pester 測試框架需要仔細的規劃和組織,但這些好處是值得付出的努力。通過遵循本文所述的結構——其模組化設計、可重用組件和靈活的環境處理——你可以創建一個隨著需求增長而增強的堅固測試解決方案。無論你是在測試 PowerShell 模組、基礎設施配置,還是複雜應用程序,這個框架都為維護整個組織的代碼質量和可靠性提供了堅實的基礎。

Source:
https://adamtheautomator.com/powershell-pester-testing-framework/