构建一个可扩展的 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测试框架。

先决条件

如果你想使用这个框架,我不能保证它百分之百有效,除非你和我在同样的环境中工作。为了使用这个框架,我使用了:

  • 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 'group' {
        context '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模块,由于我们需要针对许多不同类型的数据源测试cmdlets,我按数据源创建了上下文,但你可以在这里使用任何东西。然后,使用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/