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 外部依赖使测试快速稳定。真实环境的测试放在单独的集成测试中