スケーラブルなPowerShell Pesterテストフレームワークを構築する

Pester テストのメンテナンスに実際に新しいテストを作成するよりも時間を費やしている場合は、この投稿が役立ちます。この投稿では、私がDevolutionsと共に取り組んできたプロジェクトを共有します。

背景

Devolutions には問題がありました。フラッグシップの PowerShell モジュール Devolutions.PowerShell には Pester テストがありませんでした。そうです、C# テストはあったのですが、それはまた別の話です。

PowerShell に特化したDevolutionsのコンサルタントとして、CI/CD パイプラインで使用され、本番展開の前に実行される Pester テストスイートを作成するよう依頼されました。問題ありません、と考えました。モジュールを数回使用したことがあり、それほど難しくはないだろうと思いました。しかし、私は間違っていました。

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

具体的なリクエストがなく、「いくつかのテストを作成してください」とだけ言われたため、モジュール全体のテストを作成し始めましたが、コマンドがほぼ500もあることがわかりました!これには時間がかかることになりました。

常に今日だけでなく長期的な解決策を構築したかったため、単一のスクリプトに多数のテストを追加して終わりにすることは避けたかったのです。そのスクリプトは膨大になるはずです!

その代わりに、1 つのテストも書く前に、以下のようなフレームワークを設計したかったのです:

  1. 拡張可能 – 私自身や他の人が簡単に考えることなくフレームワークにテストを追加できるようにする必要があります。
  2. コードの重複を防ぐため、このプロジェクトは DRY(自己繰り返しをしない)である必要がありました。コードを重複させたくなかったため、代わりに共有スクリプト、関数などを使用しました。
  3. データ駆動型テストを使用するには:
  4. データ駆動型テストは、テストをデータ構造で定義し、テストがその構造を参照することで、各テストごとにテストコードを複製する代わりにコードの再利用性を促進します。
  5. モジュール化すること – すべてのテストは、不要なコードをスクロールして探す必要がないような構造に分割する必要があります。
  6. 環境のセットアップと後処理シナリオをサポートするために – これらのテストはエンドツーエンド(E2E)/ 統合/ 受け入れテストであり、エンドユーザーの使用方法をテストしています。ユニットテストはないため、実行するために環境にさまざまな設定が必要です。

これらの要件は、DevolutionsのPesterテストフレームワークにつながります。

前提条件

このフレームワークを使用する場合、私と同じ環境で作業していない限り、100%動作することを保証できません。このフレームワークを使用するには、次のようにしました:

  • Windows上のPowerShell v7.4.5
  • Pester 5.6.1

これは引き続きWindows PowerShellおよびPester v5の以前のバージョンでも動作しますが、保証はありません!

フレームワークの概要

このフレームワークには、呼び出しスクリプト、さまざまなテスト定義スクリプト、ヘルパー関数を保存するためのオプションのスクリプト、およびオプションのbefore all / before eachスクリプトの4つのコンポーネントが含まれています。これらのコンポーネントは、ファイルシステム内で次のように構造化されています:

📁 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. 各グループのbeforeeachおよびbeforeallスクリプトを見つけます。
  3. 各グループおよびサブグループに対してPesterコンテキストを作成します。
    context 'group' {
        context 'subgroup' {
    
        }
    }
  4. グループ内のテストが実行される前にbeforeallスクリプトを1回呼び出します。
  5. グループ内のすべてのテストの前にbeforeeachスクリプトを呼び出します。
  6. 最後に、各テスト定義で定義されたテストアサーションを呼び出します。

呼び出し元のテストスクリプト

呼び出しスクリプトはPesterの呼び出しポイントです。これは、フレームワーク内のテストを呼び出す必要があるときにInvoke-Pesterが呼び出すスクリプトです。

呼び出しスクリプトはXのエリアに分割されます:

BeforeDiscoveryブロック

呼び出しスクリプトの1つのタスクは、すべてのテスト定義とPesterの検出フェーズが何であるかを見つけることです。スクリプトのBeforeDiscoveryブロック内で、テスト定義だけでなく、before eachまたはbefore 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モジュールでは、さまざまな種類のデータソースに対してcmdletをテストする必要があるため、データソースごとにコンテキストを作成しましたが、ここでは何でも使用できます。次に、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/