首页
/ 为 PowerShell 编写 Pester 测试:从基础断言到 CI 标签体系的完整实战指南

为 PowerShell 编写 Pester 测试:从基础断言到 CI 标签体系的完整实战指南

2026-09-07 17:52:35作者:冯爽妲Honey

导读

本文基于 PowerShell 官方仓库的《Writing Pester Tests》测试编写指南,系统讲解如何为 PowerShell 项目编写高质量、可跨平台、可直接接入 CI 的 Pester 测试。你将掌握 Describe/Context/It 的结构化组织、Should -Throw -ErrorId 的错误断言技巧、TestDrive 临时驱动、参数化测试、Mock 以及 Pester 4 时代的执行时序陷阱;同时结合仓库中 build.psm1tools/ci.psm1 的源码,深入理解 PowerShell 项目独有的测试标签体系(CI/Feature/Scenario/SLOW/RequireAdminOnWindows/RequireSudoOnUnix)以及"Describe 必须打标签、否则构建失败"的强制校验机制。

背景:PowerShell 项目中的 Pester 测试

在 PowerShell 仓库中,脚本级测试统一采用 Pester 框架。根据 docs/testing-guidelines/testing-guidelines.md 的说明,这是 Microsoft 内部为新的脚本测试所使用的框架,仓库中大量测试即从该内部测试库迁移而来;Pester 甚至可以测试大部分 PowerShell 行为(包括部分 API 操作)。

撰写本文时对应的 Pester 版本基线为 Pester 4(文档更新于 2018 年 1 月),相比早期版本在断言语法(Should -Be)等方面有变化。本文所有示例均以该版本语法为准。需要说明的是,本文不替代 Pester 官方文档与 Wiki,而是聚焦于"在本仓库环境下编写测试"的快速提示与最佳实践。

编写测试的三条总原则

在动手写测试之前,请始终记住:

  • 测试不应过度复杂,也不要在一组测试里塞进太多东西——把测试"蒸馏"到本质,只测你真正需要验证的内容;
  • 测试应当尽可能简单
  • 测试一般不应依赖其他测试(保持相互独立,避免顺序耦合)。

基础测试示例:最简单的断言与类型检查

最朴素的测试只需要一个 Describe 块和一个 It 块:

Describe "A variable can be assigned and retrieved" {
    It "Creates a variable and makes sure its value is correct" {
       $a = 1
       $a | Should -Be 1
   }
}

如果需要类型检查,可以使用 Should -BeOfType

Describe "One is really one" {
    It "Compare 1 to 1" {
       $a = 1
       $a | Should -Be 1
    }
    It "1 is really an int" {
       $i = 1
       $i | Should -BeOfType System.Int32
    }
}

在真实的 PowerShell 仓库测试中,这种风格随处可见。例如 test/powershell/Modules/Microsoft.PowerShell.Utility/Add-Member.Tests.ps1 中:

It "Successful alias, no type" {
    $results = Add-Member -InputObject a -MemberType AliasProperty -Name Cnt -Value Length -PassThru
    $results.Cnt | Should -BeOfType Int32
}

错误断言:用 Should -Throw -ErrorId 而非错误消息

当测试预期命令会失败时,推荐使用 Should -Throw-ErrorId 参数。它校验的是 FullyQualifiedErrorId 属性,而 FullyQualifiedErrorId不随语言/区域设置(culture)变化的稳定标识,不像错误消息文本那样会因本地化而改变。这一点在跨平台、跨语言环境的 CI 中尤为重要。

...
It "Get-Item on a nonexisting file should have error PathNotFound" {
    { Get-Item "ThisFileCannotPossiblyExist" -ErrorAction Stop } | Should -Throw -ErrorId "PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand"
}

注意:如果 Get-Item 意外成功,该测试将失败。

在仓库源码中,-ErrorId 的完整值通常形如 "错误ID,命令全名"。例如 Add-Member.Tests.ps1 中对参数校验错误的断言:

{ Add-Member -Name $null } | Should -Throw -ErrorId "ParameterArgumentValidationErrorNullNotAllowed,Microsoft.PowerShell.Commands.AddMemberCommand"

需要检查 InnerException 时使用 -PassThru

如果测试需要深入检查 ErrorRecordInnerException 或其他成员,则应使用 -PassThru 参数把错误对象"透传"出来,再对返回的 $e 做进一步断言:

It "InnerException sample" {
   $e = { Invoke-WebRequest https://expired.badssl.com/ } | Should -Throw -ErrorId "WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" -PassThru
   $e.Exception.InnerException.NativeErrorCode | Should -Be 12175
}

Describe / Context / It:三层结构语义

Pester 测试由三层块结构组织,理解每一层的作用域与生命周期是写出正确测试的前提。

Describe

  • Describe 创建一个逻辑测试分组
  • Describe 内定义的所有 MockTestDrive 内容都限定在该 Describe 作用域内Describe 块退出后即消失;
  • 一个 Describe 可以包含任意数量的 ContextIt 块。

Context

  • Context 在单个 Describe 内对 It 块做进一步的逻辑分组;
  • Context 内定义的 MockContext 作用域结束时被移除;在 Context 执行期间添加到 TestDrive 的文件或文件夹同样被清理;
  • Context 内定义的 BeforeEach / AfterEach 只对该 Context 内的测试生效。

It

  • It 块应嵌套在 DescribeContext 内使用;
  • 如果你熟悉 AAA(Arrange-Act-Assert,准备-执行-断言)模式,It 块体内就是放置断言的合适位置;
  • 约定俗成:每个 It 只断言一个期望It 体内的代码在期望未满足时应抛出终止性错误,从而让测试失败;
  • It 块的名称应当富有表达力地陈述测试期望,让失败信息一目了然(错误消息本身不应承担"描述测试"的职责)。

标签体系:CI / Feature / Scenario 与权限标签

PowerShell 仓库的 CI 依赖 Pester 的 Describe 标签(-Tag)来调度测试,因此Describe 块打标签是强制要求:每个 Describe 必须且只能使用 CIFeatureScenario 三种"优先标签"之一,否则构建过程将直接失败(详见下文源码佐证)。

各标签的用途与运行频次如下:

标签 含义 运行时机
CI 单元测试级别的快速测试,通常应在 1 秒内完成 CI / PR 流程
Feature 较高级别的功能测试,例如访问远程资源或验证更广泛的功能 由 cron 驱动的每日构建
Scenario 与其他功能集成的测试,覆盖面比 Feature 更广 以较低频率定期运行
SLOW 耗时较长的测试(约 97% 的 CI 测试在 100ms 内完成;超过 1 秒的测试应被视为 SLOW 候选) 辅助标签

真实的标签写法见 test/powershell/Host/ConsoleHost.Tests.ps1

Describe "WindowStyle argument" -Tag Feature {

以及 Add-Member.Tests.ps1

Describe "Add-Member DRT Unit Tests" -Tags "CI" {

标签校验的源码实现:为什么"不打标签就构建失败"

build.psm1 中的 Get-PesterTag 函数用 PowerShell AST 解析 test/powershell 下所有 *.tests.ps1 文件,并做三件事:

  1. 检查每个 Describe 是否带 -Tag:若未找到任何优先标签,产生警告 ${fullname}:$lineno does not include -Tag in Describe
  2. 检查优先标签是否唯一:若同时出现多个优先标签,产生警告 includes more then one scope -Tag: ...
  3. 校验标签合法性:只允许 CIFEATURESCENARIO 作为优先标签,允许 REQUIREADMINONWINDOWSREQUIRESUDOONUNIXSLOW 作为辅助标签,其余一律警告 includes improper tag;且要求标签必须是静态字符串(动态变量表达式会被拒绝:TAGS must be static strings)。

只要存在任何警告,Result 即为 Fail。而 tools/ci.psm1 在构建流程中调用它并在失败时抛出:

$result = Get-PesterTag
if ( $result.Result -ne "Pass" )
{
    $result.Warnings
    throw "Tags must be CI, Feature, Scenario, or Slow"
}

这就是"Describe 未提供标签则构建失败"的强制机制来源。

权限标签:RequireAdminOnWindows 与 RequireSudoOnUnix

除上述优先级标签外,本仓库还定义了两个平台权限标签

  • RequireAdminOnWindowsWindows 上需要管理员权限的测试必须附加此标签。在 Azure DevOps 的 Windows CI 中会运行两轮测试:一轮排除该标签,一轮只运行该标签,每轮都使用相应的权限级别执行;
  • RequireSudoOnUnixUnix 系统上需要以 sudo 运行的测试必须附加此标签。它优先于其他所有标签(如 CIFeature 等,后者在 RequireSudoOnUnix 存在时被忽略),带此标签的测试会作为独立的 pass 在 Unix 上单独执行。

build.psm1 中的 Start-PSPester 依据当前环境自动处理排除逻辑:

if (-not $environment.IsWindows -and (-not $Sudo.IsPresent))
{
    if (-not $PSBoundParameters.ContainsKey('ExcludeTag'))
    {
        $ExcludeTag += 'RequireSudoOnUnix'
    }
}
elseif (-not $environment.IsWindows -and $Sudo.IsPresent)
{
    if (-not $PSBoundParameters.ContainsKey('Tag'))
    {
        $Tag = 'RequireSudoOnUnix'
    }
}

即:普通权限下运行 Unix 测试时会自动排除 RequireSudoOnUnix 标签;以 -Sudo 运行时则只运行该标签。同理,非管理员 Windows 环境会自动排除 RequireAdminOnWindows

常用特性详解

TestDrive:为文件操作提供临时隔离区

测试经常需要做文件操作,但我们不希望文件活动在测试之外产生副作用。Pester 会在系统临时目录中创建一个 PSDrive,通过 TestDrive:$TestDrive 访问,测试完成后 Pester 会自动删除该驱动器

  • 该驱动器在整个 Describe 作用域内可用;
  • 退出 Context 时驱动器内容会被清空。
function Add-Footer($path, $footer) {
   Add-Content $path -Value $footer
}

Describe "Add-Footer" {
   $testPath="TestDrive:\test.txt"
   Set-Content $testPath -value "my test text."
   Add-Footer $testPath "-Footer"
   $result = Get-Content $testPath

   It "adds a footer" {
       (-join $result) | Should -BeExactly "my test text.-Footer"
   }
}

当测试完成后,TestDrive: 的内容会被自动清除——这正是"避免在 TESTDRIVE: 之外创建或使用测试文件"这一最佳实践的理由。

Parameter Generation:用 -TestCases 做参数化测试

当需要遍历多组输入数据时,使用 It-TestCases 参数。注意 It 名称中的 <变量名> 占位符会被替换为对应测试用例的值,便于失败时定位是哪组数据出问题:

$testCases = @(
    @{ a = 0; b = 1; ExpectedResult = 1 }
    @{ a = 1; b = 0; ExpectedResult = 1 }
    @{ a = 1; b = 1; ExpectedResult = 0 }
    @{ a = 0; b = 0; ExpectedResult = 0 }
    )

Describe "A test" {
    It "<a> -xor <b> should be <expectedresult>" -TestCases $testCases {
        param ($a, $b, $ExpectedResult)
        $a -xor $b | Should -Be $ExpectedResult
    }
}

Mocking:用替代实现模拟现有命令

Mock 可以为现有命令在 DescribeContext 作用域内创建新的行为:通过脚本块指定该命令的新实现,从而在测试中隔离外部依赖。例如把"随机"变得"确定":

Context "Get-Random is not random" {
    Mock Get-Random { return 3 }

    It "Get-Random returns 3" {
        Get-Random | Should -Be 3
    }
}

Free Code in a Describe block:游离代码的执行时序陷阱

Pester 中游离在标准块之外的代码(直接写在 Describe/Context 体内、不属于任何 BeforeAll/It 等的代码)执行时机可能与你预期不符。考虑下面这个"演示陷阱"的示例:

Describe it {
    Write-Host -For DarkRed "Before Context"
    Context "subsection" {
        Write-Host -for DarkRed "Before BeforeAll"
        BeforeAll { write-host -for Blue "In Context BeforeAll" }
        Write-Host -for DarkRed "After BeforeAll"

        Write-Host -for DarkRed "Before AfterAll"
        AfterAll { Write-Host -for Blue "In Context AfterAll" }
        Write-Host -for DarkRed "After AfterAll"

        BeforeEach { Write-Host -for Blue "In BeforeEach" }
        AfterEach { Write-Host -for Blue "In AfterEach" }

        Write-Host -for DarkRed "Before It"
        It "should not be a surprise" {
            1 | should -Be 1
        }
        Write-Host -for DarkRed "After It"
    }
    Write-Host -for DarkRed "After Context"
    Write-Host -for DarkGreen "Before Describe BeforeAll"
    BeforeAll { Write-Host -for DarkGreen "In Describe BeforeAll" }
    AfterAll { Write-Host -for DarkGreen "In Describe AfterAll" }
}

实际运行(invoke-pester)时输出如下:

PS# invoke-pester c:\temp\pester.demo.tests.ps1
Describing it
In Describe BeforeAll
Before Context
   Context subsection
In Context BeforeAll
Before BeforeAll
After BeforeAll
Before AfterAll
After AfterAll
Before It
In BeforeEach
    [+] should not be a surprise 79ms
In AfterEach
After It
In Context AfterAll
After Context
Before Describe BeforeAll
In Describe AfterAll
Tests completed in 79ms
Passed: 1 Failed: 0 Skipped: 0 Pending: 0

观察到的规律:

  • DescribeBeforeAll 先于 Describe 内任何其他代码执行,即使它在 Describe 块的末尾书写。因此,如果 Describe 体内其他游离代码设置了某些状态,BeforeAll 执行时这些状态尚不可见(那些代码还没运行);
  • 同理,Context 内的 BeforeAll 也先于该 Context 内的其他代码执行;
  • 通用建议:把需要执行的代码放进 BeforeAllBeforeEachAfterEach 和/或 AfterAll 这四个代码块元素中,尤其是当这些块依赖块内其他位置由游离代码设置的状态时,更要避免游离代码。

Skipping Tests in Bulk:批量跳过一组测试

有时需要跳过某个 Describe 下的全部测试——例如该组测试在当前平台不适用。可以利用 PowerShell 的 $PSDefaultParameterValues 特性,在非目标平台上临时把 It-skip 参数设为 $true

Describe "Should not run these tests on non-Windows platforms" {
    BeforeAll {
        $originalDefaultParameterValues = $PSDefaultParameterValues.Clone()
        if ( ! $IsWindows ) {
            $PSDefaultParameterValues["it:skip"] = $true
        }
    }
    AfterAll {
        $global:PSDefaultParameterValues = $originalDefaultParameterValues
    }
    Context "Block 1" {
        It "This block 1 test 1" {
            1 | should -Be 1
        }
        It "This is block 1 test 2" {
            1 | should -Be 1
        }
    }
    Context "Block 2" {
        It "This block 2 test 1" {
            2 | should -Be 1
        }
        It "This is block 2 test 2" {
            2 | should -Be 1
        }
    }
}

在 Linux 上运行的结果(全部被跳过,标记为 [!]):

Describing Should not run these tests on non-Windows platforms
   Context Block 1
    [!] This block 1 test 1 691ms
    [!] This is block 1 test 2 114ms
   Context Block 2
    [!] This block 2 test 1 73ms
    [!] This is block 2 test 2 6ms

在 Windows 上运行的结果(正常执行;失败项标记为 [-]):

Describing Should not run these tests on non-Windows platforms
   Context Block 1
    [+] This block 1 test 1 86ms
    [+] This is block 1 test 2 33ms
   Context Block 2
    [-] This block 2 test 1 52ms
      Expected: {1}
      But was:  {2}
      22:             2 | should -Be 1
      at <ScriptBlock>, <No file>: line 22
    [-] This is block 2 test 2 77ms
      Expected: {1}
      But was:  {2}
      25:             2 | should -Be 1
      at <ScriptBlock>, <No file>: line 25

该技巧的本质是:在非目标平台上临时把 It 块的 -skip 参数通过 $PSDefaultParameterValues 默认设为 $true(Windows 上则不设置),并在 AfterAll 中恢复原始值。

Multi-line strings:多行字符串比较的坑与对策

你可能想写这样的测试:

It 'tests multi-line string' {
    Get-MultiLineString | Should -Be @'
first line
second line
'@
}

但直接用多行字符串校验输出结果存在隐患,根源在于行尾符(line-ends)

  • 不同平台的行尾不同:Windows 是 \r\n,Unix 是 \n
  • 即使在同一系统上,行尾还取决于仓库的克隆方式(本地 git 配置)。特别是在默认的 Azure DevOps CI Windows 镜像上,所有文件的行尾都是 \n,而运行时 Get-MultiLineString 在 Windows 上可能产生 \r\n,导致断言失败。

可以引入一个归一化函数,先统一行尾再比较(这是可行的 workaround,但会略微降低测试代码的可读性):

function normalizeEnds([string]$text)
{
    $text -replace "`r`n?|`n", "`r`n"
}

It 'tests multi-line string' {
    normalizeEnds (Get-MultiLineString) | Should -Be (normalizeEnds @'
first line
second line
'@)
}

更优的做法是从一开始就避免构造多行字符串:使用会生成字符串数组的命令,如 Get-ContentOut-String -Stream,再对数组做逐行断言。

Pester 编写规范:Do 与 Don't 清单

Do(应该做)

  1. 测试文件命名为 <descriptive_test_name>.tests.ps1.tests.ps1 后缀是 Pester 识别测试文件与 CI 扫描的约定,Get-PesterTag 即按 tests.ps1 文件名匹配扫描);
  2. 保持测试简单:
    • 只测你需要验证的内容;
    • 减少依赖;
  3. 依据用途为 Describe 块打标签:
    • CI:作为持续集成流程一部分运行的测试,应为单元测试风格,通常 1 秒内完成;
    • Feature:较高级别的功能测试(定期运行),例如访问远程资源的测试或验证更广泛功能的测试;
    • Scenario:与其他功能集成的测试(运行频率更低,覆盖面比 Feature 测试更广);
  4. 确保 Describe/Context/It 的描述有用——错误消息里不该用来描述测试内容(It 名称本身要表达期望);
  5. 使用 Context 对测试分组——多个 Context 可以把测试套件组织成逻辑分区;
  6. 使用 BeforeAll/BeforeEach/AfterEach/AfterAll,而不要用自定义的初始化代码(见Free Code in a Describe block);
  7. Should -Throw -ErrorId 检查预期错误;
  8. 遍历多个 It 时使用 -TestCases
  9. 在适当的地方使用代码覆盖率功能;
  10. 环境不完整时使用 Mock 功能;
  11. 避免 Describe 块内的游离代码,改用 BeforeAll/BeforeEach/AfterEach/AfterAll
  12. 避免在 TESTDRIVE: 之外创建或使用测试文件——TESTDRIVE: 自带自动清理;
  13. 牢记我们编写的是跨平台测试:避免使用注册表、避免使用 COM;
  14. 避免对资源的数量过于具体(数量可能随平台变化)。例如不要检查已加载格式文件的数量,而应检查某种具体类型的格式数据是否存在。

Don't(不要做)

  1. 不要在单个 It 块里放太多断言——第一个 Should 失败就会终止该块,后续断言不会执行,难以定位全部问题;
  2. 不要在 It 块之外使用 Should
  3. 不要用 "Error" 或 "Fail" 这类词去描述一个正向用例。例如,把否定式 "Get-ChildItem TESTDRIVE: shouldn't fail" 改写成肯定式 "Get-ChildItem should be able to retrieve file listing from TESTDRIVE"

在本地运行 Pester 测试

使用 Start-PSPester

build.psm1 提供了 Start-PSPester 辅助函数,用于执行与 CI 相同的 Pester 测试集合(另有一个 Start-PSxUnit 用于 xUnit 测试;xUnit 仅用于难以用 Pester 测试的场景)。CI 系统本身也运行这些函数,因此本地执行与 CI 之间不应有差异。

在仓库根目录、使用自带的开发版 PowerShell 运行:

Import-Module ./build.psm1
Start-PSPester

按路径限制测试范围:

Start-PSPester -Path test/powershell/engine/Api

或指定单个测试文件:

Start-PSPester -Path test/powershell/engine/Api/XmlAdapter.Tests.ps1

务必注意:应以 -noprofile 启动 PowerShell 再运行测试(pwsh -noprofile),因为自定义的 profile 或非默认环境会让部分测试失败。

平台相关的跳过与挂起

根据 test/powershell/README.md,跨平台测试应优先使用 It-Skip 指令按平台跳过:

# 仅 Windows 运行
It "Should do something on Windows" -Skip:($IsLinux -Or $IsMacOS) { ... }

# 仅 Linux 与 macOS 运行
It "Should do something on Linux" -Skip:$IsWindows { ... }

同时,任何新启动的 powershell 进程都必须带 -noprofile,且要调用开发版 PowerShell(注意它不一定在 PATH 的第一个位置):

$powershell = Join-Path -Path $PsHome -ChildPath "pwsh"
& $powershell -noprofile -command "ExampleCommand" | Should Be "ExampleOutput"

另外,若测试"应该通过但当前未通过",不要直接删除或跳过它,而是使用 It "Should Pass" -Pending 标记为挂起,并提交 issue 追踪。

新测试应该放在哪里:测试目录布局

仓库按功能维度组织 Pester 测试:如果你修复了某个模块中的 cmdlet,测试应放在对应模块目录下;不确定时可以在 PR 中提出或创建 issue。完整的测试布局见 docs/testing-guidelines/testing-guidelines.md,核心目录包括:

总结

为 PowerShell 编写 Pester 测试的核心要点可以浓缩为:结构上Describe/Context/It 建立清晰的作用域分层,把状态初始化交给四个 Before/After 块而非游离代码;断言上Should -Be/-BeOfType 验证结果、用 Should -Throw -ErrorId 校验稳定的错误标识;隔离上利用 TestDrive 做临时文件操作、用 Mock 屏蔽外部依赖、用 -TestCases 消除重复;工程化上牢记 Describe 必须且只能打一个 CI/Feature/Scenario 优先标签(Get-PesterTag 与 CI 构建会强制校验),平台受限测试按需追加 RequireAdminOnWindowsRequireSudoOnUnixSLOW 标签。遵循以上规范写出的测试,既能在本地通过 Start-PSPester 快速验证,也能无缝接入 Windows 与 Unix 双轨的 Azure DevOps CI 流水线。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388