1 引言
Go 语言的 testing 包虽然简洁,但功能强大。通过分析 MIT 6.824 分布式系统课程(2022 版)的 Raft 实验测试代码,可以学到很多高级测试技巧。这些技巧不仅适用于分布式系统测试,对日常开发也大有裨益。
本文将深入剖析 6.824-golabs-2022-6.824/src/raft/test_test.go 中使用的测试模式。
2 测试基础规范
2.1 命名约定
Go 测试有严格的命名约定:
源文件: raft.go
测试文件: raft_test.go # 必须以 _test.go 结尾
测试函数: func TestXxx(t *testing.T) # 必须以 Test 开头
基准测试: func BenchmarkXxx(b *testing.B)
示例测试: func ExampleXxx()
MIT 6.824 的测试命名采用了功能+实验编号的方式,这种方式非常便于组织和管理:
// 实验 2A:选举相关测试
func TestInitialElection2A(t *testing.T) { /* ... */ }
func TestReElection2A(t *testing.T) { /* ... */ }
func TestManyElections2A(t *testing.T) { /* ... */ }
// 实验 2B:日志复制相关测试
func TestBasicAgree2B(t *testing.T) { /* ... */ }
func TestFailAgree2B(t *testing.T) { /* ... */ }
func TestConcurrentStarts2B(t *testing.T) { /* ... */ }
2.2 测试函数签名
func TestInitialElection2A(t *testing.T) {
servers := 3
cfg := make_config(t, servers, false, false)
defer cfg.cleanup()
cfg.begin("Test (2A): initial election")
// 测试逻辑...
cfg.end()
}
关键点:
- 参数必须是
t *testing.T - 使用
t.Errorf()/t.Fatalf()报告失败 Fatalf会立即终止当前测试,Errorf会继续执行
3 资源清理:defer 模式
3.1 基本用法
在分布式系统测试中,资源清理尤为重要。MIT 6.824 使用 defer 确保测试环境被正确清理:
func TestInitialElection2A(t *testing.T) {
servers := 3
cfg := make_config(t, servers, false, false)
defer cfg.cleanup() // 无论测试成功或失败,都会执行清理
// ... 测试逻辑
}
3.2 defer 与 t.Cleanup 的对比
Go 1.14 引入了 t.Cleanup(),它也可以注册清理函数:
func TestWithCleanup(t *testing.T) {
cfg := make_config(t, 3, false, false)
t.Cleanup(func() { cfg.cleanup() })
// ...
}
两者的区别:
| 特性 | defer | t.Cleanup() |
|---|---|---|
| 执行时机 | 函数返回时 | 测试及其子测试完成后 |
| 子测试支持 | 每个子测试需要单独 defer | 自动处理子测试清理 |
| 执行顺序 | LIFO(后进先出) | 注册顺序 |
| 适用场景 | 简单资源清理 | 复杂测试层次结构 |
MIT 6.824 选择 defer 是因为测试结构相对简单,且 defer 更直观。
4 测试配置管理
4.1 测试配置结构
Raft 实验使用一个 config 结构来管理整个测试环境:
type config struct {
t *testing.T // 测试上下文
n int // 服务器数量
rafts []*Raft // Raft 实例数组
connected []bool // 网络连接状态
// ... 其他字段
}
func make_config(t *testing.T, n int, unreliable bool, snap bool) *config {
cfg := &config{
t: t,
n: n,
rafts: make([]*Raft, n),
connected: make([]bool, n),
}
// 初始化...
return cfg
}
4.2 测试生命周期
func (cfg *config) begin(description string) {
fmt.Printf("%s ...\n", description)
cfg.t0 = time.Now()
}
func (cfg *config) end() {
cfg.checkTimeout()
fmt.Printf(" ... Passed --\n")
}
每个测试都遵循相同的生命周期:
func TestReElection2A(t *testing.T) {
cfg := make_config(t, 3, false, false)
defer cfg.cleanup()
cfg.begin("Test (2A): election after network failure")
// ... 测试步骤 ...
cfg.end()
}
5 并发测试模式
5.1 WaitGroup 协调
TestConcurrentStarts2B 测试展示了如何测试并发场景:
func TestConcurrentStarts2B(t *testing.T) {
// ...
iters := 5
var wg sync.WaitGroup
is := make(chan int, iters)
for ii := 0; ii < iters; ii++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
i, term1, ok := cfg.rafts[leader].Start(100 + i)
if term1 != term || !ok {
return
}
is <- i
}(ii)
}
wg.Wait()
close(is)
// 验证结果...
}
关键点:
wg.Add(1)在启动 goroutine 前调用defer wg.Done()确保完成时通知wg.Wait()阻塞等待所有 goroutine 完成- 使用带缓冲的 channel 收集结果
5.2 并发结果验证
failed := false
cmds := []int{}
for index := range is {
cmd := cfg.wait(index, servers, term)
if ix, ok := cmd.(int); ok {
if ix == -1 {
failed = true
break
}
cmds = append(cmds, ix)
}
}
// 验证所有命令都被提交
for ii := 0; ii < iters; ii++ {
x := 100 + ii
ok := false
for j := 0; j < len(cmds); j++ {
if cmds[j] == x {
ok = true
}
}
if !ok {
t.Fatalf("cmd %v missing in %v", x, cmds)
}
}
6 原子操作与状态控制
6.1 使用 atomic 控制测试流程
在长时间运行的并发测试中,需要一种机制来安全地停止所有 goroutine:
func internalChurn(t *testing.T, unreliable bool) {
// ...
stop := int32(0) // 原子标志位
// 客户端 goroutine
cfn := func(me int, ch chan []int) {
var ret []int
ret = nil
defer func() { ch <- ret }()
values := []int{}
// 使用原子读取检查是否应该停止
for atomic.LoadInt32(&stop) == 0 {
// 执行操作...
}
ret = values
}
// 启动多个客户端
for i := 0; i < ncli; i++ {
cha = append(cha, make(chan []int))
go cfn(i, cha[i])
}
// 运行测试一段时间...
// 发送停止信号
atomic.StoreInt32(&stop, 1)
// 等待所有客户端完成
for i := 0; i < ncli; i++ {
vv := <-cha[i]
if vv == nil {
t.Fatal("client failed")
}
values = append(values, vv...)
}
}
6.2 atomic 与 channel 的对比
在这个场景中,使用 atomic 比用 close(channel) 更合适:
// 方案 1:atomic(推荐)
stop := int32(0)
for atomic.LoadInt32(&stop) == 0 {
// ...
}
// 方案 2:channel
stopCh := make(chan struct{})
for {
select {
case <-stopCh:
return
default:
// ...
}
}
atomic 的优势:
- 更轻量,无需 channel 开销
- 不需要在循环中使用 select
- 语义更清晰:简单的布尔标志
7 重试机制:处理并发不确定性
7.1 带重试的测试循环
分布式系统测试经常因为时序问题而失败。MIT 6.824 使用重试机制来处理这种不确定性:
var success bool
loop:
for try := 0; try < 5; try++ {
if try > 0 {
// 给系统一些时间稳定
time.Sleep(3 * time.Second)
}
leader := cfg.checkOneLeader()
_, term, ok := cfg.rafts[leader].Start(1)
if !ok {
// leader 已经变了,重试
continue
}
// ... 执行测试逻辑 ...
// 检查 term 是否改变
for j := 0; j < servers; j++ {
if t, _ := cfg.rafts[j].GetState(); t != term {
// term 变了,不能期望测试通过
continue loop // 跳到下一次重试
}
}
// ... 验证结果 ...
success = true
break
}
if !success {
t.Fatalf("term changed too often")
}
7.2 标签跳转(labeled break/continue)
注意 continue loop 和 break 的用法:
loop:
for try := 0; try < 5; try++ {
// ...
for j := 0; j < servers; j++ {
if condition {
continue loop // 跳到外层循环的下一次迭代
}
}
break // 成功,退出外层循环
}
这是 Go 语言处理嵌套循环控制流的标准方式。
8 测试辅助函数
8.1 共享测试逻辑
MIT 6.824 将公共测试逻辑抽取为辅助函数:
// 内部测试函数,被多个测试复用
func internalChurn(t *testing.T, unreliable bool) {
// ... 复杂的测试逻辑
}
// 两个公开的测试函数
func TestReliableChurn2C(t *testing.T) {
internalChurn(t, false)
}
func TestUnreliableChurn2C(t *testing.T) {
internalChurn(t, true)
}
8.2 参数化的测试辅助函数
snapcommon 函数展示了如何创建高度可配置的测试辅助函数:
func snapcommon(t *testing.T, name string, disconnect bool, reliable bool, crash bool) {
iters := 30
servers := 3
cfg := make_config(t, servers, !reliable, true)
defer cfg.cleanup()
cfg.begin(name)
// ... 根据 disconnect, crash 等参数执行不同测试逻辑
}
// 多个测试用例
func TestSnapshotBasic2D(t *testing.T) {
snapcommon(t, "Test (2D): snapshots basic", false, true, false)
}
func TestSnapshotInstall2D(t *testing.T) {
snapcommon(t, "Test (2D): install snapshots (disconnect)", true, true, false)
}
func TestSnapshotInstallCrash2D(t *testing.T) {
snapcommon(t, "Test (2D): install snapshots (crash)", false, true, true)
}
9 网络故障模拟
9.1 断开与重连
Raft 测试需要模拟各种网络分区场景:
func TestReElection2A(t *testing.T) {
servers := 3
cfg := make_config(t, servers, false, false)
defer cfg.cleanup()
leader1 := cfg.checkOneLeader()
// 断开 leader
cfg.disconnect(leader1)
cfg.checkOneLeader() // 应该选出新 leader
// 旧 leader 重新加入
cfg.connect(leader1)
leader2 := cfg.checkOneLeader()
// 断开两个节点,破坏多数派
cfg.disconnect(leader2)
cfg.disconnect((leader2 + 1) % servers)
time.Sleep(2 * RaftElectionTimeout)
cfg.checkNoLeader() // 不应该有 leader
// 恢复一个节点,重建多数派
cfg.connect((leader2 + 1) % servers)
cfg.checkOneLeader() // 应该再次选出 leader
}
9.2 图解网络分区测试
Initial State Disconnect Leader New Leader Elected
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ L │ │ F │ │ F │ --> │ L │ │ F │ │ F │ --> │ L │ │ F*│ │ F │
│ 1 │ │ 2 │ │ 3 │ │ 1 │ │ 2 │ │ 3 │ │ 1 │ │ 2 │ │ 3 │
└───┘ └───┘ └───┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘
▲ ▲ ▲ │ └─────┘ │ └─────┘
└─────┴─────┘ X │ ▲ X │ ▲
All connected │ │ │ │
isolated quorum isolated quorum
10 时间控制与超时
10.1 常量定义
const RaftElectionTimeout = 1000 * time.Millisecond
使用常量定义超时值,便于统一管理和调整。
10.2 等待策略
// 等待选举完成
time.Sleep(2 * RaftElectionTimeout)
// 渐进式等待(指数退避)
for _, to := range []int{10, 20, 50, 100, 200} {
nd, cmd := cfg.nCommitted(index)
if nd > 0 {
// 成功提交
break
}
time.Sleep(time.Duration(to) * time.Millisecond)
}
渐进式等待既不会无谓地浪费时间等待,也不会因为等待时间太短而频繁重试。
11 测试技巧总结
11.1 核心模式速查
| 模式 | 用途 | 示例 |
|---|---|---|
defer cfg.cleanup() | 资源清理 | 确保测试环境恢复 |
sync.WaitGroup | 并发协调 | 等待多个 goroutine |
atomic.LoadInt32 | 状态控制 | 安全停止 goroutine |
labeled continue | 跳转控制 | 嵌套循环重试 |
| 渐进式等待 | 超时处理 | 指数退避 |
11.2 最佳实践
- 始终使用 defer 清理资源:即使测试失败也要清理
- 使用辅助函数复用逻辑:避免重复代码
- 处理并发不确定性:使用重试机制
- 原子操作控制流程:轻量且线程安全
- 清晰的测试命名:反映测试意图和范围
12 go test 常用方法
12.1 testing.T 常用方法
testing.T 提供了丰富的测试控制方法:
func TestExample(t *testing.T) {
// 日志输出(需要 -v 标志才会显示)
t.Log("普通日志")
t.Logf("格式化日志: %d", 42)
// 标记失败但继续执行
t.Error("这个测试失败了,但会继续执行")
t.Errorf("期望 %d,实际得到 %d", 10, 20)
// 标记失败并立即终止当前测试
t.Fatal("严重错误,立即停止")
t.Fatalf("致命错误: %v", err)
// 跳过当前测试
t.Skip("跳过原因")
t.Skipf("跳过: %s", reason)
// 并行执行(与其他 t.Parallel() 测试并行)
t.Parallel()
// 注册清理函数(测试结束后执行)
t.Cleanup(func() {
// 清理资源
})
// 获取测试名称
name := t.Name() // 例如: "TestExample"
// 设置子测试的 deadline
t.Deadline()
}
12.2 子测试(Subtests)
使用 t.Run() 创建子测试,便于组织和控制:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
{"zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 子测试可以并行执行
t.Parallel()
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
运行子测试:
# 运行特定子测试
go test -v -run "TestAdd/positive"
go test -v -run "TestAdd/negative"
12.3 跳过测试
根据条件跳过测试:
func TestRequiresNetwork(t *testing.T) {
// 使用 -short 标志时跳过
if testing.Short() {
t.Skip("跳过耗时测试")
}
// 检查环境变量
if os.Getenv("NETWORK_TEST") == "" {
t.Skip("设置 NETWORK_TEST=1 启用网络测试")
}
// 需要网络的测试逻辑
}
# 跳过耗时测试
go test -short
# 启用网络测试
NETWORK_TEST=1 go test -v
12.4 基准测试(Benchmark)
基准测试用于测量代码性能:
func BenchmarkAdd(b *testing.B) {
// b.N 由框架自动调整
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// 基准测试也可以使用子测试
func BenchmarkAddParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Add(2, 3)
}
})
}
testing.B 常用方法:
func BenchmarkExample(b *testing.B) {
// 重置计时器(排除初始化时间)
b.ResetTimer()
// 暂停/恢复计时
b.StopTimer()
// 初始化操作...
b.StartTimer()
// 报告内存分配
b.ReportAllocs()
// 设置自定义指标
b.SetBytes(1024) // 每次操作处理 1KB
for i := 0; i < b.N; i++ {
// 被测代码
}
}
运行基准测试:
# 运行所有基准测试
go test -bench=.
# 运行特定基准测试
go test -bench=BenchmarkAdd
# 显示内存分配统计
go test -bench=. -benchmem
# 指定运行时间
go test -bench=. -benchtime=5s
# 运行多次以获得更准确的结果
go test -bench=. -count=5
12.5 示例测试(Example)
示例测试既是文档,也是可执行的测试:
// 示例会被 go doc 工具提取为文档
func ExampleAdd() {
result := Add(2, 3)
fmt.Println(result)
// Output: 5
}
// 带后缀的示例(用于展示不同用法)
func ExampleAdd_negative() {
result := Add(-1, -1)
fmt.Println(result)
// Output: -2
}
// 无 Output 注释的示例只会编译检查,不会运行
func ExampleAdd_noCheck() {
Add(1, 2)
}
示例测试规则:
- 函数名必须以
Example开头 // Output:注释指定期望输出- 输出必须完全匹配(包括换行)
12.6 Main 测试
自定义测试初始化和清理逻辑:
func TestMain(m *testing.M) {
// 测试前的初始化
fmt.Println("设置测试环境...")
// 运行所有测试
code := m.Run()
// 测试后的清理
fmt.Println("清理测试环境...")
// 退出码传递给 os.Exit
os.Exit(code)
}
12.7 测试覆盖率
测量代码覆盖率:
# 查看覆盖率概要
go test -cover
# 生成覆盖率文件
go test -coverprofile=coverage.out
# 查看函数级别覆盖率
go tool cover -func=coverage.out
# 生成 HTML 报告
go tool cover -html=coverage.out
# 只统计特定包的覆盖率
go test -coverpkg=./pkg/... -coverprofile=coverage.out ./...
12.8 常用命令行标志
| 标志 | 说明 | 示例 |
|---|---|---|
-v | 详细输出 | go test -v |
-run regex | 运行匹配的测试 | go test -run TestAdd |
-bench regex | 运行基准测试 | go test -bench=. |
-benchmem | 显示内存分配 | go test -bench=. -benchmem |
-cover | 启用覆盖率 | go test -cover |
-coverprofile | 覆盖率文件 | go test -coverprofile=c.out |
-race | 竞态检测 | go test -race |
-short | 跳过耗时测试 | go test -short |
-timeout | 超时时间 | go test -timeout 5m |
-count N | 重复运行 | go test -count=100 |
-cpu | 指定 CPU 数 | go test -cpu=1,2,4 |
-parallel N | 并行数 | go test -parallel=4 |
-failfast | 首次失败后停止 | go test -failfast |
-json | JSON 格式输出 | go test -json |
12.9 竞态检测
Go 提供内置的竞态条件检测器:
# 启用竞态检测
go test -race ./...
# 运行特定测试
go test -race -run TestConcurrent
# 构建带竞态检测的程序
go build -race -o myapp
./myapp
注意:竞态检测会显著降低性能(通常 5-10 倍),仅用于开发和测试环境。
13 总结
MIT 6.824 的 Raft 测试代码展示了如何编写健壮的分布式系统测试。关键技巧包括:
- 使用
defer确保资源清理 - 使用
WaitGroup协调并发测试 - 使用
atomic进行轻量级状态控制 - 使用重试机制处理并发不确定性
- 使用辅助函数复用测试逻辑
这些技巧不仅适用于分布式系统测试,也可以应用到任何需要并发测试的场景中。