为 PowerShell 编写 Pester 测试:从基础断言到 CI 标签体系的完整实战指南
导读
本文基于 PowerShell 官方仓库的《Writing Pester Tests》测试编写指南,系统讲解如何为 PowerShell 项目编写高质量、可跨平台、可直接接入 CI 的 Pester 测试。你将掌握 Describe/Context/It 的结构化组织、Should -Throw -ErrorId 的错误断言技巧、TestDrive 临时驱动、参数化测试、Mock 以及 Pester 4 时代的执行时序陷阱;同时结合仓库中 build.psm1 与 tools/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
如果测试需要深入检查 ErrorRecord 的 InnerException 或其他成员,则应使用 -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内定义的所有Mock和TestDrive内容都限定在该 Describe 作用域内,Describe块退出后即消失; - 一个
Describe可以包含任意数量的Context和It块。
Context
Context在单个Describe内对It块做进一步的逻辑分组;Context内定义的Mock在Context作用域结束时被移除;在Context执行期间添加到TestDrive的文件或文件夹同样被清理;- 在
Context内定义的BeforeEach/AfterEach只对该Context内的测试生效。
It
It块应嵌套在Describe或Context内使用;- 如果你熟悉 AAA(Arrange-Act-Assert,准备-执行-断言)模式,
It块体内就是放置断言的合适位置; - 约定俗成:每个
It只断言一个期望。It体内的代码在期望未满足时应抛出终止性错误,从而让测试失败; It块的名称应当富有表达力地陈述测试期望,让失败信息一目了然(错误消息本身不应承担"描述测试"的职责)。
标签体系:CI / Feature / Scenario 与权限标签
PowerShell 仓库的 CI 依赖 Pester 的 Describe 标签(-Tag)来调度测试,因此为 Describe 块打标签是强制要求:每个 Describe 必须且只能使用 CI、Feature、Scenario 三种"优先标签"之一,否则构建过程将直接失败(详见下文源码佐证)。
各标签的用途与运行频次如下:
| 标签 | 含义 | 运行时机 |
|---|---|---|
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 {
Describe "Add-Member DRT Unit Tests" -Tags "CI" {
标签校验的源码实现:为什么"不打标签就构建失败"
build.psm1 中的 Get-PesterTag 函数用 PowerShell AST 解析 test/powershell 下所有 *.tests.ps1 文件,并做三件事:
- 检查每个
Describe是否带-Tag:若未找到任何优先标签,产生警告${fullname}:$lineno does not include -Tag in Describe; - 检查优先标签是否唯一:若同时出现多个优先标签,产生警告
includes more then one scope -Tag: ...; - 校验标签合法性:只允许
CI、FEATURE、SCENARIO作为优先标签,允许REQUIREADMINONWINDOWS、REQUIRESUDOONUNIX、SLOW作为辅助标签,其余一律警告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
除上述优先级标签外,本仓库还定义了两个平台权限标签:
RequireAdminOnWindows:Windows 上需要管理员权限的测试必须附加此标签。在 Azure DevOps 的 Windows CI 中会运行两轮测试:一轮排除该标签,一轮只运行该标签,每轮都使用相应的权限级别执行;RequireSudoOnUnix:Unix 系统上需要以sudo运行的测试必须附加此标签。它优先于其他所有标签(如CI、Feature等,后者在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 可以为现有命令在 Describe 或 Context 作用域内创建新的行为:通过脚本块指定该命令的新实现,从而在测试中隔离外部依赖。例如把"随机"变得"确定":
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
观察到的规律:
Describe的BeforeAll先于Describe内任何其他代码执行,即使它在Describe块的末尾书写。因此,如果Describe体内其他游离代码设置了某些状态,BeforeAll执行时这些状态尚不可见(那些代码还没运行);- 同理,
Context内的BeforeAll也先于该Context内的其他代码执行; - 通用建议:把需要执行的代码放进
BeforeAll、BeforeEach、AfterEach和/或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-Content、Out-String -Stream,再对数组做逐行断言。
Pester 编写规范:Do 与 Don't 清单
Do(应该做)
- 测试文件命名为
<descriptive_test_name>.tests.ps1(.tests.ps1后缀是 Pester 识别测试文件与 CI 扫描的约定,Get-PesterTag即按tests.ps1文件名匹配扫描); - 保持测试简单:
- 只测你需要验证的内容;
- 减少依赖;
- 依据用途为
Describe块打标签:CI:作为持续集成流程一部分运行的测试,应为单元测试风格,通常 1 秒内完成;Feature:较高级别的功能测试(定期运行),例如访问远程资源的测试或验证更广泛功能的测试;Scenario:与其他功能集成的测试(运行频率更低,覆盖面比 Feature 测试更广);
- 确保
Describe/Context/It的描述有用——错误消息里不该用来描述测试内容(It名称本身要表达期望); - 使用
Context对测试分组——多个Context可以把测试套件组织成逻辑分区; - 使用
BeforeAll/BeforeEach/AfterEach/AfterAll,而不要用自定义的初始化代码(见Free Code in a Describe block); - 用
Should -Throw -ErrorId检查预期错误; - 遍历多个
It时使用-TestCases; - 在适当的地方使用代码覆盖率功能;
- 环境不完整时使用
Mock功能; - 避免
Describe块内的游离代码,改用BeforeAll/BeforeEach/AfterEach/AfterAll; - 避免在
TESTDRIVE:之外创建或使用测试文件——TESTDRIVE:自带自动清理; - 牢记我们编写的是跨平台测试:避免使用注册表、避免使用 COM;
- 避免对资源的数量过于具体(数量可能随平台变化)。例如不要检查已加载格式文件的数量,而应检查某种具体类型的格式数据是否存在。
Don't(不要做)
- 不要在单个
It块里放太多断言——第一个Should失败就会终止该块,后续断言不会执行,难以定位全部问题; - 不要在
It块之外使用Should; - 不要用 "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,核心目录包括:
- test/powershell/engine:引擎核心(含 Api、Basic、ETS、Help、Logging、Module、ParameterBinding、Remoting、Runspace 等子目录)
- test/powershell/Host:宿主相关(ConsoleHost、TabCompletion)
- test/powershell/Language:语言特性(Classes、Interop、Operators、Parser、Scripting 等)
- test/powershell/Modules:各内置模块(Microsoft.PowerShell.Core、Microsoft.PowerShell.Management、Microsoft.PowerShell.Utility、PSReadLine 等)
- test/powershell/Provider、test/powershell/SDK、test/powershell/Security
总结
为 PowerShell 编写 Pester 测试的核心要点可以浓缩为:结构上用 Describe/Context/It 建立清晰的作用域分层,把状态初始化交给四个 Before/After 块而非游离代码;断言上用 Should -Be/-BeOfType 验证结果、用 Should -Throw -ErrorId 校验稳定的错误标识;隔离上利用 TestDrive 做临时文件操作、用 Mock 屏蔽外部依赖、用 -TestCases 消除重复;工程化上牢记 Describe 必须且只能打一个 CI/Feature/Scenario 优先标签(Get-PesterTag 与 CI 构建会强制校验),平台受限测试按需追加 RequireAdminOnWindows、RequireSudoOnUnix 或 SLOW 标签。遵循以上规范写出的测试,既能在本地通过 Start-PSPester 快速验证,也能无缝接入 Windows 与 Unix 双轨的 Azure DevOps CI 流水线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00