PowerShell 技能连载 - Pester 高级测试

适用于 PowerShell 5.1 及以上版本

在 PowerShell 生态中,Pester 已经成为事实上的测试框架标准。从简单的断言到复杂的端到端验证,Pester 能够覆盖各种测试场景。然而,许多开发者仅停留在 Should -Be 的基本用法上,对于 Mock、BeforeAll/AfterAll、参数化测试、Code Coverage 等高级特性缺乏了解。

随着 DevOps 实践的深入,持续集成流水线对自动化测试的要求越来越高。一份高质量的 Pester 测试套件不仅能捕捉回归缺陷,还能作为模块行为的”可执行文档”。掌握 Pester 的高级技巧,可以显著提升测试的可维护性、执行效率和覆盖广度。

本文将围绕四个高级主题展开:参数化测试(Data-Driven Tests)、Mock 与 Assert-VerifiableMock、自定义 Should 断言运算符,以及 Code Coverage 集成。每个主题都配有可直接运行的完整示例。

参数化测试:用 TestCases 消除重复

当你需要对同一个函数的多组输入进行验证时,逐一编写 It 块会导致大量重复代码。Pester 提供了 TestCases 参数,可以将测试数据与测试逻辑分离,一个 It 块即可覆盖所有场景。

下面的例子定义了一个字符串处理函数,然后使用 TestCases 同时验证多种输入组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 被测函数:移除字符串首尾的空白字符并转为标题大小写
function Format-TitleCase {
param(
[Parameter(Mandatory)]
[string]$Text
)
$trimmed = $Text.Trim()
$words = $trimmed -split '\s+'
$result = foreach ($word in $words) {
$word.Substring(0, 1).ToUpper() + $word.Substring(1).ToLower()
}
$result -join ' '
}

# Pester 测试
Describe 'Format-TitleCase 参数化测试' {
It '应将 "<Input>" 转换为 "<Expected>"' -TestCases @(
@{ Input = 'hello world'; Expected = 'Hello World' }
@{ Input = ' powershell '; Expected = 'Powershell' }
@{ Input = 'a b c d'; Expected = 'A B C D' }
@{ Input = 'MIXED case INPUT'; Expected = 'Mixed Case Input' }
@{ Input = ' too many spaces '; Expected = 'Too Many Spaces' }
) {
param($Input, $Expected)
Format-TitleCase -Text $Input | Should -Be $Expected
}
}

执行结果示例:

1
2
3
4
5
6
7
8
Describing Format-TitleCase 参数化测试
[+] 应将 "hello world" 转换为 "Hello World" 32ms
[+] 应将 " powershell " 转换为 "Powershell" 18ms
[+] 应将 "a b c d" 转换为 "A B C D" 12ms
[+] 应将 "MIXED case INPUT" 转换为 "Mixed Case Input" 15ms
[+] 应将 " too many spaces " 转换为 "Too Many Spaces" 11ms
Tests completed in 188ms
Tests Passed: 5, Failed: 0, Skipped: 0 NotRun: 0

使用 TestCases 的好处在于:每条数据会生成独立的测试用例,某一条失败不影响其他数据的验证,测试报告中也能清晰看到具体是哪组输入出了问题。<Input><Expected> 这样的占位符会在输出中被实际值替换,方便定位问题。

Mock 与依赖隔离

在单元测试中,被测函数往往会调用外部依赖(文件系统、数据库、REST API 等)。如果不加以隔离,测试结果会受到外部环境的影响,导致测试时好时坏(Flaky Tests)。Pester 的 Mock 命令可以将任意命令替换为桩实现,让测试专注于验证逻辑本身。

下面的示例演示如何 Mock Invoke-RestMethod,使测试在不发送真实网络请求的情况下验证函数行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 被测函数:调用天气 API 并格式化输出
function Get-WeatherReport {
param(
[Parameter(Mandatory)]
[string]$City
)
$uri = "https://api.weather.example.com/v1/current?city=$City"
$response = Invoke-RestMethod -Uri $uri -Method Get
if ($response.Temperature -gt 35) {
return "[$City] 高温预警:当前 $($response.Temperature)°C,天气 $($response.Condition)"
}
return "[$City] 当前 $($response.Temperature)°C,天气 $($response.Condition)"
}

# Pester 测试
Describe 'Get-WeatherReport' {
BeforeAll {
# Mock Invoke-RestMethod,返回预设的天气数据
Mock Invoke-RestMethod {
param($Uri)
if ($Uri -match 'Beijing') {
return @{
Temperature = 38
Condition = '晴'
}
}
return @{
Temperature = 22
Condition = '多云'
}
}
}

It '当温度超过 35 度时应包含高温预警' {
$result = Get-WeatherReport -City 'Beijing'
$result | Should -Match '高温预警'
$result | Should -Match '38°C'
}

It '正常温度应返回天气信息而无预警' {
$result = Get-WeatherReport -City 'Shanghai'
$result | Should -Not -Match '高温预警'
$result | Should -Match '22°C'
}

It '应调用 Invoke-RestMethod 一次' {
# 先调用一次被测函数
Get-WeatherReport -City 'Guangzhou' | Out-Null
# 验证 Mock 被调用了恰好 1 次
Should -Invoke Invoke-RestMethod -Exactly 1 -Scope It
}
}

执行结果示例:

1
2
3
4
5
6
Describing Get-WeatherReport
[+] 当温度超过 35 度时应包含高温预警 45ms
[+] 正常温度应返回天气信息而无预警 28ms
[+] 应调用 Invoke-RestMethod 一次 19ms
Tests completed in 92ms
Tests Passed: 3, Failed: 0, Skipped: 0 NotRun: 0

这里有几个关键点值得注意。BeforeAll 块中的 Mock 对当前 Describe 内的所有 It 块生效。Mock 命令通过拦截指定命令的调用,将其重定向到自定义脚本块。Should -Invoke(旧版为 Assert-MockCalled)用于验证 Mock 是否按预期被调用。这种方式特别适合测试错误处理分支——你可以让 Mock 抛出异常,验证被测函数的容错逻辑是否正确。

自定义 Should 断言运算符

Pester v5 允许通过 Add-ShouldOperator 注册自定义断言运算符。当你需要在多个测试文件中复用同一种复杂的验证逻辑时,自定义运算符比在每个测试中写重复的 Where-Object 管道要优雅得多。

下面展示如何创建一个 BeValidJson 运算符,用于断言字符串是否为合法的 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 首先定义自定义断言(通常放在测试的 BeforeAll 或单独的 .Tests.Setup.ps1 中)
BeforeAll {
Add-ShouldOperator -Name BeValidJson -Test {
param($ActualValue, [switch]$Negate)
# 尝试用 ConvertFrom-Json 解析,如果失败则不是合法 JSON
$isValid = $true
try {
ConvertFrom-Json -InputObject $ActualValue -ErrorAction Stop | Out-Null
}
catch {
$isValid = $false
}

if ($Negate) {
$isValid = -not $isValid
}

if (-not $isValid) {
if ($Negate) {
$failureMessage = "Expected the string to NOT be valid JSON, but it parsed successfully."
}
else {
$failureMessage = "Expected the string to be valid JSON, but parsing failed. Input: $($ActualValue.Substring(0, [Math]::Min(50, $ActualValue.Length)))..."
}
throw [Pester.Factory]::CreateShouldErrorResult($failureMessage, $ActualValue)
}
}
}

# 使用自定义运算符进行测试
Describe 'BeValidJson 自定义断言' {
It '合法的 JSON 字符串应通过验证' {
$json = '{"name": "PowerShell", "version": 7.4}'
$json | Should -BeValidJson
}

It '非法的 JSON 字符串应验证失败' {
$badJson = '{name: missing_quotes}'
$badJson | Should -Not -BeValidJson
}

It 'JSON 数组也应被正确识别' {
$array = '[1, 2, 3, "four"]'
$array | Should -BeValidJson
}

It '嵌套结构应通过验证' {
$nested = '{"users": [{"id": 1}, {"id": 2}], "total": 2}'
$nested | Should -BeValidJson
}
}

执行结果示例:

1
2
3
4
5
6
7
Describing BeValidJson 自定义断言
[+] 合法的 JSON 字符串应通过验证 36ms
[+] 非法的 JSON 字符串应验证失败 14ms
[+] JSON 数组也应被正确识别 11ms
[+] 嵌套结构应通过验证 13ms
Tests completed in 74ms
Tests Passed: 4, Failed: 0, Skipped: 0 NotRun: 0

自定义运算符的核心是 $Negate 参数,它处理 Should -Not 的反转逻辑。当断言失败时,通过抛出 [Pester.Factory]::CreateShouldErrorResult() 来提供清晰的错误消息。将自定义运算符的定义放在一个独立的 .ps1 文件中,然后在测试启动时通过 -ConfigurationScriptBlock 参数加载,就能在整个测试套件中复用。

Code Coverage 与 CI 集成

在团队协作中,仅知道”测试通过”还不够,你还需要量化测试覆盖了多少代码路径。Pester v5 内置了 Code Coverage 功能,可以告诉你哪些行、哪些函数从未被测试触达。结合 CI/CD 流水线,可以设置覆盖率阈值门禁,确保代码质量不会随时间退化。

下面的示例展示如何在 CI 脚本中运行 Pester 并生成覆盖率报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# run-tests.ps1 — CI 流水线中调用的测试入口脚本
param(
[double]$CoverageThreshold = 80
)

# 安装或导入 Pester
if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge '5.0' })) {
Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck
}
Import-Module Pester

# 配置 Pester 运行
$configuration = @{
Run = @{
Path = '.\tests'
}
Output = @{
Verbosity = 'Detailed'
}
CodeCoverage = @{
Enabled = $true
Path = '.\src\*.ps1', '.\src\*.psm1'
CoverageThreshold = $CoverageThreshold
OutputFormat = 'JaCoCo'
OutputPath = '.\coverage.xml'
}
TestResult = @{
Enabled = $true
OutputFormat = 'NUnitXml'
OutputPath = '.\testResults.xml'
}
}

$result = Invoke-Pester -Configuration $configuration

# 输出覆盖率摘要
if ($result.CodeCoverage) {
$covered = $result.CodeCoverage.NumberOfCommandsExecuted
$total = $result.CodeCoverage.NumberOfCommandsAnalyzed
$percent = if ($total -gt 0) { [math]::Round(($covered / $total) * 100, 1) } else { 0 }
Write-Host "代码覆盖率:$covered / $total 条命令已覆盖 ($percent%)"
}

# 根据结果设置退出码
if ($result.FailedCount -gt 0) {
Write-Host "测试失败:$($result.FailedCount) 个用例未通过" -ForegroundColor Red
exit 1
}
if ($result.CodeCoverage -and $percent -lt $CoverageThreshold) {
Write-Host "覆盖率 $percent% 低于阈值 $CoverageThreshold%" -ForegroundColor Yellow
exit 2
}
Write-Host "所有测试通过,覆盖率达标" -ForegroundColor Green
exit 0

执行结果示例:

1
2
3
4
5
6
7
8
9
10
Starting discovery in 1 files.
Discovery finished in 245ms.
Running tests.
[+] .\tests\Format-TitleCase.Tests.ps1 188ms (5 passed)
[+] .\tests\Get-WeatherReport.Tests.ps1 92ms (3 passed)
[+] .\tests\BeValidJson.Tests.ps1 74ms (4 passed)
Tests completed in 554ms
Tests Passed: 12, Failed: 0, Skipped: 0 NotRun: 0
代码覆盖率:87 / 102 条命令已覆盖 (85.3%)
所有测试通过,覆盖率达标

这段脚本做了三件关键的事情。第一,通过 CodeCoverage 配置项指定需要追踪的源码路径和覆盖率阈值,低于阈值时以非零退出码退出,CI 流水线可以据此拦截不合格的提交。第二,OutputFormat 设为 JaCoCo 后生成标准格式的覆盖率报告,可被 Azure DevOps、GitLab CI、SonarQube 等工具直接消费。第三,TestResult 生成 NUnit 格式的测试结果,便于 CI 平台展示测试趋势和失败详情。

注意事项

  1. Mock 作用域要精确控制Mock 默认作用于当前及子作用域。如果你在 Describe 级别 Mock 了 Get-Content,该 Describe 下所有 ContextIt 块都会受影响。如果只想在特定 It 中生效,就把 Mock 写在那个 It 内部。滥用全局 Mock 会让测试难以理解和维护。

  2. 避免在 Mock 中执行真实操作:Mock 的脚本块中不应调用外部服务或修改文件系统。一旦 Mock 内部产生了副作用,就违背了隔离测试的初衷。如果 Mock 逻辑很复杂,考虑先写一个简单的桩函数,再 Mock 这个桩函数。

  3. TestCases 中的特殊字符需要转义:当测试数据包含双引号、美元符号、反引号等 PowerShell 特殊字符时,哈希表中的字符串要用单引号包裹,或者使用反引号转义,否则解析器会提前展开变量。

  4. Code Coverage 不是银弹:80% 的行覆盖率不代表 80% 的逻辑覆盖率。条件分支的短路求值、异常路径、边界值等场景需要专门的测试用例来覆盖。不要为了凑覆盖率数字而写无意义的断言。

  5. Pester v5 与 v4 的差异:v5 对配置模型做了大幅重构,Invoke-Pester 的参数风格完全不同。如果你在迁移旧测试套件,注意 Assert-MockCalled 已被 Should -Invoke 替代,TestDriveTestRegistry 的行为也有细微变化。建议统一使用 v5 的新语法。

  6. 在 CI 中固定 Pester 版本:不同版本的 Pester 行为差异较大,CI 流水线中务必通过 -RequiredVersion 锁定版本号,或者在 pwsh 启动时显式 Import-Module Pester -RequiredVersion 5.x.x。否则某天 Pester 发布了大版本更新,你的流水线可能突然大面积失败。

PowerShell 技能连载 - 单元测试与 Pester 进阶

适用于 PowerShell 5.1 及以上版本,需要 Pester 模块

单元测试不是可选项,而是生产级脚本的必需品——尤其是被多人调用、影响关键业务逻辑的函数。Pester 是 PowerShell 生态中事实上的测试框架,提供 Describe/Context/It 的 BDD 风格语法、Mock 能力、代码覆盖率统计。从简单的函数验证到复杂的模块测试,Pester 都能胜任。

本文将讲解 Pester 的高级用法,包括 Mock、参数过滤、代码覆盖率,以及测试驱动的开发实践。

基础测试结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# 安装 Pester
Install-Module -Name Pester -Force -Scope CurrentUser

# 被测函数
function Get-FileSizeCategory {
param([Parameter(Mandatory)][long]$Bytes)

switch ($true) {
($Bytes -ge 1GB) { return "Large" }
($Bytes -ge 1MB) { return "Medium" }
($Bytes -ge 1KB) { return "Small" }
default { return "Tiny" }
}
}

function ConvertTo-ReadableSize {
param([Parameter(Mandatory)][long]$Bytes)

switch ($true) {
($Bytes -ge 1TB) { return "{0:N2} TB" -f ($Bytes / 1TB) }
($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) }
($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) }
($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) }
default { return "$Bytes B" }
}
}

# Pester 测试文件
$tests = @"
Describe "Get-FileSizeCategory" {
Context "当文件大小小于 1KB" {
It "应该返回 Tiny" {
Get-FileSizeCategory -Bytes 0 | Should -Be "Tiny"
Get-FileSizeCategory -Bytes 512 | Should -Be "Tiny"
Get-FileSizeCategory -Bytes 1023 | Should -Be "Tiny"
}
}

Context "当文件大小在 1KB 到 1MB 之间" {
It "应该返回 Small" {
Get-FileSizeCategory -Bytes 1KB | Should -Be "Small"
Get-FileSizeCategory -Bytes 500KB | Should -Be "Small"
}
}

Context "当文件大小在 1MB 到 1GB 之间" {
It "应该返回 Medium" {
Get-FileSizeCategory -Bytes 1MB | Should -Be "Medium"
Get-FileSizeCategory -Bytes 500MB | Should -Be "Medium"
}
}

Context "当文件大小超过 1GB" {
It "应该返回 Large" {
Get-FileSizeCategory -Bytes 1GB | Should -Be "Large"
Get-FileSizeCategory -Bytes 100GB | Should -Be "Large"
}
}
}

Describe "ConvertTo-ReadableSize" {
It "正确转换字节" {
ConvertTo-ReadableSize -Bytes 500 | Should -Be "500 B"
}

It "正确转换 KB" {
ConvertTo-ReadableSize -Bytes 1536 | Should -Be "1.50 KB"
}

It "正确转换 MB" {
ConvertTo-ReadableSize -Bytes 1048576 | Should -Be "1.00 MB"
}

It "正确转换 GB" {
ConvertTo-ReadableSize -Bytes 2147483648 | Should -Be "2.00 GB"
}
}
"@

$tests | Set-Content "C:\Tests\FileSizeCategory.Tests.ps1" -Encoding UTF8

# 运行测试
$result = Invoke-Pester -Path "C:\Tests\FileSizeCategory.Tests.ps1" -PassThru
Write-Host "`n测试结果:$($result.PassedCount) 通过,$($result.FailedCount) 失败" -ForegroundColor $(if ($result.FailedCount -gt 0) { "Red" } else { "Green" })

执行结果示例:

1
2
3
4
5
6
7
Starting discovery in 1 files.
Discovery finished in 12ms.
[+] C:\Tests\FileSizeCategory.Tests.ps1 245ms (18ms|227ms)
TESTS PASSED: 7
TESTS FAILED: 0

测试结果:7 通过,0 失败

Mock 与隔离测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# 使用 Mock 隔离外部依赖
$mockTests = @"
BeforeAll {
function Get-ServerStatus {
param([string]$ServerName)

if (-not $ServerName) {
throw "ServerName 不能为空"
}

try {
$ping = Test-Connection -ComputerName $ServerName -Count 1 -Quiet
$os = Get-CimInstance Win32_OperatingSystem -ComputerName $ServerName -ErrorAction Stop

return [PSCustomObject]@{
Server = $ServerName
Online = $ping
OS = $os.Caption
Version = $os.Version
}
} catch {
return [PSCustomObject]@{
Server = $ServerName
Online = $false
OS = "Unknown"
Version = "Unknown"
Error = $_.Exception.Message
}
}
}
}

Describe "Get-ServerStatus" {
Context "当服务器在线" {
BeforeEach {
Mock Test-Connection { return $true }
Mock Get-CimInstance {
return [PSCustomObject]@{
Caption = "Microsoft Windows Server 2022 Standard"
Version = "10.0.20348"
}
}
}

It "应该返回在线状态" {
$result = Get-ServerStatus -ServerName "SRV01"
$result.Online | Should -BeTrue
$result.OS | Should -Be "Microsoft Windows Server 2022 Standard"
}

It "应该调用 Test-Connection 一次" {
Get-ServerStatus -ServerName "SRV01"
Should -Invoke Test-Connection -Times 1 -Exactly
}
}

Context "当服务器离线" {
BeforeEach {
Mock Test-Connection { return $false }
Mock Get-CimInstance { throw "RPC 服务器不可用" }
}

It "应该返回离线状态和错误信息" {
$result = Get-ServerStatus -ServerName "SRV-OFFLINE"
$result.Online | Should -BeFalse
$result.Error | Should -Match "RPC"
}
}

Context "参数验证" {
It "空服务器名应该抛出异常" {
{ Get-ServerStatus -ServerName "" } | Should -Throw "ServerName 不能为空"
}
}
}
"@

$mockTests | Set-Content "C:\Tests\ServerStatus.Tests.ps1" -Encoding UTF8
Invoke-Pester -Path "C:\Tests\ServerStatus.Tests.ps1" -PassThru

执行结果示例:

1
2
3
4
5
Starting discovery in 1 files.
Discovery finished in 8ms.
[+] C:\Tests\ServerStatus.Tests.ps1 189ms (12ms|177ms)
TESTS PASSED: 4
TESTS FAILED: 0

参数化测试与代码覆盖率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 参数化测试(TestCases)
$paramTests = @"
Describe "文件大小分类验证" -ForEach @(
@{ Bytes = 0; Expected = "Tiny" }
@{ Bytes = 100; Expected = "Tiny" }
@{ Bytes = 1023; Expected = "Tiny" }
@{ Bytes = 1024; Expected = "Small" }
@{ Bytes = 51200; Expected = "Small" }
@{ Bytes = 1048576; Expected = "Medium" }
@{ Bytes = 52428800; Expected = "Medium" }
@{ Bytes = 1073741824; Expected = "Large" }
) {
It "Bytes=<Bytes> 应该分类为 <Expected>" {
Get-FileSizeCategory -Bytes $Bytes | Should -Be $Expected
}
}

Describe "ConvertTo-ReadableSize 参数化测试" {
It "正确转换 <Bytes> 字节为 <Expected>" -TestCases @(
@{ Bytes = 0; Expected = "0 B" }
@{ Bytes = 500; Expected = "500 B" }
@{ Bytes = 1024; Expected = "1.00 KB" }
@{ Bytes = 1536; Expected = "1.50 KB" }
@{ Bytes = 1048576; Expected = "1.00 MB" }
@{ Bytes = 1073741824; Expected = "1.00 GB" }
) {
ConvertTo-ReadableSize -Bytes $Bytes | Should -Be $Expected
}
}
"@

$paramTests | Set-Content "C:\Tests\Parameterized.Tests.ps1" -Encoding UTF8

# 运行测试并计算代码覆盖率
$result = Invoke-Pester -Path "C:\Tests\" -PassThru `
-CodeCoverage "C:\Scripts\*.ps1" `
-CodeCoverageOutputFile "C:\Tests\coverage.xml"

Write-Host "`n测试摘要:" -ForegroundColor Cyan
Write-Host " 通过:$($result.PassedCount)"
Write-Host " 失败:$($result.FailedCount)"
Write-Host " 跳过:$($result.SkippedCount)"
if ($result.CodeCoverage) {
$coveragePct = [math]::Round($result.CodeCoverage.NumberOfCommandsExecuted / $result.CodeCoverage.NumberOfCommandsAnalyzed * 100, 1)
Write-Host " 代码覆盖率:$coveragePct%"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
Starting discovery in 3 files.
Discovery finished in 25ms.
[+] C:\Tests\Parameterized.Tests.ps1 56ms

测试摘要:
通过:17
失败:0
跳过:0
代码覆盖率:85.4%

注意事项

  1. 测试命名:测试文件以 .Tests.ps1 结尾,Pester 会自动发现和运行
  2. Mock 作用域Mock 只在当前 Describe/Context 块中生效,不会影响其他测试
  3. 幂等性:测试应该可以重复运行且结果一致,避免依赖外部状态
  4. 覆盖率目标:核心模块争取 80% 以上的代码覆盖率,辅助工具可以适当降低
  5. CI/CD 集成:Pester 输出支持 NUnit XML 格式,可以集成到 CI/CD 流水线
  6. 测试速度:Mock 外部依赖使测试快速稳定。真实环境的测试放在单独的集成测试中

PowerShell 技能连载 - Pester 测试框架

适用于 PowerShell 5.1 及以上版本,建议安装 Pester 5.x

基础设施即代码(IaC)和自动化脚本的普及使得”测试”不再只是开发者的专利。Pester 是 PowerShell 社区最流行的测试框架,它使用 BDD(行为驱动开发)风格的语法编写测试——描述期望行为、执行代码、验证结果。无论是验证函数逻辑、测试配置是否符合预期,还是构建运维合规检查,Pester 都是不可或缺的工具。

本文将讲解 Pester 的核心语法、常用模式,以及如何构建运维合规测试。

安装与基础语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 安装 Pester 5.x
Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck

# 验证版本
Get-Module Pester -ListAvailable | Select-Object Name, Version

# 基础测试结构
$testScript = @'
Describe "数学运算测试" {
Context "加法" {
It "1 + 1 应该等于 2" {
(1 + 1) | Should -Be 2
}

It "负数加法应该正确" {
(-3) + 5 | Should -Be 2
}
}

Context "字符串操作" {
It "字符串应该包含子串" {
"Hello World" | Should -BeLike "*World*"
}

It "字符串长度应该正确" {
"PowerShell".Length | Should -Be 10
}
}
}
'@

Invoke-Pester -ScriptBlock ([scriptblock]::Create($testScript)) -Output Detailed

执行结果示例:

1
2
3
4
5
6
7
8
9
Describing 数学运算测试
Context 加法
[+] 1 + 1 应该等于 2 15ms
[+] 负数加法应该正确 3ms
Context 字符串操作
[+] 字符串应该包含子串 8ms
[+] 字符串长度应该正确 2ms

Tests passed: 4, Failed: 0, Skipped: 0

测试自定义函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 被测函数
function Get-FileSizeCategory {
param([long]$SizeBytes)

if ($SizeBytes -lt 1KB) { return 'Tiny' }
if ($SizeBytes -lt 1MB) { return 'Small' }
if ($SizeBytes -lt 1GB) { return 'Medium' }
if ($SizeBytes -lt 1TB) { return 'Large' }
return 'Huge'
}

# 测试文件
Describe "Get-FileSizeCategory" {
Context "小文件" {
It "0 字节应该返回 Tiny" {
Get-FileSizeCategory -SizeBytes 0 | Should -Be 'Tiny'
}

It "1023 字节应该返回 Tiny" {
Get-FileSizeCategory -SizeBytes 1023 | Should -Be 'Tiny'
}
}

Context "边界值测试" {
It "1KB 恰好应该是 Small" {
Get-FileSizeCategory -SizeBytes 1KB | Should -Be 'Small'
}

It "1MB - 1 应该是 Small" {
Get-FileSizeCategory -SizeBytes (1MB - 1) | Should -Be 'Small'
}

It "1MB 应该是 Medium" {
Get-FileSizeCategory -SizeBytes 1MB | Should -Be 'Medium'
}
}

Context "异常输入" {
It "负数应该抛出异常" {
{ Get-FileSizeCategory -SizeBytes -1 } | Should -Throw
}
}
}

# 运行测试
Invoke-Pester -Output Detailed

执行结果示例:

1
2
3
4
5
6
7
8
9
10
Describing Get-FileSizeCategory
Context 小文件
[+] 0 字节应该返回 Tiny 12ms
[+] 1023 字节应该返回 Tiny 3ms
Context 边界值测试
[+] 1KB 恰好应该是 Small 2ms
[+] 1MB - 1 应该是 Small 2ms
[+] 1MB 应该是 Medium 2ms

Tests passed: 5, Failed: 0

运维合规测试

Pester 最强大的运维场景是基础设施合规检查——验证服务器配置是否符合安全基线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 合规检查测试文件
Describe "Windows 安全基线检查" {
Context "执行策略" {
It "执行策略不应为 Unrestricted" {
$policy = Get-ExecutionPolicy
$policy | Should -Not -Be 'Unrestricted'
}

It "应使用 RemoteSigned 或更严格的策略" {
$policy = Get-ExecutionPolicy
@('RemoteSigned', 'AllSigned', 'Restricted') | Should -Contain $policy
}
}

Context "防火墙状态" {
It "域配置文件防火墙应启用" {
$fw = Get-NetFirewallProfile -Profile Domain
$fw.Enabled | Should -BeTrue
}

It "专用配置文件防火墙应启用" {
$fw = Get-NetFirewallProfile -Profile Private
$fw.Enabled | Should -BeTrue
}

It "公用配置文件防火墙应启用" {
$fw = Get-NetFirewallProfile -Profile Public
$fw.Enabled | Should -BeTrue
}
}

Context "磁盘空间" {
It "系统盘可用空间应大于 10GB" {
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$freeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
$freeGB | Should -BeGreaterThan 10
}
}

Context "安全更新" {
It "应在最近 30 天内安装过更新" {
$latestPatch = Get-CimInstance Win32_QuickFixEngineering |
Sort-Object InstalledOn -Descending |
Select-Object -First 1
($latestPatch.InstalledOn) | Should -BeGreaterThan (Get-Date).AddDays(-30)
}
}

Context "密码策略" {
It "最小密码长度应至少 8 位" {
$policy = Get-ADDefaultDomainPasswordPolicy -ErrorAction SilentlyContinue
if ($policy) {
$policy.MinPasswordLength | Should -BeGreaterOrEqual 8
}
}
}
}

# 运行合规检查并生成报告
$config = New-PesterConfiguration
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = "C:\Reports\compliance-results.xml"

Invoke-Pester -Configuration $config

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
Describing Windows 安全基线检查
Context 执行政策
[+] 执行政策不应为 Unrestricted 15ms
Context 防火墙状态
[+] 域配置文件防火墙应启用 8ms
[+] 专用配置文件防火墙应启用 5ms
[-] 公用配置文件防火墙应启用 45ms
Expected $true, but got $false.
Context 磁盘空间
[+] 系统盘可用空间应大于 10GB 12ms

Tests passed: 6, Failed: 1, Skipped: 0

Mock 和 Stub

Pester 支持 Mock 功能,可以替换外部依赖进行隔离测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function Get-ServerStatus {
param([string]$ServerName)

try {
$response = Invoke-RestMethod -Uri "https://$ServerName/health" -TimeoutSec 5
return $response.status
} catch {
return "Unreachable"
}
}

Describe "Get-ServerStatus" {
BeforeAll {
# Mock Invoke-RestMethod
Mock Invoke-RestMethod {
return @{ status = "healthy" }
} -ParameterFilter { $Uri -match 'web-01' }

Mock Invoke-RestMethod {
return @{ status = "degraded" }
} -ParameterFilter { $Uri -match 'db-01' }

Mock Invoke-RestMethod {
throw "Connection timeout"
} -ParameterFilter { $Uri -match 'legacy' }
}

It "正常服务器应返回 healthy" {
Get-ServerStatus -ServerName "web-01" | Should -Be "healthy"
}

It "降级服务器应返回 degraded" {
Get-ServerStatus -ServerName "db-01" | Should -Be "degraded"
}

It "不可达服务器应返回 Unreachable" {
Get-ServerStatus -ServerName "legacy-server" | Should -Be "Unreachable"
}

It "应该调用 Invoke-RestMethod" {
Get-ServerStatus -ServerName "web-01" | Out-Null
Should -Invoke Invoke-RestMethod -Times 1 -Exactly
}
}

执行结果示例:

1
2
3
4
5
6
7
Describing Get-ServerStatus
[+] 正常服务器应返回 healthy 8ms
[+] 降级服务器应返回 degraded 3ms
[+] 不可达服务器应返回 Unreachable 2ms
[+] 应该调用 Invoke-RestMethod 15ms

Tests passed: 4, Failed: 0

注意事项

  1. Pester 5.x 语法:Pester 5 有重大语法变更(如 Context 内不支持直接写 It 外的代码),从 Pester 4 升级需注意
  2. 测试隔离:每个 It 块应该是独立的,不依赖其他测试的执行顺序。使用 BeforeAll/BeforeEach 初始化状态
  3. Mock 作用域:Mock 只在当前 Describe/Context 块内生效,不会影响外部代码
  4. CI/CD 集成:Pester 的 NUnit XML 输出可以直接被 Azure DevOps、GitHub Actions 等解析展示
  5. 测试命名It 的描述字符串应该清晰表达期望行为,失败时可直接从报告看出问题
  6. 覆盖率:Pester 不直接提供代码覆盖率,但可以结合 Invoke-CoverageAnalysis 等工具实现