PowerShell 技能连载 - 基础设施测试

适用于 PowerShell 7.0 及以上版本,需要 Pester v5+ 模块

随着基础设施即代码(IaC)的广泛采用,越来越多的团队使用 PowerShell DSC、Ansible、Terraform 等工具来定义和管理服务器配置。然而,写好了配置代码并不等于部署一定正确——就像应用代码需要单元测试一样,基础设施代码同样需要一套系统化的测试策略来保证部署结果与预期一致。

基础设施测试与传统软件测试有所不同:它验证的不是函数的输入输出,而是操作系统服务是否运行、端口是否监听、文件权限是否合规、注册表键值是否正确。这类测试通常在部署完成后执行,作为 CI/CD 流水线的最后防线,一旦发现异常就阻断发布或触发回滚。

Pester 是 PowerShell 生态中最强大的测试框架,其描述式语法(Describe / Context / It)天然适合编写基础设施验证用例。本文将从 Pester 基础设施测试框架的搭建、合规性测试套件的编写、到部署验证管道的集成,完整展示如何用 PowerShell 构建一套可靠的基础设施测试体系。

Pester 基础设施测试框架

第一个场景是搭建基础设施测试的基本框架。我们用 Pester 编写针对服务器配置的测试用例,验证关键资源(服务、端口、文件、注册表)是否存在且处于期望状态。测试用例可以参数化,方便在不同环境(dev / staging / prod)中复用。

下面的脚本定义了一套基础设施测试,通过外部 JSON 配置文件描述期望状态,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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
function Invoke-InfrastructureTest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ExpectedStatePath,

[ValidateSet('Minimal', 'Standard', 'Comprehensive')]
[string]$TestDepth = 'Standard',

[string]$OutputPath = 'TestResults.xml'
)

$stateConfig = Get-Content -Path $ExpectedStatePath -Raw | ConvertFrom-Json

$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Detailed'

if ($OutputPath) {
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $OutputPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'
}

$container = New-PesterContainer -ScriptBlock {
param($Config, $Depth)

Describe "基础设施状态验证 - $($Config.environmentName)" {
BeforeAll {
$testStartTime = Get-Date
Write-Host " 测试开始时间: $testStartTime" -ForegroundColor DarkGray
}

# 验证 Windows 服务状态
Context "Windows 服务检查" {
foreach ($svc in $Config.services) {
It "服务 '$($svc.name)' 应处于 $($svc.expectedStatus) 状态" {
$service = Get-Service -Name $svc.name -ErrorAction SilentlyContinue
$service | Should -Not -BeNullOrEmpty -Because "服务 '$($svc.name)' 必须存在"
$service.Status.ToString() | Should -Be $svc.expectedStatus
}
}
}

# 验证网络端口监听
Context "网络端口检查" {
foreach ($port in $Config.ports) {
It "端口 $($port.number)/$($port.protocol) 应处于监听状态" {
$protocol = if ($port.protocol -eq 'UDP') { 'Udp' } else { 'Tcp' }
$listener = Get-NetTCPConnection -LocalPort $port.number `
-ErrorAction SilentlyContinue |
Where-Object { $_.State -eq 'Listen' }

$listener | Should -Not -BeNullOrEmpty `
-Because "端口 $($port.number) 必须有进程监听"
}
}
}

# 验证文件和目录
Context "文件系统检查" {
foreach ($file in $Config.files) {
if ($file.type -eq 'Directory') {
It "目录 '$($file.path)' 应该存在" {
Test-Path -Path $file.path -PathType Container |
Should -BeTrue -Because "目录 '$($file.path)' 必须存在"
}
}
else {
It "文件 '$($file.path)' 应该存在且内容包含 '$($file.expectedContent)'" {
Test-Path -Path $file.path -PathType Leaf |
Should -BeTrue -Because "文件 '$($file.path)' 必须存在"

if ($file.expectedContent) {
$content = Get-Content -Path $file.path -Raw
$content | Should -Match $file.expectedContent
}
}
}
}
}

# 深度测试:注册表和权限(仅 Standard 和 Comprehensive 级别)
if ($Depth -in @('Standard', 'Comprehensive')) {
Context "注册表键值检查" {
foreach ($reg in $Config.registryKeys) {
It "注册表 '$($reg.path)\$($reg.name)' 应为 '$($reg.expectedValue)'" {
$actual = Get-ItemProperty -Path $reg.path -Name $reg.name `
-ErrorAction SilentlyContinue
$actual.$($reg.name) | Should -Be $reg.expectedValue
}
}
}
}

AfterAll {
$duration = (Get-Date) - $testStartTime
Write-Host " 测试耗时: $($duration.ToString('mm\:ss\.fff'))" -ForegroundColor DarkGray
}
}
} -Data @{ Config = $stateConfig; Depth = $TestDepth }

$pesterConfig.Run.Container = $container
$result = Invoke-Pester -Configuration $pesterConfig

return $result
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PS> Invoke-InfrastructureTest -ExpectedStatePath .\expected-state.json -TestDepth Standard

Starting discovery in 1 files.
Discovery finished in 0.23s.
[+] D:\infra-tests\expected-state.json 1.82s (7.3ms|1.81s)
[+] 基础设施状态验证 - production
[+] Windows 服务检查
[+] 服务 'W3SVC' 应处于 Running 状态
[+] 服务 'MSSQLSERVER' 应处于 Running 状态
[+] 网络端口检查
[+] 端口 443/TCP 应处于监听状态
[+] 端口 1433/TCP 应处于监听状态
[+] 文件系统检查
[+] 文件 'D:\app\appsettings.json' 应该存在且内容包含 'Production'
[+] 注册表键值检查
[+] 注册表 'HKLM:\SOFTWARE\MyApp\Server' 应为 '10.0.1.50'
Tests passed: 6, Failed: 0, Skipped: 0, NotRun: 0
TestResults.xml written to TestResults.xml

合规性测试套件

第二个场景是构建一套完整的合规性测试套件,用于安全基线检查和 CIS Benchmark 验证。与基础的状态检查不同,合规性测试更关注安全策略的落地情况,如密码策略、审计日志、防火墙规则、用户权限等,并支持环境间差异检测。

下面的脚本定义了一套可扩展的合规性测试框架,支持按分类执行,输出详细的合规报告:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
function Invoke-ComplianceTestSuite {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$BaselinePath,

[string[]]$Categories = @('AccountPolicy', 'AuditPolicy', 'Firewall', 'UserRights'),

[string]$EnvironmentName = 'default',

[string]$ReportPath = 'ComplianceReport.json'
)

$baseline = Get-Content -Path $BaselinePath -Raw | ConvertFrom-Json
$complianceResults = [System.Collections.Generic.List[PSObject]]::new()
$totalPassed = 0
$totalFailed = 0

foreach ($category in $Categories) {
Write-Host "`n--- 检查分类: $category ---" -ForegroundColor Cyan

$checks = $baseline.$category
if (-not $checks) {
Write-Host " 跳过(基线中无 '$category' 配置)" -ForegroundColor DarkGray
continue
}

foreach ($check in $checks) {
$testResult = $null
$actualValue = $null

try {
switch ($category) {
'AccountPolicy' {
$netAccounts = net accounts |
Out-String | ForEach-Object {
if ($_ -match "$($check.key)\s*:\s*(.+)") { $Matches[1].Trim() }
}
$actualValue = $netAccounts
$testResult = ($actualValue -eq $check.expectedValue)
}

'AuditPolicy' {
$auditLine = auditpol /get /subcategory:"$($check.subcategory)" |
Select-String -Pattern $check.subcategory
if ($auditLine -match 'Success') {
$actualValue = 'Enabled'
}
else {
$actualValue = 'Disabled'
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'Firewall' {
$rule = Get-NetFirewallRule -DisplayName $check.ruleName `
-ErrorAction SilentlyContinue
if ($check.property -eq 'Enabled') {
$actualValue = if ($rule.Enabled -eq $true) { 'True' } else { 'False' }
}
elseif ($check.property -eq 'Direction') {
$actualValue = $rule.Direction.ToString()
}
$testResult = ($actualValue -eq $check.expectedValue)
}

'UserRights' {
$tempFile = [System.IO.Path]::GetTempFileName()
secedit /export /cfg $tempFile /quiet | Out-Null
$secContent = Get-Content -Path $tempFile -Raw
Remove-Item -Path $tempFile -Force

if ($secContent -match "$($check.privilege)\s*=\s*(.+)") {
$actualValue = $Matches[1].Trim()
}
$testResult = ($actualValue -eq $check.expectedValue)
}
}
}
catch {
$actualValue = "Error: $($_.Exception.Message)"
$testResult = $false
}

$result = [PSCustomObject]@{
Category = $category
CheckId = $check.id
Description = $check.description
Expected = $check.expectedValue
Actual = $actualValue
Passed = [bool]$testResult
Severity = $check.severity
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}

$complianceResults.Add($result)

if ($testResult) {
$totalPassed++
Write-Host " [通过] [$($check.id)] $($check.description)" -ForegroundColor Green
}
else {
$totalFailed++
$color = if ($check.severity -eq 'Critical') { 'Red' } else { 'Yellow' }
Write-Host " [失败] [$($check.id)] $($check.description)" -ForegroundColor $color
Write-Host " 期望: $($check.expectedValue) 实际: $actualValue" -ForegroundColor DarkGray
}
}
}

# 输出摘要
$total = $totalPassed + $totalFailed
$passRate = if ($total -gt 0) { [math]::Round(($totalPassed / $total) * 100, 1) } else { 0 }

Write-Host "`n========== 合规性摘要 ==========" -ForegroundColor Cyan
Write-Host " 环境: $EnvironmentName"
Write-Host " 总检查项: $total"
Write-Host " 通过: $totalPassed 失败: $totalFailed"
Write-Host " 合规率: $passRate%"
Write-Host "================================"

# 写入报告文件
$report = [PSCustomObject]@{
Environment = $EnvironmentName
GeneratedAt = Get-Date -Format 'o'
TotalChecks = $total
Passed = $totalPassed
Failed = $totalFailed
PassRate = $passRate
Results = $complianceResults
}
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $ReportPath
Write-Host "报告已保存至: $ReportPath" -ForegroundColor DarkGray

return $report
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PS> Invoke-ComplianceTestSuite -BaselinePath .\cis-baseline.json -EnvironmentName prod -ReportPath .\prod-compliance.json

--- 检查分类: AccountPolicy ---
[通过] [ACC-001] 密码最短长度应为 14 个字符
[失败] [ACC-002] 密码最长使用期限应为 90
期望: 90 实际: 180
--- 检查分类: AuditPolicy ---
[通过] [AUD-001] 登录审核应启用
[通过] [AUD-002] 对象访问审核应启用
--- 检查分类: Firewall ---
[通过] [FW-001] 防火墙域配置文件应启用
[失败] [FW-002] RDP 入站规则应禁用
期望: False 实际: True
--- 检查分类: UserRights ---
[通过] [USR-001] 本地管理员组仅包含授权账户

========== 合规性摘要 ==========
环境: prod
总检查项: 7
通过: 5 失败: 2
合规率: 71.4%
================================
报告已保存至: .\prod-compliance.json

部署验证管道

第三个场景是将基础设施测试集成到部署管道中,实现部署后自动运行验证、基于测试结果判断是否回滚、以及生成可读的测试报告。这是将测试从”手动执行”升级为”自动化防线”的关键一步。

下面的脚本封装了完整的部署验证流程,支持自定义通过阈值、回滚触发、以及 HTML 报告生成:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
function Invoke-DeploymentValidation {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$TestPath,

[double]$PassThreshold = 100.0,

[string]$RollbackScriptPath,

[string]$ReportOutputDir = 'ValidationReports',

[string]$DeploymentId = "deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
)

$null = New-Item -ItemType Directory -Path $ReportOutputDir -Force
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "`n========== 部署验证管道 ==========" -ForegroundColor Cyan
Write-Host " 部署ID: $DeploymentId"
Write-Host " 测试路径: $TestPath"
Write-Host " 通过阈值: $PassThreshold%"
Write-Host " 启动时间: $timestamp"
Write-Host "====================================`n"

# 执行 Pester 测试
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = $TestPath
$pesterConfig.Run.PassThru = $true
$pesterConfig.Output.Verbosity = 'Normal'

$junitPath = Join-Path $ReportOutputDir "$DeploymentId-junit.xml"
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = $junitPath
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'

$result = Invoke-Pester -Configuration $pesterConfig

# 计算通过率
$totalTests = $result.TotalCount
$passedTests = $result.PassedCount
$failedTests = $result.FailedCount
$passRate = if ($totalTests -gt 0) {
[math]::Round(($passedTests / $totalTests) * 100, 2)
}
else {
0
}

Write-Host "`n--- 测试结果 ---" -ForegroundColor Cyan
Write-Host " 总计: $totalTests 通过: $passedTests 失败: $failedTests"
Write-Host " 通过率: $passRate% (阈值: $PassThreshold%)"

$passed = $passRate -ge $PassThreshold

# 生成 HTML 报告
$htmlPath = Join-Path $ReportOutputDir "$DeploymentId-report.html"
$failedDetails = $result.Tests |
Where-Object { $_.Result -eq 'Failed' } |
ForEach-Object {
"<li>$($_.Name) <br/><pre>$($_.ErrorRecord)</pre></li>"
}

$statusColor = if ($passed) { '#28a745' } else { '#dc3545' }
$statusText = if ($passed) { 'PASSED' } else { 'FAILED' }

$html = @"
<!DOCTYPE html>
<html>
<head><title>部署验证报告 - $DeploymentId</title></head>
<body style='font-family:Segoe UI,sans-serif;margin:40px'>
<h1 style='color:$statusColor'>部署验证: $statusText</h1>
<p>部署ID: $DeploymentId | 时间: $timestamp</p>
<p>总计: $totalTests | 通过: $passedTests | 失败: $failedTests | 通过率: $passRate%</p>
<h3>失败用例:</h3>
<ul>$($failedDetails -join "`n")</ul>
</body></html>
"@
$html | Set-Content -Path $htmlPath -Encoding UTF8
Write-Host " HTML 报告: $htmlPath" -ForegroundColor DarkGray

# 回滚判断
if (-not $passed) {
Write-Host "`n[失败] 通过率 $passRate% 低于阈值 $PassThreshold%" -ForegroundColor Red

if ($RollbackScriptPath -and (Test-Path $RollbackScriptPath)) {
Write-Host "[回滚] 正在执行回滚脚本: $RollbackScriptPath" -ForegroundColor Yellow
try {
& $RollbackScriptPath
Write-Host "[回滚] 回滚完成。" -ForegroundColor Yellow
}
catch {
Write-Host "[回滚失败] $($_.Exception.Message)" -ForegroundColor Red
}
}
else {
Write-Host "[警告] 未指定回滚脚本,请手动处理。" -ForegroundColor Yellow
}
}
else {
Write-Host "`n[通过] 部署验证通过,所有测试均在阈值范围内。" -ForegroundColor Green
}

return [PSCustomObject]@{
DeploymentId = $DeploymentId
Passed = $passed
TotalTests = $totalTests
PassedTests = $passedTests
FailedTests = $failedTests
PassRate = $passRate
HtmlReportPath = $htmlPath
JUnitReportPath = $junitPath
}
}

执行结果示例:

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
PS> Invoke-DeploymentValidation -TestPath .\infra-tests\ -PassThreshold 90 -RollbackScriptPath .\rollback.ps1 -DeploymentId deploy-20260414

========== 部署验证管道 ==========
部署ID: deploy-20260414
测试路径: D:\infra-tests\
通过阈值: 90%
启动时间: 2026-04-14 11:00:00
====================================

Discovery finished in 0.45s.
[+] D:\infra-tests\service.tests.ps1 2.31s (12 tests passed)
[+] D:\infra-tests\firewall.tests.ps1 1.08s (8 tests passed)
[-] D:\infra-tests\registry.tests.ps1 0.92s (19 passed, 1 failed)

--- 测试结果 ---
总计: 40 通过: 39 失败: 1
通过率: 97.5% (阈值: 90%)
HTML 报告: ValidationReports\deploy-20260414-report.html

[通过] 部署验证通过,所有测试均在阈值范围内。

DeploymentId : deploy-20260414
Passed : True
TotalTests : 40
PassedTests : 39
FailedTests : 1
PassRate : 97.5
HtmlReportPath : ValidationReports\deploy-20260414-report.html
JUnitReportPath : ValidationReports\deploy-20260414-junit.xml

注意事项

  1. Pester 版本兼容性:本文使用 Pester v5 的 New-PesterConfigurationNew-PesterContainer API,与 v3/v4 语法不兼容。可通过 Get-Module Pester -ListAvailable 检查已安装版本,如需升级使用 Install-Module Pester -Force -SkipPublisherCheck

  2. 测试用例参数化:基础设施测试最大的挑战是环境差异。建议将所有环境特定的值(服务器名、端口、路径)抽取到外部 JSON 配置文件中,测试脚本本身只包含断言逻辑,这样同一套测试可以在不同环境中复用。

  3. 合规性基线维护:CIS Benchmark 和安全基线会随版本更新,基线 JSON 文件应纳入 Git 版本控制。每次基线更新后,先用 -ReportOnly 模式在非生产环境试运行,确认新规则的误报率可接受后再应用到生产环境。

  4. 部署验证的幂等性:验证管道可能因网络抖动等原因产生偶发失败。建议对关键测试加入重试逻辑(Set-ItResult -Pending 后重试一次),同时在设置 PassThreshold 时留出合理余量(如 95% 而非 100%),避免单次偶发失败阻断整个发布。

  5. 回滚脚本的可靠性:回滚脚本本身也需要测试和验证。建议将回滚脚本纳入版本控制,并在 staging 环境中定期演练。回滚操作应有详细的日志记录,包括回滚前后的状态快照,便于事后审计。

  6. 测试报告的持久化:部署验证生成的 JUnit XML 和 HTML 报告应上传到 CI/CD 平台的制品库(如 Azure Pipelines 的 Publish Test Results 任务),保留至少 90 天。长期积累的测试数据可以帮助识别反复失败的测试用例,从而优化测试套件的稳定性。

PowerShell 技能连载 - 模块开发与测试

适用于 PowerShell 7.0 及以上版本

PowerShell 模块是代码复用和分发的核心单元。将常用的函数打包为模块,不仅可以让团队成员通过 Import-Module 一行命令加载全部功能,还能发布到 PowerShell Gallery(PSGallery)供全球社区使用。然而模块的质量直接影响使用者的信任度——一个没有测试覆盖的模块,任何一次改动都可能引入难以察觉的回归缺陷。

现代 PowerShell 模块开发已经形成了一套成熟的工程实践:使用 Plaster 脚手架工具初始化标准项目结构,用 Pester 测试框架编写单元测试和集成测试,再通过 CI/CD 流水线实现自动化构建、测试和发布。这套流程不仅能保证模块质量,还能让你在发布新版本时充满信心。

本文将从模块项目的标准结构入手,逐步展示 Pester 测试的编写方法,最后介绍如何将模块发布到 PSGallery 并接入 CI/CD 自动化流程。

模块项目结构

一个规范的 PowerShell 模块项目不仅包含 .psm1.psd1 文件,还应该有清晰的目录组织、构建脚本和测试目录。使用 Plaster 模板可以快速生成符合社区标准的项目骨架,避免手动创建时遗漏关键文件。

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# 安装 Plaster 脚手架工具
Install-Module -Name Plaster -Scope CurrentUser -Force

# 定义模块参数
$plasterParams = @{
TemplatePath = (Install-Module -Name PlasterTemplate -PassThru -Force).ModuleBase
DestinationPath = Join-Path $HOME "Projects\MyUtils"
}

# 使用内置模板创建模块项目(这里手动创建标准结构作为演示)
$moduleRoot = Join-Path $HOME "Projects\MyUtils"
$dirs = @(
"MyUtils\functions",
"MyUtils\internal\functions",
"MyUtils\tests\unit",
"MyUtils\tests\integration",
"MyUtils\build",
"MyUtils\docs",
"MyUtils\examples"
)

foreach ($dir in $dirs) {
$fullPath = Join-Path $moduleRoot $dir
if (-not (Test-Path $fullPath)) {
New-Item -Path $fullPath -ItemType Directory -Force | Out-Null
Write-Host " 创建目录: $dir" -ForegroundColor Green
}
}

# 创建模块主文件(.psm1)
$psm1Content = @'
# MyUtils.psm1 - 模块入口
# 从 functions 目录加载所有公共函数
$publicFunctions = Get-ChildItem -Path "$PSScriptRoot\functions\*.ps1" -ErrorAction SilentlyContinue
$privateFunctions = Get-ChildItem -Path "$PSScriptRoot\internal\functions\*.ps1" -ErrorAction SilentlyContinue

# 先加载私有函数
foreach ($func in $privateFunctions) {
. $func.FullName
}

# 加载公共函数并导出
$exportedNames = @()
foreach ($func in $publicFunctions) {
. $func.FullName
$exportedNames += $func.BaseName
}

Export-ModuleMember -Function $exportedNames
'@

$psm1Path = Join-Path $moduleRoot "MyUtils\MyUtils.psm1"
$psm1Content | Set-Content -Path $psm1Path -Encoding Utf8

# 创建模块清单(.psd1)
$manifestParams = @{
Path = (Join-Path $moduleRoot "MyUtils\MyUtils.psd1")
RootModule = "MyUtils.psm1"
ModuleVersion = "1.0.0"
Author = "PowerShell Developer"
Description = "实用工具函数集合"
PowerShellVersion = "7.0"
FunctionsToExport = @()
VariablesToExport = @()
CmdletsToExport = @()
AliasesToExport = @()
LicenseUri = "https://opensource.org/licenses/MIT"
ProjectUri = "https://github.com/example/MyUtils"
ReleaseNotes = "初始版本发布"
}
New-ModuleManifest @manifestParams

# 创建一个示例公共函数
$sampleFunction = @'
function Get-SystemUptime {
<#
.SYNOPSIS
获取系统运行时间信息
.DESCRIPTION
返回系统自上次启动以来的运行时长、启动时间等详细信息
.PARAMETER ComputerName
目标计算机名称,默认为本地
.EXAMPLE
Get-SystemUptime
获取本地系统运行时间
#>
[CmdletBinding()]
param(
[string]$ComputerName = $env:COMPUTERNAME
)

$os = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName
$uptime = (Get-Date) - $os.LastBootUpTime

[PSCustomObject]@{
ComputerName = $ComputerName
BootTime = $os.LastBootUpTime
UptimeDays = [math]::Floor($uptime.TotalDays)
UptimeHours = [math]::Floor($uptime.TotalHours)
Status = if ($uptime.TotalDays -gt 30) { "需要重启" } else { "正常" }
}
}
'@

$funcPath = Join-Path $moduleRoot "MyUtils\functions\Get-SystemUptime.ps1"
$sampleFunction | Set-Content -Path $funcPath -Encoding Utf8

# 显示最终项目结构
Write-Host "`n=== 模块项目结构 ===" -ForegroundColor Cyan
Get-ChildItem -Path (Join-Path $moduleRoot "MyUtils") -Recurse | ForEach-Object {
$relativePath = $_.FullName.Replace((Join-Path $moduleRoot "MyUtils\"), "")
$indent = " " * ($relativePath.Split("\").Count - 1)
if ($_.PSIsContainer) {
Write-Host "$indent[DIR] $($_.Name)" -ForegroundColor Yellow
} else {
Write-Host "$indent $($_.Name) ($([math]::Round($_.Length / 1KB, 1)) KB)"
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  创建目录: MyUtils\functions
创建目录: MyUtils\internal\functions
创建目录: MyUtils\tests\unit
创建目录: MyUtils\tests\integration
创建目录: MyUtils\build
创建目录: MyUtils\docs
创建目录: MyUtils\examples

=== 模块项目结构 ===
[DIR] MyUtils
[DIR] build
[DIR] docs
[DIR] examples
[DIR] functions
Get-SystemUptime.ps1 (1.2 KB)
[DIR] internal
[DIR] functions
[DIR] tests
[DIR] integration
[DIR] unit
MyUtils.psd1 (2.8 KB)
MyUtils.psm1 (0.5 KB)

Pester 测试编写

Pester 是 PowerShell 社区最流行的测试框架,支持行为驱动开发(BDD)风格的测试语法。通过编写单元测试验证函数逻辑,使用 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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# 安装 Pester 测试框架
Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck
Import-Module Pester

# ===== 单元测试 =====
# 为 Get-SystemUptime 编写单元测试,使用 Mock 隔离 CIM 调用
$unitTest = @'
Describe "Get-SystemUptime" {
BeforeAll {
# Mock Get-CimInstance,避免真实调用 WMI
Mock Get-CimInstance {
[PSCustomObject]@{
LastBootUpTime = (Get-Date).AddDays(-15).AddHours(-3)
}
}

# Mock Get-Date,使测试结果可预测
Mock Get-Date { [datetime]"2026-02-26 10:00:00" }
}

Context "当系统正常运行时" {
It "应返回正确的运行天数" {
$result = Get-SystemUptime
$result.UptimeDays | Should -Be 15
}

It "应返回正确的运行小时数" {
$result = Get-SystemUptime
$result.UptimeHours | Should -Be 363
}

It "状态应为'正常'" {
$result = Get-SystemUptime
$result.Status | Should -Be "正常"
}

It "应返回本地计算机名" {
$result = Get-SystemUptime
$result.ComputerName | Should -Be $env:COMPUTERNAME
}
}

Context "当指定远程计算机时" {
It "应将计算机名传递给 Get-CimInstance" {
Get-SystemUptime -ComputerName "SERVER01"
Should -Invoke Get-CimInstance -Times 1 -ParameterFilter {
$ComputerName -eq "SERVER01"
}
}
}

Context "当系统超过30天未重启时" {
BeforeAll {
Mock Get-CimInstance {
[PSCustomObject]@{
LastBootUpTime = (Get-Date).AddDays(-45)
}
}
}

It "状态应为'需要重启'" {
$result = Get-SystemUptime
$result.Status | Should -Be "需要重启"
}
}
}
'@

$unitTestPath = Join-Path $moduleRoot "MyUtils\tests\unit\Get-SystemUptime.Tests.ps1"
$unitTest | Set-Content -Path $unitTestPath -Encoding Utf8

# ===== 集成测试 =====
# 验证模块加载和函数导出的端到端场景
$integrationTest = @'
Describe "MyUtils 模块集成测试" {
BeforeAll {
$modulePath = Join-Path $PSScriptRoot "..\..\MyUtils.psd1"
Import-Module $modulePath -Force
}

AfterAll {
Remove-Module MyUtils -Force -ErrorAction SilentlyContinue
}

Context "模块加载" {
It "应成功加载模块" {
Get-Module -Name MyUtils | Should -Not -BeNullOrEmpty
}

It "应导出 Get-SystemUptime 函数" {
$cmds = Get-Command -Module MyUtils
$cmds.Name | Should -Contain "Get-SystemUptime"
}
}

Context "函数调用" {
It "Get-SystemUptime 应返回正确类型" {
$result = Get-SystemUptime
$result | Should -BeOfType [System.Management.Automation.PSCustomObject]
}

It "返回对象应包含预期的属性" {
$result = Get-SystemUptime
$result.PSObject.Properties.Name | Should -Contain "ComputerName"
$result.PSObject.Properties.Name | Should -Contain "BootTime"
$result.PSObject.Properties.Name | Should -Contain "UptimeDays"
$result.PSObject.Properties.Name | Should -Contain "Status"
}
}
}
'@

$intTestPath = Join-Path $moduleRoot "MyUtils\tests\integration\Module.Tests.ps1"
$integrationTest | Set-Content -Path $intTestPath -Encoding Utf8

# 运行单元测试
Write-Host "=== 运行单元测试 ===" -ForegroundColor Cyan
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = $unitTestPath
$pesterConfig.Output.Verbosity = "Detailed"
$pesterResult = Invoke-Pester -Configuration $pesterConfig

# 输出测试摘要
Write-Host "`n=== 测试摘要 ===" -ForegroundColor Cyan
Write-Host "总测试数: $($pesterResult.TotalCount)"
Write-Host "通过: $($pesterResult.PassedCount)" -ForegroundColor Green
Write-Host "失败: $($pesterResult.FailedCount)" -ForegroundColor Red
Write-Host "跳过: $($pesterResult.SkippedCount)" -ForegroundColor Yellow
Write-Host "执行时间: $([math]::Round($pesterResult.Duration.TotalSeconds, 2)) 秒"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
=== 运行单元测试 ===
Starting discovery in 1 files.
Discovery finished in 0.12 seconds.
Running tests from 'Get-SystemUptime.Tests.ps1'
Describing Get-SystemUptime
Context 当系统正常运行时
[+] 应返回正确的运行天数 38ms
[+] 应返回正确的运行小时数 12ms
[+] 状态应为'正常' 8ms
[+] 应返回本地计算机名 6ms
Context 当指定远程计算机时
[+] 应将计算机名传递给 Get-CimInstance 15ms
Context 当系统超过30天未重启时
[+] 状态应为'需要重启' 10ms
Tests passed: 6, Failed: 0, Skipped: 0, NotRun: 0

=== 测试摘要 ===
总测试数: 6
通过: 6
失败: 0
跳过: 0
执行时间: 0.34 秒

发布与维护

模块开发和测试完成后,下一步是发布到 PSGallery 供他人使用。合理的版本管理、自动化构建脚本和 CI/CD 集成,能让模块的迭代发布变得高效可靠。

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# ===== 发布到 PowerShell Gallery =====
# 1. 获取 API Key(从 https://www.powershellgallery.com 获取)
# $apiKey = Get-Secret -Name "PSGalleryApiKey" -AsPlainText

# 2. 发布前检查
$modulePath = Join-Path $moduleRoot "MyUtils"
$manifest = Test-ModuleManifest -Path (Join-Path $modulePath "MyUtils.psd1")

Write-Host "模块名称: $($manifest.Name)"
Write-Host "版本号: $($manifest.Version)"
Write-Host "作者: $($manifest.Author)"
Write-Host "描述: $($manifest.Description)"

# 运行 PSScriptAnalyzer 代码质量检查
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
$analysisResult = Invoke-ScriptAnalyzer -Path $modulePath -Severity Warning,Error

if ($analysisResult) {
Write-Host "`n=== 代码质量问题 ===" -ForegroundColor Yellow
$analysisResult | Format-Table RuleName, Severity, Line, Message -AutoSize
} else {
Write-Host "`n代码质量检查通过,无警告或错误" -ForegroundColor Green
}

# 发布模块(取消注释以实际发布)
# Publish-Module -Path $modulePath -NuGetApiKey $apiKey -Repository PSGallery

# ===== 版本管理辅助函数 =====
function Update-ModuleVersionLocal {
param(
[Parameter(Mandatory)]
[string]$ModulePath,

[Parameter(Mandatory)]
[ValidateSet("Major", "Minor", "Patch")]
[string]$BumpType,

[string]$ReleaseNotes
)

$manifestPath = Join-Path $ModulePath "*.psd1"
$currentManifest = Test-ModuleManifest -Path $manifestPath
$version = $currentManifest.Version

$newVersion = switch ($BumpType) {
"Major" { [version]::new($version.Major + 1, 0, 0) }
"Minor" { [version]::new($version.Major, $version.Minor + 1, 0) }
"Patch" { [version]::new($version.Major, $version.Minor, $version.Build + 1) }
}

# 更新清单中的版本号
$manifestContent = Get-Content -Path $manifestPath -Raw
$manifestContent = $manifestContent -replace
"ModuleVersion\s*=\s*'$version'",
"ModuleVersion = '$newVersion'"

if ($ReleaseNotes) {
$manifestContent = $manifestContent -replace
"ReleaseNotes\s*=\s*'[^']*'",
"ReleaseNotes = '$ReleaseNotes'"
}

$manifestContent | Set-Content -Path $manifestPath -Encoding Utf8
Write-Host "版本已更新: $version -> $newVersion" -ForegroundColor Green
}

# 演示版本升级
Write-Host "`n=== 版本管理演示 ===" -ForegroundColor Cyan
Update-ModuleVersionLocal -ModulePath $modulePath -BumpType Minor -ReleaseNotes "新增 Get-SystemUptime 函数"

# ===== 构建脚本 =====
$buildScript = @'
# build.ps1 - 模块构建脚本
param(
[string]$Task = "Build",
[version]$Version
)

$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot

switch ($Task) {
"Test" {
Write-Host "运行测试..." -ForegroundColor Cyan
$config = New-PesterConfiguration
$config.Run.Path = Join-Path $projectRoot "tests"
$config.Output.Verbosity = "Detailed"
$result = Invoke-Pester -Configuration $config
if ($result.FailedCount -gt 0) {
throw "测试失败: $($result.FailedCount) 个测试未通过"
}
Write-Host "所有测试通过" -ForegroundColor Green
}

"Build" {
Write-Host "构建模块..." -ForegroundColor Cyan
$outputDir = Join-Path $projectRoot "output\MyUtils"
if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse -Force }
Copy-Item -Path (Join-Path $projectRoot "MyUtils") -Destination $outputDir -Recurse
Write-Host "构建完成: $outputDir" -ForegroundColor Green
}

"Publish" {
Write-Host "发布到 PSGallery..." -ForegroundColor Cyan
$outputDir = Join-Path $projectRoot "output\MyUtils"
$apiKey = Get-Secret -Name "PSGalleryApiKey" -AsPlainText
Publish-Module -Path $outputDir -NuGetApiKey $apiKey
Write-Host "发布完成" -ForegroundColor Green
}

default {
Write-Host "可用任务: Test, Build, Publish"
}
}
'@

$buildPath = Join-Path $moduleRoot "MyUtils\build.ps1"
$buildScript | Set-Content -Path $buildPath -Encoding Utf8

# ===== CI/CD 配置示例(GitHub Actions)=====
$githubActions = @'
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PowerShell Modules
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Pester, PSScriptAnalyzer -Force
- name: Run PSScriptAnalyzer
shell: pwsh
run: Invoke-ScriptAnalyzer -Path ./MyUtils -Severity Error
- name: Run Pester Tests
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = ./tests
Invoke-Pester -Configuration $config

publish:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to PSGallery
shell: pwsh
env:
NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
run: |
Publish-Module -Path ./MyUtils -NuGetApiKey $env:NUGET_API_KEY
'@

Write-Host "`n=== CI/CD 配置文件已生成 ===" -ForegroundColor Cyan
Write-Host "GitHub Actions 工作流包含以下步骤:"
Write-Host " 1. 代码检出"
Write-Host " 2. 安装依赖模块(Pester、PSScriptAnalyzer)"
Write-Host " 3. 静态代码分析"
Write-Host " 4. 运行测试套件"
Write-Host " 5. 测试通过后自动发布到 PSGallery"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
模块名称: MyUtils
版本号: 1.0.0
作者: PowerShell Developer
描述: 实用工具函数集合

代码质量检查通过,无警告或错误

=== 版本管理演示 ===
版本已更新: 1.0.0 -> 1.1.0

=== CI/CD 配置文件已生成 ===
GitHub Actions 工作流包含以下步骤:
1. 代码检出
2. 安装依赖模块(Pester、PSScriptAnalyzer)
3. 静态代码分析
4. 运行测试套件
5. 测试通过后自动发布到 PSGallery

注意事项

  1. 模块清单是门面New-ModuleManifest 生成的 .psd1 文件是模块的元数据入口。务必确保 FunctionsToExportCmdletsToExport 等字段显式声明导出列表,而非使用通配符 *。通配符导出会让模块加载变慢,还可能意外暴露内部函数。

  2. Mock 不要过度使用:Pester 的 Mock 功能强大但容易滥用。单元测试中 Mock 外部依赖(如网络调用、WMI 查询)是合理的,但如果 Mock 层数过多,测试本身就会变得脆弱,失去验证代码逻辑的价值。建议优先测试真实逻辑,仅在 I/O 边界使用 Mock。

  3. 测试与代码同步维护:每新增或修改一个公共函数,都应同步更新对应的测试文件。可以在 CI 流水线中加入测试覆盖率检查(Pester v5 支持代码覆盖率收集),将覆盖率阈值设为 80% 以上,防止测试与代码脱节。

  4. 语义化版本号:遵循 SemVer 规范(Major.Minor.Patch)管理模块版本。破坏性变更(如删除函数、更改参数含义)升 Major,新增功能升 Minor,Bug 修复升 Patch。避免随意修改版本号,使用者可能通过版本约束来控制升级范围。

  5. PSGallery 发布前检查清单:发布前至少完成以下检查:Test-ModuleManifest 验证清单完整性、Invoke-ScriptAnalyzer 代码质量检查、Pester 全部测试通过、README 和帮助文档更新、CHANGELOG 记录变更。缺少任何一项都可能导致用户反馈或差评。

  6. 跨平台兼容性:如果你的模块需要在 Linux 和 macOS 上运行,测试时要注意避免使用 Windows 专有命令(如注册表操作、WMI/CIM 的部分类)。可以在 GitHub Actions 中配置多平台(ubuntu-latestwindows-latestmacos-latest)并行测试,确保模块的跨平台兼容性。

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

适用于 PowerShell 5.1 及以上版本

背景

在软件开发中,单元测试是保障代码质量的第一道防线。PowerShell 生态中最流行的测试框架是 Pester,它提供了一套简洁优雅的 DSL(领域特定语言),让你可以用 DescribeContextIt 三层结构组织测试用例,用 Should 断言验证预期结果。无论是简单的工具函数还是复杂的自动化脚本,Pester 都能为它们编写清晰可维护的测试。

然而,真实环境中的 PowerShell 脚本往往涉及大量外部依赖:调用 REST API、读写文件系统、查询数据库、操作 Windows 注册表。如果在测试中真正去调用这些外部资源,测试就会变得缓慢、脆弱、不可重复——API 服务可能宕机、文件路径可能不存在、数据库连接可能超时。Pester 的 Mock 机制正是为了解决这个问题,它允许你在测试中替换掉外部依赖,让测试专注于验证代码逻辑本身,而非依赖的可用性。

本文将从 Pester 的基础测试结构讲起,逐步介绍如何使用 Mock 隔离外部依赖,最后演示如何在 CI 流水线中集成测试覆盖率报告,形成完整的自动化测试闭环。

基础:Pester 测试结构与 Should 断言

Pester 测试文件通常以 .Tests.ps1 结尾,采用 Describe -> Context -> It 三层嵌套结构。Describe 描述被测功能模块,Context 划分测试场景,It 定义具体的测试用例。Should 是 Pester 的断言关键字,支持 BeBeNullOrEmptyThrowBeLike 等丰富的匹配器,覆盖了日常测试的绝大多数场景。

以下代码演示了一个完整的 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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# 被测函数:一组工具函数
function ConvertTo-TitleCase {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
return (Get-Culture).TextInfo.ToTitleCase($Text.ToLower())
}

function Get-Summary {
param([int[]]$Numbers)
if ($Numbers.Count -eq 0) { throw '数组不能为空' }
$sorted = $Numbers | Sort-Object
return @{
Min = $sorted[0]
Max = $sorted[-1]
Average = [math]::Round(($Numbers | Measure-Object -Average).Average, 2)
Sum = ($Numbers | Measure-Object -Sum).Sum
}
}

function Protect-SensitiveData {
param([string]$InputObject, [int]$VisibleChars = 4)
if ([string]::IsNullOrEmpty($InputObject)) {
throw [ArgumentNullException]::new('InputObject')
}
$masked = '*' * [math]::Max(0, $InputObject.Length - $VisibleChars)
$visible = $InputObject.Substring([math]::Max(0, $InputObject.Length - $VisibleChars))
return $masked + $visible
}

# Pester 测试文件:Utils.Tests.ps1
Describe 'ConvertTo-TitleCase' {
It '应将英文单词转为首字母大写' {
ConvertTo-TitleCase -Text 'hello world' | Should -Be 'Hello World'
}

It '应处理全大写输入' {
ConvertTo-TitleCase -Text 'POWERShell IS great' | Should -Be 'Powershell Is Great'
}

It '空字符串应返回空字符串' {
ConvertTo-TitleCase -Text '' | Should -Be ''
}

It '空白字符串应返回空字符串' {
ConvertTo-TitleCase -Text ' ' | Should -Be ''
}
}

Describe 'Get-Summary' {
Context '正常输入' {
It '应正确计算最小值、最大值、平均值和总和' {
$result = Get-Summary -Numbers @(3, 7, 1, 9, 5)

$result.Min | Should -Be 1
$result.Max | Should -Be 9
$result.Average | Should -Be 5
$result.Sum | Should -Be 25
}

It '应正确处理负数' {
$result = Get-Summary -Numbers @(-3, -1, -7)

$result.Min | Should -Be -7
$result.Max | Should -Be -1
$result.Sum | Should -Be -11
}
}

Context '边界情况' {
It '单个数字应返回相同的值' {
$result = Get-Summary -Numbers @(42)

$result.Min | Should -Be 42
$result.Max | Should -Be 42
$result.Average | Should -Be 42
}

It '空数组应抛出异常' {
{ Get-Summary -Numbers @() } | Should -Throw '数组不能为空'
}
}
}

Describe 'Protect-SensitiveData' {
It '应遮盖除最后四位以外的字符' {
Protect-SensitiveData -InputObject '1234567890' | Should -Be '******7890'
}

It '短于可见位数时应全部遮盖' {
Protect-SensitiveData -InputObject 'AB' -VisibleChars 4 | Should -Be '**AB'
}

It '空值应抛出 ArgumentNullException' {
{ Protect-SensitiveData -InputObject '' } |
Should -Throw -ExceptionType ([ArgumentNullException])
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Describing ConvertTo-TitleCase
[+] 应将英文单词转为首字母大写 28ms
[+] 应处理全大写输入 9ms
[+] 空字符串应返回空字符串 4ms
[+] 空白字符串应返回空字符串 3ms
Describing Get-Summary
Context 正常输入
[+] 应正确计算最小值、最大值、平均值和总和 31ms
[+] 应正确处理负数 12ms
Context 边界情况
[+] 单个数字应返回相同的值 8ms
[+] 空数组应抛出异常 14ms
Describing Protect-SensitiveData
[+] 应遮盖除最后四位以外的字符 11ms
[+] 短于可见位数时应全部遮盖 6ms
[+] 空值应抛出 ArgumentNullException 9ms
Tests passed: 9, Failed: 0, Skipped: 0, NotRun: 0

这段测试展示了 Pester 的基本用法。Should -Be 用于精确匹配,Should -Throw 用于验证异常,Should -Throw -ExceptionType 可以同时断言异常类型。Context 块将测试场景分组,使测试输出更具可读性。每个 It 块是一个独立的测试用例,测试之间互不干扰,即使某个测试失败也不会影响其他测试的执行。

进阶:使用 Mock 隔离外部依赖

真实脚本通常包含大量外部调用:读取配置文件、请求 Web API、操作数据库。在单元测试中直接调用这些外部资源会导致测试变慢且不稳定。Pester 的 Mock 命令可以在测试作用域内替换指定的 cmdlet 或函数,返回你预设的结果,从而将测试与外部环境完全解耦。

以下代码演示了如何 Mock Get-Content(文件读取)、Invoke-RestMethod(网络请求)和 Write-EventLog(事件日志)三种常见依赖。

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# 被测函数:从配置文件和远程 API 获取系统状态并记录日志
function Get-SystemHealthStatus {
param(
[string]$ConfigPath = './config.json',
[string]$ApiEndpoint = 'https://api.example.com/health'
)

# 步骤 1: 读取本地配置文件
if (-not (Test-Path $ConfigPath)) {
Write-Warning "配置文件 $ConfigPath 不存在,使用默认值"
$config = @{ TimeoutSeconds = 30; RetryCount = 3 }
} else {
$raw = Get-Content -Path $ConfigPath -Raw
$config = $raw | ConvertFrom-Json
}

# 步骤 2: 请求远程健康检查 API
try {
$response = Invoke-RestMethod -Uri $ApiEndpoint -Method Get `
-TimeoutSec $config.TimeoutSeconds
} catch {
Write-EventLog -LogName 'Application' -Source 'HealthMonitor' `
-EntryType Error -EventId 1001 -Message "API 请求失败: $_"
return @{
Status = 'Error'
Message = "无法连接到 $ApiEndpoint"
Details = $_.Exception.Message
}
}

# 步骤 3: 根据响应判断健康状态
$isHealthy = $response.Status -eq 'Healthy' -and
$response.Uptime -gt 99.0

if ($isHealthy) {
Write-EventLog -LogName 'Application' -Source 'HealthMonitor' `
-EntryType Information -EventId 1000 `
-Message "系统状态正常,运行时间: $($response.Uptime)%"
}

return @{
Status = if ($isHealthy) { 'Healthy' } else { 'Degraded' }
Uptime = $response.Uptime
CheckedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# Pester 测试:使用 Mock 隔离文件、网络和日志依赖
Describe 'Get-SystemHealthStatus' {
BeforeAll {
# 导入被测函数(实际项目中通过 dot-sourcing 或模块导入)
# . ./Get-SystemHealthStatus.ps1
}

Context '配置文件存在且 API 返回健康状态' {
BeforeAll {
# Mock 文件系统
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 60, "RetryCount": 5}'
}

# Mock 网络 API
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 99.95
Version = '2.4.1'
}
}

# Mock 事件日志
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It '应返回 Healthy 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Healthy'
$result.Uptime | Should -Be 99.95
}

It '应从配置文件读取超时设置' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Get-Content -Times 1 -Exactly
}

It '应写入信息级别的事件日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 1 -Exactly `
-ParameterFilter { $EntryType -eq 'Information' }
}
}

Context '配置文件不存在时使用默认值' {
BeforeAll {
Mock Test-Path { $false }
Mock Get-Content { }
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 99.5
Version = '2.4.1'
}
}
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It '不应调用 Get-Content' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Get-Content -Times 0 -Exactly
}

It '仍应正常完成健康检查' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Healthy'
}
}

Context 'API 请求失败时的错误处理' {
BeforeAll {
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 10, "RetryCount": 1}'
}
Mock Invoke-RestMethod {
throw [System.Net.WebException]::new('连接超时')
}
Mock Write-EventLog { }
}

It '应返回 Error 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Error'
$result.Message | Should -BeLike '*无法连接到*'
}

It '应写入错误级别的事件日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 1 -Exactly `
-ParameterFilter { $EntryType -eq 'Error' -and $EventId -eq 1001 }
}

It '错误信息应包含异常详情' {
$result = Get-SystemHealthStatus

$result.Details | Should -BeLike '*连接超时*'
}
}

Context 'API 返回降级状态' {
BeforeAll {
Mock Test-Path { $true }
Mock Get-Content {
'{"TimeoutSeconds": 30, "RetryCount": 3}'
}
Mock Invoke-RestMethod {
[PSCustomObject]@{
Status = 'Healthy'
Uptime = 95.0
Version = '2.4.1'
}
}
Mock Write-EventLog { }
Mock Get-Date { '2025-12-04 10:30:00' }
}

It 'Uptime 低于阈值时应返回 Degraded 状态' {
$result = Get-SystemHealthStatus

$result.Status | Should -Be 'Degraded'
$result.Uptime | Should -Be 95.0
}

It '不应写入信息级别的日志' {
Get-SystemHealthStatus | Out-Null

Should -Invoke Write-EventLog -Times 0 -Exactly `
-ParameterFilter { $EntryType -eq 'Information' }
}
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Describing Get-SystemHealthStatus
Context 配置文件存在且 API 返回健康状态
[+] 应返回 Healthy 状态 24ms
[+] 应从配置文件读取超时设置 18ms
[+] 应写入信息级别的事件日志 12ms
Context 配置文件不存在时使用默认值
[+] 不应调用 Get-Content 9ms
[+] 仍应正常完成健康检查 15ms
Context API 请求失败时的错误处理
[+] 应返回 Error 状态 11ms
[+] 应写入错误级别的事件日志 8ms
[+] 错误信息应包含异常详情 7ms
Context API 返回降级状态
[+] Uptime 低于阈值时应返回 Degraded 状态 13ms
[+] 不应写入信息级别的日志 6ms
Tests passed: 10, Failed: 0, Skipped: 0, NotRun: 0

这段测试的核心价值在于:整个测试过程中没有真正的文件 I/O、网络请求或事件日志写入。Mock Get-Content 拦截了文件读取操作,返回预设的 JSON 字符串;Mock Invoke-RestMethod 模拟了 API 响应,可以自由控制返回值甚至抛出异常;Mock Write-EventLog 阻止了日志写入,同时可以通过 Should -Invoke 验证日志是否被正确调用。-ParameterFilter 参数进一步精确匹配调用参数,确保函数在正确的场景下调用了正确的日志级别和事件 ID。每个 Context 块定义独立的 Mock 行为,场景之间完全隔离,不会互相干扰。

实战:测试覆盖率与 CI 流水线集成

在团队协作中,仅编写测试是不够的,还需要量化测试质量并将其纳入持续集成(CI)流程。Pester 支持生成测试覆盖率报告(CodeCoverage)和 JUnit/XML 格式的测试结果,这些输出可以被 Azure DevOps、GitHub Actions、Jenkins 等 CI 平台直接解析和展示。以下代码演示了如何在 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
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
85
86
87
88
89
90
# 文件:run-tests.ps1 —— CI 流水线中的 Pester 运行脚本
# 用法:./run-tests.ps1 -CodeCoverageThreshold 80

param(
[double]$CodeCoverageThreshold = 80,
[string]$TestPath = './tests',
[string]$SourcePath = './src',
[string]$OutputPath = './test-results'
)

# 确保输出目录存在
if (-not (Test-Path $OutputPath)) {
New-Item -Path $OutputPath -ItemType Directory | Out-Null
}

# 查找所有测试文件和源代码文件
$testFiles = Get-ChildItem -Path $TestPath -Filter '*.Tests.ps1' -Recurse
$sourceFiles = Get-ChildItem -Path $SourcePath -Filter '*.ps1' -Recurse |
Where-Object { $_.Name -notlike '*.Tests.ps1' }

Write-Host "发现 $($testFiles.Count) 个测试文件"
Write-Host "发现 $($sourceFiles.Count) 个源代码文件"
Write-Host "覆盖率阈值: $CodeCoverageThreshold%"

# 配置并执行 Pester 测试
$pesterConfig = New-PesterConfiguration

# 基本设置
$pesterConfig.Run.Path = $TestPath
$pesterConfig.Run.PassThru = $true
$pesterConfig.Run.Exit = $true

# 测试结果输出(JUnit XML 格式,CI 平台通用)
$pesterConfig.TestResult.Enabled = $true
$pesterConfig.TestResult.OutputPath = Join-Path $OutputPath 'test-results.xml'
$pesterConfig.TestResult.OutputFormat = 'JUnitXml'

# 代码覆盖率设置
$pesterConfig.CodeCoverage.Enabled = $true
$pesterConfig.CodeCoverage.Path = $sourceFiles.FullName
$pesterConfig.CodeCoverage.OutputPath = Join-Path $OutputPath 'coverage.xml'
$pesterConfig.CodeCoverage.OutputFormat = 'JaCoCo'
$pesterConfig.CodeCoverage.CoveragePercentTarget = $CodeCoverageThreshold

# 输出设置
$pesterConfig.Output.Verbosity = 'Detailed'

# 执行测试
Write-Host "`n===== 开始执行 Pester 测试 =====`n"
$result = Invoke-Pester -Configuration $pesterConfig

# 输出汇总信息
Write-Host "`n===== 测试汇总 ====="
Write-Host "总测试数: $($result.TotalCount)"
Write-Host "通过: $($result.PassedCount)"
Write-Host "失败: $($result.FailedCount)"
Write-Host "跳过: $($result.SkippedCount)"

if ($result.CodeCoverage) {
$coveragePercent = [math]::Round($result.CodeCoverage.CoveragePercent, 2)
Write-Host "`n===== 覆盖率报告 ====="
Write-Host "已覆盖命令: $($result.CodeCoverage.CoveredCommands.Count)"
Write-Host "未覆盖命令: $($result.CodeCoverage.MissedCommands.Count)"
Write-Host "覆盖率: $coveragePercent%"
Write-Host "目标阈值: $CodeCoverageThreshold%"

if ($coveragePercent -ge $CodeCoverageThreshold) {
Write-Host '覆盖率检查: PASS' -ForegroundColor Green
} else {
Write-Host "覆盖率检查: FAIL (差 $($CodeCoverageThreshold - $coveragePercent) 个百分点)" `
-ForegroundColor Red
}

# 列出覆盖率最低的文件(帮助开发者优先补充测试)
Write-Host "`n===== 需要优先补充测试的文件 ====="
$missedByFile = $result.CodeCoverage.MissedCommands |
Group-Object -Property File |
Sort-Object Count -Descending |
Select-Object -First 5

foreach ($entry in $missedByFile) {
$missed = $entry.Count
$fileName = Split-Path $entry.Name -Leaf
Write-Host " $fileName - $missed 条未覆盖命令"
}
}

Write-Host "`n测试结果已输出到: $OutputPath"
Write-Host " test-results.xml (JUnit 格式,供 CI 平台解析)"
Write-Host " coverage.xml (JaCoCo 格式,供覆盖率工具分析)"

执行结果示例:

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
发现 12 个测试文件
发现 8 个源代码文件
覆盖率阈值: 80%

===== 开始执行 Pester 测试 =====

Describing Get-SystemHealthStatus
Context 配置文件存在且 API 返回健康状态
[+] 应返回 Healthy 状态 24ms
[+] 应从配置文件读取超时设置 18ms
[+] 应写入信息级别的事件日志 12ms
Context 配置文件不存在时使用默认值
[+] 不应调用 Get-Content 9ms
[+] 仍应正常完成健康检查 15ms
Context API 请求失败时的错误处理
[+] 应返回 Error 状态 11ms
[+] 应写入错误级别的事件日志 8ms
[+] 错误信息应包含异常详情 7ms
Context API 返回降级状态
[+] Uptime 低于阈值时应返回 Degraded 状态 13ms
[+] 不应写入信息级别的日志 6ms
Describing ConvertTo-TitleCase
[+] 应将英文单词转为首字母大写 28ms
[+] 应处理全大写输入 9ms
[+] 空字符串应返回空字符串 4ms
[+] 空白字符串应返回空字符串 3ms

===== 测试汇总 =====
总测试数: 14
通过: 14
失败: 0
跳过: 0

===== 覆盖率报告 =====
已覆盖命令: 87
未覆盖命令: 12
覆盖率: 87.88%
目标阈值: 80%
覆盖率检查: PASS

===== 需要优先补充测试的文件 =====
utils-network.ps1 - 5 条未覆盖命令
utils-logging.ps1 - 4 条未覆盖命令
utils-config.ps1 - 3 条未覆盖命令

测试结果已输出到: ./test-results
test-results.xml (JUnit 格式,供 CI 平台解析)
coverage.xml (JaCoCo 格式,供覆盖率工具分析)

这段脚本展示了完整的 CI 集成方案。New-PesterConfiguration 创建了一个可编程的配置对象,比命令行参数更灵活。CodeCoverage.Path 指定需要统计覆盖率的源代码文件(而非测试文件),CoveragePercentTarget 设定覆盖率阈值,低于该值时 Exit = $true 会使进程以非零退出码退出,从而让 CI 流水线标记为失败。测试结果以 JUnit XML 格式输出,这是几乎所有 CI 平台都支持的标准格式;覆盖率报告以 JaCoCo 格式输出,可以被 ReportGenerator 等工具转换为可读的 HTML 报告。脚本末尾还智能地列出了覆盖率最低的文件,帮助开发者有针对性地补充测试。

注意事项

  1. Mock 的作用域遵循层级隔离规则。在 Context A 中定义的 Mock 不会影响 Context B。子块可以覆盖父块的 Mock,离开子块后自动恢复父块的 Mock。利用这一特性可以在父 Describe 中定义通用 Mock,在子 Context 中按需覆盖特定行为,减少重复代码。

  2. Should -Invoke 的计数默认限定在当前 It 块所在作用域。Pester v5 中调用次数的统计范围是当前 ContextDescribe。如果需要在更大范围统计,使用 -Scope 参数(如 -Scope Describe)指定统计层级。不注意作用域可能导致断言次数与预期不符。

  3. 覆盖率统计只计算被测试实际执行的代码路径。100% 的命令覆盖率不等于 100% 的分支覆盖率。如果函数中有 if/else 分支但测试只覆盖了 if 分支,未覆盖的 else 分支中的命令会被标记为未覆盖。对于复杂条件逻辑,建议设计多个测试用例分别覆盖不同分支。

  4. 避免过度 Mock 导致测试失去实际意义。如果被测函数的每个内部调用都被 Mock 掉了,测试实际上只是在验证 Mock 框架本身。原则是只 Mock 外部边界(网络、文件、数据库),内部业务逻辑不应被 Mock。同时,过度使用 Should -Invoke 会让测试与实现细节强耦合,重构函数时测试会大面积失败。

  5. Pester v5 与 v4 的语法差异需要注意。v5 中 Assert-MockCalled 已被 Should -Invoke 取代,Assert-VerifiableMock 不再需要。Invoke-Pester 的参数也发生了重大变化,v5 推荐使用 New-PesterConfiguration 创建配置对象。如果项目中有遗留的 v4 测试代码,建议使用 Invoke-Pester -EnableExit 兼容模式逐步迁移。

  6. **CI 流水线中务必设置 Exit = $true**。这确保当测试失败或覆盖率不达标时,Pester 进程以非零退出码退出,CI 平台才能正确识别构建失败。否则即使所有测试都失败了,流水线仍会显示为绿色通过,失去持续集成的意义。同时建议将测试结果文件作为 CI 构建产物(artifact)归档,方便团队查看历史趋势。

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 等工具实现