第一章:Go测试工程化核心理念与演进路径
Go语言自诞生起便将测试能力深度融入工具链,go test 不是插件而是原生命令,这种“测试即基础设施”的设计哲学奠定了工程化演进的底层基调。随着微服务架构普及与CI/CD流水线成熟,Go测试已从单点验证跃迁为质量门禁、可观测性入口和开发者体验核心组件。
测试驱动的可维护性保障
Go强调接口抽象与组合优于继承,这天然适配测试桩(mock)与依赖注入。通过 io.Reader/io.Writer 等标准接口解耦,可轻松替换真实依赖为内存实现(如 bytes.Buffer),无需第三方mock框架即可完成单元测试:
func ProcessData(r io.Reader, w io.Writer) error {
data, _ := io.ReadAll(r)
_, err := w.Write(bytes.ToUpper(data))
return err
}
// 测试用例:使用内存对象替代文件或网络IO
func TestProcessData(t *testing.T) {
input := strings.NewReader("hello")
var output bytes.Buffer
err := ProcessData(input, &output)
if err != nil {
t.Fatal(err)
}
if got := output.String(); got != "HELLO" {
t.Errorf("expected HELLO, got %s", got)
}
}
工程化分层实践
现代Go项目普遍采用三层测试结构,各层目标与执行频率明确:
| 层级 | 覆盖范围 | 执行频率 | 典型工具 |
|---|---|---|---|
| 单元测试 | 单个函数/方法 | 每次提交 | go test -short |
| 集成测试 | 模块间协作(DB/HTTP) | PR阶段 | go test -tags=integration |
| 端到端测试 | 全链路业务流 | 每日构建 | ginkgo 或自定义CLI驱动 |
可观测性内建机制
go test 原生支持 -coverprofile 生成覆盖率数据,配合 go tool cover 可快速定位未覆盖逻辑分支。在CI中强制覆盖率阈值已成为主流实践:
# 生成HTML覆盖率报告并检查最低阈值
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out | grep "total:" | awk '{if ($3 < 80) exit 1}'
第二章:基础测试结构与最佳实践
2.1 Go testing.T 与 testing.B 的生命周期与状态管理
testing.T 和 testing.B 并非普通结构体,而是由 go test 运行时动态管理的有状态上下文对象,其生命周期严格绑定于单个测试/基准函数的执行周期。
生命周期阶段
- 创建:测试函数入口前由
testRunner实例化,携带唯一name、parent及并发控制字段 - 活跃:执行期间可调用
t.Fatal()、b.ResetTimer()等方法改变内部状态(如failed,done,timerOn) - 终止:函数返回或显式调用
t.FailNow()后,状态被冻结,后续调用(如t.Log())被静默丢弃
状态字段关键含义
| 字段 | 类型 | 说明 |
|---|---|---|
ch |
chan bool |
用于同步子测试完成(t.Run 内部) |
deadline |
time.Time |
由 -timeout 设置,超时自动 FailNow |
benchTime |
time.Duration |
testing.B 特有,控制基准循环时长 |
func TestLifecycle(t *testing.T) {
t.Log("stage: active") // ✅ 允许
go func() {
t.Log("concurrent log") // ⚠️ 竞态:t 已在主 goroutine 结束后被回收
}()
}
此代码中
t.Log在 goroutine 中调用存在竞态:testing.T不是线程安全的,且其底层ch通道在测试函数返回后已关闭,导致日志丢失或 panic。Go 测试框架禁止跨 goroutine 持有*T引用。
graph TD
A[Start Test] --> B[New T/B instance]
B --> C{Run fn body}
C -->|t.FailNow/b.StopTimer| D[Set failed/timerOn=false]
C -->|return| E[Free resources]
D --> E
2.2 表驱动测试(Table-Driven Tests)的范式重构与边界覆盖实践
表驱动测试将测试用例与执行逻辑解耦,以结构化数据驱动验证流程,显著提升可维护性与边界覆盖密度。
核心结构演进
- 从硬编码断言 → 映射到结构体切片
- 从单点校验 → 统一
t.Run()子测试命名与错误定位 - 从遗漏边界 → 显式枚举
nil、空字符串、最大整数等边缘值
示例:URL路径规范化测试
func TestNormalizePath(t *testing.T) {
tests := []struct {
name string // 子测试名,用于精准定位
input string // 待测原始路径
expected string // 期望标准化结果
}{
{"empty", "", "/"},
{"root", "/", "/"},
{"double-slash", "//api//v1//", "/api/v1/"},
{"trailing-dot", "/a/./b", "/a/b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizePath(tt.input); got != tt.expected {
t.Errorf("NormalizePath(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
该代码将输入/输出/语义名称封装为结构体切片,t.Run 为每个用例创建独立上下文,错误信息自动携带 tt.name 和参数快照,便于快速归因。NormalizePath 函数需处理路径折叠、冗余分隔符及相对符号(./..),此处仅验证其契约一致性。
| 边界类型 | 示例输入 | 覆盖意图 |
|---|---|---|
| 空值 | "" |
初始化与默认行为 |
| 结构退化 | "//" |
多重分隔符归一化能力 |
| 路径遍历符号 | "/a/../b" |
安全性与语义正确性 |
graph TD
A[定义测试表] --> B[遍历结构体切片]
B --> C{调用被测函数}
C --> D[比较实际 vs 期望]
D --> E[失败时打印带上下文的错误]
2.3 测试辅助函数设计:避免 test helper 泄漏与 t.Helper() 深度应用
测试辅助函数若未显式声明为 helper,会导致 t.Errorf 等失败信息定位到辅助函数内部,而非真实调用处——这是典型的“test helper 泄漏”。
正确声明 helper 的必要性
func mustParseTime(t *testing.T, s string) time.Time {
t.Helper() // 关键:告知 testing 包此函数不参与错误行号追溯
tm, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("invalid time string %q: %v", s, err)
}
return tm
}
<t.Helper()> 告诉测试框架跳过该函数帧,使 t.Fatal 的文件/行号指向调用者(如 TestOrderProcessing),而非本函数内部。
常见陷阱对比
| 场景 | 是否调用 t.Helper() |
错误定位目标 |
|---|---|---|
| 辅助函数内未声明 | ❌ | 辅助函数内部行号 |
显式调用 t.Helper() |
✅ | 调用该辅助函数的测试用例行号 |
多层嵌套时的行为
func assertOrderValid(t *testing.T, o Order) {
t.Helper()
if o.ID == "" {
t.Error("missing ID") // 错误归属到调用 assertOrderValid 的位置
}
}
graph TD A[TestOrderProcessing] –> B[assertOrderValid] B –> C[mustParseTime] C –> D[t.Error] style D stroke:#f66,stroke-width:2px classDef helper fill:#e6f7ff,stroke:#1890ff; class B,C helper;
2.4 子测试(Subtests)的分层组织与并行控制策略
子测试通过 t.Run() 构建嵌套命名空间,天然支持逻辑分层与独立生命周期管理。
分层组织示例
func TestAuthFlow(t *testing.T) {
t.Run("valid_token", func(t *testing.T) {
t.Run("with_scopes", func(t *testing.T) { /* ... */ })
t.Run("without_scopes", func(t *testing.T) { /* ... */ })
})
t.Run("invalid_token", func(t *testing.T) { /* ... */ })
}
Run() 第一个参数为子测试名称,形成层级路径(如 valid_token/with_scopes),便于过滤(-run "valid_token/")和结果归因;每个子测试拥有独立的 *testing.T 实例,互不干扰。
并行控制策略
- 子测试默认串行执行
- 显式调用
t.Parallel()启用并行(仅对同级子测试生效) - 父测试调用
t.Parallel()无效,需在子测试内部声明
| 控制方式 | 作用范围 | 是否继承父级并行 |
|---|---|---|
t.Parallel() |
当前子测试 | 否 |
-p=4 CLI 参数 |
全局并发数上限 | 是 |
graph TD
A[Root Test] --> B[Subtest A]
A --> C[Subtest B]
B --> B1[Parallel Sub-B1]
B --> B2[Parallel Sub-B2]
C --> C1[Serial Sub-C1]
2.5 测试覆盖率精准采集:go test -coverprofile 与行级覆盖率陷阱规避
Go 的 go test -coverprofile 是覆盖率采集核心,但默认行级统计存在语义盲区——如 if cond { return } else { return } 中的 else 分支可能被标记为“已覆盖”,实则未执行逻辑体。
行级覆盖的典型误判场景
func isEven(n int) bool {
if n%2 == 0 { // ✅ 覆盖
return true // ✅ 覆盖
} else { // ⚠️ 行被标记“覆盖”,但仅因 if 语句执行,非 else 块执行
return false // ❌ 若 n 恒为偶数,则此行永不执行
}
}
-coverprofile 统计的是 源码行是否被解析执行,而非 分支逻辑是否真实进入。该行为易导致高覆盖率假象。
规避策略清单
- 使用
-covermode=count替代默认set模式,获取每行执行次数; - 结合
go tool cover -func=coverage.out定位零执行频次的“伪覆盖”行; - 对
if/else、switch等控制流语句,强制编写反例测试用例。
| 模式 | 覆盖判定粒度 | 是否暴露伪覆盖 |
|---|---|---|
set(默认) |
行是否被执行过 | 否 |
count |
行被执行的具体次数 | 是 |
atomic |
并发安全计数 | 是 |
graph TD
A[go test -coverprofile] --> B{covermode}
B -->|set| C[布尔标记:行是否触达]
B -->|count| D[整数计数:行执行频次]
D --> E[筛选 count==0 的“幽灵覆盖行”]
第三章:依赖解耦与测试替身工程
3.1 接口抽象与依赖倒置在测试中的落地:从 mock 到 fake 的选型决策
接口抽象是依赖倒置(DIP)的基石——它让测试不再绑定具体实现,而是面向契约协作。
测试替身光谱
- Mock:验证交互行为(如调用次数、参数断言),适合协议校验
- Fake:提供轻量真实逻辑(如内存数据库),适合状态驱动场景
- Stub:仅返回预设值,适用于隔离外部副作用
选型决策表
| 维度 | Mock | Fake |
|---|---|---|
| 状态一致性 | ❌(无状态) | ✅(可读写) |
| 实现复杂度 | 低(框架生成) | 中(需维护简易实现) |
| 适用测试类型 | 单元测试(行为) | 集成/端到端(流程) |
# Fake 数据库实现(内存版)
class FakeUserRepo(UserRepository):
def __init__(self):
self._users = {} # 模拟持久化状态
def save(self, user: User) -> None:
self._users[user.id] = user # 真实赋值逻辑
def find_by_id(self, user_id: str) -> Optional[User]:
return self._users.get(user_id) # 可被其他测试用例观测
该 FakeUserRepo 保留了 UserRepository 接口契约,支持跨测试用例的状态传递;_users 字典模拟真实存储行为,使「注册→查询」链路可端到端验证。
graph TD
A[被测服务] -->|依赖| B[UserRepository]
B --> C{测试替身选择}
C --> D[Mock:验证save被调用1次]
C --> E[Fake:验证find_by_id返回非None]
3.2 HTTP 客户端/服务端测试:httptest.Server 与 httpmock 的生产级组合用法
在真实微服务场景中,单一测试工具难以覆盖全链路验证需求:httptest.Server 擅长模拟可控服务端行为,而 httpmock 则专精于拦截并断言外部依赖调用。
场景分层测试策略
- 单元级:用
httptest.NewUnstartedServer预配置 handler,精确控制响应头、状态码与延迟 - 集成级:启用
httpmock.Activate()拦截第三方 API,避免网络依赖与限流干扰 - 回归保障:组合二者——本地服务调用外部 API 时,同时验证内部逻辑 和 外部请求合规性
典型组合代码示例
func TestPaymentFlow(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// 模拟支付网关响应
httpmock.RegisterResponder("POST", "https://api.pay.example/v1/charge",
httpmock.NewStringResponder(200, `{"id":"ch_123","status":"succeeded"}`))
// 启动被测服务(含真实 HTTP 客户端)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp, _ := http.DefaultClient.Post("https://api.pay.example/v1/charge", "application/json", strings.NewReader(`{}`))
io.Copy(w, resp.Body)
}))
defer srv.Close()
// 发起测试请求
res, _ := http.Get(srv.URL + "/checkout")
assert.Equal(t, 200, res.StatusCode)
}
此代码构建了“被测服务 → 拦截的外部依赖”闭环。
httpmock.RegisterResponder指定协议、路径与响应体;httptest.Server提供可访问的本地端点;defer srv.Close()确保资源释放。二者协同实现无副作用、高覆盖率的端到端验证。
| 工具 | 核心能力 | 适用阶段 |
|---|---|---|
httptest.Server |
启动轻量 HTTP 服务,可控 handler | 服务端逻辑 |
httpmock |
替换 http.Transport,拦截请求 |
客户端依赖 |
| 组合使用 | 验证跨服务交互完整性 | 集成测试 |
3.3 数据库测试三重奏:内存 SQLite、testcontainers 与事务回滚模式实战
为什么需要三重策略?
单一对策难以兼顾速度、隔离性与真实性:
- 内存 SQLite 快但不兼容 SQL 方言;
- testcontainers 真实但启动慢;
- 事务回滚轻量却依赖框架事务支持。
各方案适用场景对比
| 方案 | 启动耗时 | 隔离粒度 | 兼容性 | 适用阶段 |
|---|---|---|---|---|
| 内存 SQLite | 进程级 | 低(无外键/触发器) | 单元测试快速验证 | |
| testcontainers | ~800ms | 容器级 | 高(真实 PostgreSQL) | 集成测试关键路径 |
| 事务回滚(Spring) | 方法级 | 中(需 @Transactional) |
服务层逻辑测试 |
@SpringBootTest
@Transactional // 启用方法级事务回滚
class UserServiceTest {
@Test
void shouldCreateUserAndRollback() {
userRepository.save(new User("test@example.com"));
assertThat(userRepository.count()).isEqualTo(1L);
// 测试结束自动 rollback,无需清理
}
}
逻辑分析:
@Transactional在测试方法前后开启/回滚事务,避免数据污染;@SpringBootTest提供完整上下文,但需确保spring.test.transaction.rollback未被禁用(默认启用)。
混合策略流程图
graph TD
A[测试启动] --> B{测试类型?}
B -->|快速验证| C[内存 SQLite]
B -->|SQL 兼容性要求高| D[testcontainers]
B -->|服务逻辑为主| E[事务回滚]
C & D & E --> F[统一断言层]
第四章:高级测试场景建模与稳定性保障
4.1 并发安全测试:race detector 集成、goroutine 泄漏检测与 sync.WaitGroup 断言
并发程序的可靠性依赖于三重验证:数据竞争、资源生命周期与同步原语行为。
数据同步机制
启用 -race 编译标志可注入内存访问跟踪逻辑:
go test -race ./...
该标志在运行时记录所有 goroutine 对共享变量的读/写操作序对,冲突时输出精确栈帧与时间戳。
Goroutine 泄漏识别
通过 runtime.NumGoroutine() 在测试前后断言数量守恒:
before := runtime.NumGoroutine()
// 执行并发逻辑
after := runtime.NumGoroutine()
if after > before+1 { // 允许主 goroutine + 1 个测试协程波动
t.Fatal("goroutine leak detected")
}
WaitGroup 行为断言
| 场景 | WaitGroup 计数预期 | 风险类型 |
|---|---|---|
| 正常完成 | 归零 | ✅ 安全 |
| 忘记 Done() | 持续 >0 | ❌ 死锁 |
| 多次 Done() | 负值 panic | ❌ 运行时崩溃 |
graph TD
A[启动测试] --> B[记录初始 goroutine 数]
B --> C[执行并发逻辑]
C --> D[检查 WaitGroup 是否归零]
D --> E[比对最终 goroutine 数]
E --> F[报告泄漏或竞争]
4.2 时间敏感逻辑测试:clock mocking 与 time.Now() 可控注入方案
时间敏感逻辑(如过期校验、重试退避、TTL 缓存)在单元测试中极易因真实时钟不可控而产生非确定性失败。
为什么直接调用 time.Now() 难以测试?
time.Now()是纯副作用函数,返回值随系统时钟实时变化;- 测试无法预测或冻结其输出,导致断言失效或需引入
time.Sleep(低效且脆弱)。
推荐解法:依赖注入式 Clock 接口
type Clock interface {
Now() time.Time
}
var DefaultClock Clock = &realClock{}
type realClock struct{}
func (r *realClock) Now() time.Time { return time.Now() }
✅ 逻辑分析:将时间获取抽象为接口,运行时注入
DefaultClock,测试时可替换为MockClock;参数说明:Now()方法无输入,返回可控的time.Time实例,便于模拟任意时刻。
Mock 方案对比
| 方案 | 可控性 | 侵入性 | 适用场景 |
|---|---|---|---|
github.com/benbjohnson/clock |
⭐⭐⭐⭐⭐ | 低(需改接口) | 中大型项目 |
函数变量替换(var now = time.Now) |
⭐⭐ | 高(全局污染) | 快速原型 |
graph TD
A[业务代码] -->|依赖| B[Clock 接口]
B --> C[RealClock]
B --> D[MockClock]
D --> E[SetTime t1]
D --> F[Advance 5s]
4.3 错误传播与重试逻辑验证:自定义 error wrapper 断言与 transient failure 模拟
自定义错误包装器设计
为精准区分瞬时失败(transient failure)与永久性错误,定义 TransientError 包装器:
type TransientError struct {
Err error
Retryable bool
}
func (e *TransientError) Error() string { return e.Err.Error() }
func (e *TransientError) Unwrap() error { return e.Err }
该结构支持 errors.Is() 和 errors.As() 标准断言;Retryable 字段显式声明重试意图,避免魔数判断。
瞬时故障模拟策略
使用 net/http/httptest 注入可控失败率:
| 故障类型 | 触发条件 | 重试建议 |
|---|---|---|
| 连接超时 | http.StatusRequestTimeout |
✅ 推荐 |
| 服务不可用 | http.StatusServiceUnavailable |
✅ 推荐 |
| 内部服务器错误 | http.StatusInternalServerError |
❌ 慎重 |
重试断言验证流程
graph TD
A[发起请求] --> B{响应状态码}
B -->|503/408| C[封装为 TransientError]
B -->|500| D[返回原始 error]
C --> E[断言 errors.As(err, &te) && te.Retryable]
验证时需确保:
errors.As(err, &te)成功提取包装器;te.Retryable == true且底层错误可溯源。
4.4 性能基准测试工程化:BenchTime 调优、内存分配分析(-benchmem)与结果基线比对
BenchTime 的精细化控制
Go 基准测试默认运行至少 1 秒,但高吞吐场景需更短周期以捕获瞬时抖动:
go test -bench=^BenchmarkParseJSON$ -benchtime=100ms -benchmem
-benchtime=100ms 强制单轮测试时长为 100 毫秒,避免长尾延迟掩盖高频调用下的分配热点;-benchmem 启用内存统计,输出 B/op 和 allocs/op。
内存分配深度追踪
启用 -benchmem 后,输出示例: |
Benchmark | Time/ns | B/op | allocs/op |
|---|---|---|---|---|
| BenchmarkParseJSON | 12450 | 896 | 7 |
7 次堆分配暴露了 JSON 解析中临时切片与 map 的冗余创建。
基线比对自动化流程
graph TD
A[基准版本 go test -bench] --> B[提取 ns/op & allocs/op]
C[待测版本 go test -bench] --> B
B --> D[Delta 分析:±5% 触发告警]
第五章:12个生产级_test.go模板与CI验证脚本全景图
核心原则:测试即契约
每个 _test.go 文件必须满足三项硬性约束:① 与被测包同目录;② TestMain 中显式调用 os.Exit(m.Run());③ 所有测试函数名以 Test 开头且接收 *testing.T。以下12个模板均通过 GitHub Actions + Golang 1.22 验证,覆盖从单体服务到微服务边界的全场景。
模板分类与适用场景
| 模板编号 | 场景定位 | 关键特征 | CI触发条件 |
|---|---|---|---|
| T01 | 基础数据结构验证 | 使用 reflect.DeepEqual 对比嵌套 map/slice |
**/*.go 修改且无 //nolint |
| T07 | HTTP Handler集成测试 | httptest.NewRecorder() + http.HandlerFunc |
**/handler/*.go |
| T12 | 跨服务gRPC调用模拟 | grpc-go/testutil + mockserver.Start() |
**/proto/*.pb.go |
T03:带超时控制的数据库事务测试
func TestUserRepository_CreateWithRollback(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 强制在事务中失败以验证回滚
err := db.Transaction(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO users(name) VALUES(?)", "test")
return errors.New("simulated failure") // 触发回滚
})
if err == nil {
t.Fatal("expected error but got nil")
}
// 验证数据未写入
var count int
db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE name = ?", "test").Scan(&count)
if count != 0 {
t.Errorf("expected 0 rows, got %d", count)
}
}
T09:Kubernetes Operator状态机测试
使用 envtest.Environment 启动轻量控制平面,验证 CRD 状态转换逻辑。CI 脚本中通过 kubectl get pod -n test-system 确认 test-env 已就绪后执行 go test -tags=e2e -timeout=120s ./controllers/...。
CI验证脚本核心逻辑
# .github/workflows/test.yml 片段
- name: Run production tests
run: |
go test -v -race -coverprofile=coverage.txt -covermode=atomic \
-tags="!unit,!integration" \
$(go list ./... | grep -v '/vendor/')
# 强制要求覆盖率 ≥85% 否则失败
go tool cover -func=coverage.txt | tail -n +2 | awk 'NF>0 {sum+=$3; cnt++} END {if (sum/cnt < 85) exit 1}'
T05:并发安全Map压力测试
采用 sync.Map 替代原生 map 后,需验证高并发读写一致性。测试启动 100 goroutines 并行执行 LoadOrStore 和 Range,最后校验所有 key 的 value 均为预期值。CI 中设置 GOMAXPROCS=4 复现真实部署环境。
T11:OpenTelemetry Tracing链路验证
在测试中注入 sdktrace.NewTracerProvider(),捕获 span 并断言 span.SpanContext().TraceID() 在上下游间透传,且 status.Code 与 HTTP 状态码严格映射。使用 oteltest.NewExporter() 获取内存中 span 列表。
流程图:CI中测试分层执行策略
graph LR
A[Pull Request] --> B{文件变更类型}
B -->|*.go & no //nolint| C[运行单元测试]
B -->|handler/*.go| D[运行HTTP集成测试]
B -->|proto/*.pb.go| E[运行gRPC端到端测试]
C --> F[覆盖率检查]
D --> F
E --> F
F -->|≥85%| G[合并准入]
F -->|<85%| H[阻断PR]
T02:配置热加载测试
模拟 fsnotify 监听 config.yaml 变更事件,在测试中启动 goroutine 修改文件内容,验证 viper.WatchConfig() 触发回调后 Get("database.host") 返回新值。CI 中通过 touch config.yaml 触发文件系统事件。
T08:Redis缓存穿透防护测试
构造大量不存在的 key(如 user:999999999),验证 cache.GetWithFallback() 是否正确调用 fallback 函数并设置空值缓存(SET user:999999999 "" EX 60)。使用 gomock 模拟 Redis 客户端行为。
T04:第三方API熔断器测试
基于 sony/gobreaker 实现熔断逻辑,测试中连续触发 5 次 cb.Execute() 返回错误,第6次调用应直接返回 cb.ErrOpenState 而不发起真实请求。通过 time.Sleep(60 * time.Second) 等待半开状态切换。
T06:消息队列死信路由验证
在 RabbitMQ 测试容器中声明 orders.dlx 死信交换机,向 orders 队列发送 3 条 TTL=100ms 的消息,验证全部自动路由至 orders.dlq 队列。使用 streadway/amqp 客户端消费并断言消息体。
T10:TLS双向认证握手测试
启动 httptest.UnstartedServer 并配置 tls.Config 启用 ClientAuth: tls.RequireAndVerifyClientCert,测试中使用预生成的 client cert 发起请求,断言 r.TLS.Verified 为 true 且 r.TLS.PeerCertificates[0].Subject.CommonName 匹配预期值。
