확장 가능한 PowerShell Pester 테스트 프레임워크 구축

당신이 Pester 테스트를 유지하는 데 더 많은 시간을 쓰는 것을 발견하면, 이 게시물은 여러분을 위한 것입니다. 이 게시물에서는 저와 Devolutions이 함께 작업하고 있는 프로젝트를 공유하겠습니다.

배경 이야기

Devolutions에서 문제가 있었습니다. 주력 PowerShell 모듈인 Devolutions.PowerShell에는 Pester 테스트가 없었습니다. 알겠어요, 알겠어요. C# 테스트는 있었지만 그건 다른 이야기였습니다.

PowerShell에 중점을 둔 Devolutions 컨설턴트로서, CI/CD 파이프라인에서 사용되고 프로덕션 배포 전에 실행되는 Pester 테스트 스위트를 작성하라는 요청을 받았습니다. 괜찮다고 생각했습니다. 몇 번 이 모듈을 사용해봤고, 그렇게 어렵지 않을 것이라고 생각했습니다. 하지만 저는 틀렸습니다.

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

구체적인 요청 없이 그냥 “일부 테스트를 만들어라”고 했기 때문에, 전체 모듈을 위한 테스트를 작성하기 시작했지만, 거의 500개의 명령이 있음을 발견했습니다! 이건 시간이 걸릴 것 같았습니다.

항상 오늘뿐만 아니라 장기적인 해결책을 만들고 싶었기 때문에, 여러 테스트를 하나의 스크립트에 모아서 일을 끝내고 싶지 않았습니다. 그 스크립트는 거대할 것이기 때문이죠!

대신, 한 가지 테스트도 작성하기 전에, 다음과 같은 프레임워크를 설계하고 싶었습니다:

  1. 확장 가능해야 함 – 저 또는 다른 사람들이 생각을 많이 하지 않고도 프레임워크에 테스트를 쉽게 추가할 수 있어야 합니다.
  2. 코드 중복을 방지하려면 이 프로젝트는 DRY(don’t repeat yourself)해야 합니다. 어떤 코드도 중복하고 싶지 않았기 때문에, 대신에 공유 스크립트, 함수 등을 사용했습니다.
  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 컨텍스트를 생성합니다.
    context '그룹' {
        context '하위그룹' {
    
        }
    }
  4. 그룹 내의 모든 테스트가 실행되기 전에 beforeall 스크립트를 한 번 호출합니다.
  5. 그룹 내의 각 테스트 전에 beforeeach 스크립트를 호출합니다.
  6. 마지막으로, 각 테스트 정의에서 정의된 테스트 어설션을 호출합니다.

Caller 테스트 스크립트

Caller 스크립트는 Pester의 호출 지점입니다. 이것은 프레임워크 내에서 테스트를 호출해야 할 때 Invoke-Pester가 호출하는 스크립트입니다.

Caller 스크립트는 X 영역으로 분할됩니다:

BeforeDiscovery 블록

Caller 스크립트의 하나의 작업은 모든 테스트 정의 및 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 블록은 Caller 스크립트 또는 테스트 정의에서 도우미 함수를 사용할 수 있도록 실행됩니다. 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에 사용된 각 데이터 원본에 대한 환경이 있는 경우, 제 환경은 xml, sqllite 등입니다.

도우미 함수

마지막으로, 도우미 함수 스크립트가 있습니다. 이 스크립트에는 호출 스크립트의 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/