Создание масштабируемой тестовой структуры PowerShell Pester

Если вы обнаружили, что тратите больше времени на обслуживание своих тестов Pester, чем на создание новых, этот пост для вас. В этом посте я поделюсь проектом, над которым работал с Devolutions.

Предыстория

У нас была проблема в Devolutions. Флагманский модуль PowerShell Devolutions.PowerShell не имел тестов Pester. Знаю, знаю. У них были тесты на C#, но это уже другая история.

В качестве консультанта для Devolutions, сосредоточенного на PowerShell, меня попросили создать набор тестов Pester, которые будут использоваться в CI/CD конвейере и запускаться перед развертыванием в продуктивной среде. Никаких проблем, подумал я. Я несколько раз использовал этот модуль, и это не может быть так сложно. Я ошибался.

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

Без каких-либо конкретных запросов, просто “создайте несколько тестов”, я начал создавать тесты для всего модуля, только чтобы обнаружить, что в нем почти 500 команд! Это займет некоторое время.

Поскольку я всегда хотел создавать решения не только на сегодня, но и на долгосрочную перспективу, я не хотел просто бросать кучу тестов в один скрипт и на этом заканчивать. Этот скрипт был бы огромным!

Вместо этого, прежде чем написать хоть один тест, я хотел разработать структуру, которую мог бы использовать, которая бы:

  1. Была масштабируемой – Я или другие могли бы легко добавлять тесты в структуру без особых усилий.
  2. Чтобы предотвратить дублирование кода, этот проект должен быть DRY (не повторяйте себя). Я не хотел дублировать никакой код, поэтому вместо этого использовал общие скрипты, функции и т.д.
  3. Чтобы использовать тесты на основе данных: Тесты на основе данных способствуют повторному использованию кода, определяя тесты в структуре данных, а затем позволяя тестам ссылаться на эту структуру, вместо того чтобы дублировать тестовый код для каждого теста.
  4. Будьте модульными – Все тесты должны быть разбиты на какую-то структуру, которая предотвратит необходимость прокручивать ненужный код, чтобы найти то, что вам нужно.
  5. Чтобы поддерживать сценарии настройки и завершения окружения – Эти тесты являются тестами “конец-к-концу” (E2E)/интеграционными/приемочными, как вам угодно. Они тестируют использование конечным пользователем; нет юнит-тестов, поэтому в окружении должно быть предусмотрено множество вещей для запуска.
  6. Чтобы поддерживать выполнение групп тестов без необходимости запускать их все сразу.

Эти требования приводят к фреймворку тестирования Pester для Devolutions.

Предварительные условия

Если вы хотите использовать этот фреймворк, я не могу гарантировать, что он будет работать на 100%, если вы не работаете в том же окружении, что и я. Для использования этого фреймворка я использовал:

  • PowerShell v7.4.5 на Windows
  • 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. Находит все beforeeach и beforeall скрипты для каждой группы.
  3. Создаёт контексты Pester для каждой группы и подгруппы.
    контекст 'группа' {
        контекст 'подгруппа' {
    
        }
    }
  4. Вызывает скрипт beforeall один раз перед запуском любых тестов в группе.
  5. Вызывает скрипт beforeeach перед каждым тестом в группе.
  6. Наконец, он вызывает утверждения теста, определённые в каждом определении теста.

Скрипт вызова теста

Скрипт вызова является точкой вызова Pester. Это скрипт, который Invoke-Pester вызывает, когда вам нужно вызвать любые тесты в рамках.

Скрипт вызова разделён на X областей:

Блок BeforeDiscovery

Одна из задач скрипта вызова — найти все определения тестов и для чего предназначена фаза обнаружения 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

Каждая конфигурация окружения, с которой вам нужно запускать тесты, делится на контексты с тестовыми группами под каждой из них.

Для модуля PowerShell Devolutions, поскольку нам нужно тестировать командлеты против множества различных типов источников данных, я создал контексты по источникам данных, но вы можете использовать здесь что угодно. Затем, используя параметр ForEach в Pester, каждая папка с определениями тестов является контекстом, как и каждая подгруппа. Затем каждый тест определяется в блоках it.


Обратите внимание на метод where() в блоке it. 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/