Skip to content
Go back

Go Test 进阶实战:从 MIT 6.824 Raft 实验学到的测试技巧

Edit page

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()
}

关键点:

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() })
    // ...
}

两者的区别:

特性defert.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)

    // 验证结果...
}

关键点:

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 的优势:

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 loopbreak 的用法:

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 最佳实践

  1. 始终使用 defer 清理资源:即使测试失败也要清理
  2. 使用辅助函数复用逻辑:避免重复代码
  3. 处理并发不确定性:使用重试机制
  4. 原子操作控制流程:轻量且线程安全
  5. 清晰的测试命名:反映测试意图和范围

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)
}

示例测试规则:

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
-jsonJSON 格式输出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 测试代码展示了如何编写健壮的分布式系统测试。关键技巧包括:

这些技巧不仅适用于分布式系统测试,也可以应用到任何需要并发测试的场景中。

参考资料


Edit page
Share this post on:

Previous Post
Go Map 底层实现深度解析:Go 1.23 vs Go 1.24
Next Post
Go Channel 底层实现深度解析:从源码看协程通信机制