目录

Go 测试进阶:gomock、testcontainers 与 Fuzzing

前言

测试是后端工程质量的生命线。2022 年的 Go 生态中,三个测试工具/特性格外引人注目:gomock(接口 mock 的最佳实践)、testcontainers(真正的集成测试)、原生 Fuzzing(Go 1.18 里程碑特性)。

一、gomock:优雅的接口 Mock

安装与生成

go install github.com/golang/mock/mockgen@v1.6.0
# 为 UserRepository 接口生成 mock
mockgen -source=repository.go -destination=mock/mock_repository.go -package=mock

定义接口

// repository.go
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
    List(ctx context.Context, page, size int) ([]User, int64, error)
}

在测试中使用 Mock

// service_test.go
func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mock.NewMockUserRepository(ctrl)
    svc := NewUserService(mockRepo)

    expectedUser := &User{ID: 1, Name: "张三"}

    // 设置期望调用
    mockRepo.EXPECT().
        GetByID(gomock.Any(), int64(1)).
        Return(expectedUser, nil).
        Times(1)

    user, err := svc.GetUser(context.Background(), 1)
    assert.NoError(t, err)
    assert.Equal(t, "张三", user.Name)
}

常用匹配器

// 任意参数
mockRepo.EXPECT().GetByID(gomock.Any(), gomock.Any())

// 特定条件
mockRepo.EXPECT().GetByID(gomock.Any(), gomock.Eq(int64(100)))

// 自定义匹配
mockRepo.EXPECT().Save(gomock.Any(), gomock.AssignableToTypeOf(&User{}))

// 调用顺序
gomock.InOrder(
    mockRepo.EXPECT().GetByID(...),
    mockRepo.EXPECT().Save(...),
)

二、testcontainers:真正的集成测试

testcontainers-go 让你在测试中启动真实的 Docker 容器(MySQL、Redis、Kafka 等),用完自动销毁。

安装

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/mysql

MySQL 集成测试

func TestUserRepository_MySQL(t *testing.T) {
    ctx := context.Background()

    // 启动 MySQL 容器
    mysql, err := mysql.RunContainer(ctx,
        testcontainers.WithImage("mysql:8.0"),
        mysql.WithDatabase("testdb"),
        mysql.WithUsername("root"),
        mysql.WithPassword("password"),
    )
    require.NoError(t, err)
    defer mysql.Terminate(ctx)

    // 获取连接地址
    host, err := mysql.Host(ctx)
    port, err := mysql.MappedPort(ctx, "3306/tcp")

    dsn := fmt.Sprintf("root:password@tcp(%s:%s)/testdb?charset=utf8mb4", host, port.Port())
    db, err := sql.Open("mysql", dsn)
    require.NoError(t, err)

    // 执行测试
    repo := NewUserRepository(db)
    users, err := repo.List(ctx, 1, 10)
    assert.NoError(t, err)
    t.Logf("查询到 %d 个用户", len(users))
}

Redis 集成测试

func TestRedisCache(t *testing.T) {
    ctx := context.Background()

    redis, err := redis.RunContainer(ctx,
        testcontainers.WithImage("redis:7-alpine"),
    )
    require.NoError(t, err)
    defer redis.Terminate(ctx)

    addr, _ := redis.Endpoint(ctx, "redis://")
    client := redisClient.NewClient(&redisClient.Options{Addr: addr})

    err = client.Set(ctx, "key", "value", time.Hour).Err()
    assert.NoError(t, err)

    val, _ := client.Get(ctx, "key").Result()
    assert.Equal(t, "value", val)
}

最佳实践:testcontainers 很适合 CI 环境,GitHub Actions 中只要 Docker 可用就能跑。本地开发建议配合 -short 标记区分单元测试和集成测试。

三、Go 原生 Fuzzing(Go 1.18)

Fuzzing(模糊测试)是 Go 1.18 引入的原生特性,它能自动生成随机输入来测试代码,发现边界条件和安全漏洞。

基础用法

// 需要测试的函数
func ParsePhone(phone string) (bool, error) {
    if len(phone) != 11 {
        return false, fmt.Errorf("invalid length")
    }
    for _, c := range phone {
        if c < '0' || c > '9' {
            return false, fmt.Errorf("invalid char: %c", c)
        }
    }
    return true, nil
}

// Fuzz 测试函数(必须以 FuzzXxx 命名)
func FuzzParsePhone(f *testing.F) {
    // 种子语料库
    f.Add("13800138000")
    f.Add("12345678901")
    f.Add("hello")

    f.Fuzz(func(t *testing.T, phone string) {
        result, err := ParsePhone(phone)
        if len(phone) == 11 && isAllDigit(phone) {
            assert.True(t, result)
            assert.NoError(t, err)
        }
    })
}

运行 Fuzz 测试:

go test -fuzz=FuzzParsePhone -fuzztime=30s

真实案例:JSON 解析边界测试

func FuzzJSONUnmarshal(f *testing.F) {
    testcases := []string{
        `{"name":"test"}`,
        `{"age": 25}`,
        `{"data": null}`,
    }
    for _, tc := range testcases {
        f.Add(tc)
    }

    f.Fuzz(func(t *testing.T, input string) {
        var data map[string]interface{}
        err := json.Unmarshal([]byte(input), &data)
        // 有错误是正常的,但不能 panic
        if err != nil {
            return
        }
        // 验证反序列化的结果
        for k, v := range data {
            assert.NotEmpty(t, k)
            switch val := v.(type) {
            case float64:
                assert.False(t, math.IsNaN(val))
            }
        }
    })
}

HTTP 处理器 Fuzzing

func FuzzUserHandler(f *testing.F) {
    f.Add("id=1")
    f.Add("id=abc")
    f.Add("name=张三&age=25")

    f.Fuzz(func(t *testing.T, query string) {
        req := httptest.NewRequest("GET", "/user?"+query, nil)
        w := httptest.NewRecorder()

        // Handler 内部不能 panic
        assert.NotPanics(t, func() {
            UserHandler(w, req)
        })
    })
}

构建一个完整的测试金字塔

// 从下往上:单元测试 → 集成测试 → Fuzzing
func TestUserService(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过集成测试")
    }

    t.Run("Unit-Mock", func(t *testing.T) {
        // 用 gomock 测试 service 逻辑
    })

    t.Run("Integration-MySQL", func(t *testing.T) {
        // 用 testcontainers 测试数据库交互
    })

    t.Run("Fuzz-Parse", func(t *testing.T) {
        // 用 Fuzzing 测试输入边界
    })
}
# 只跑单元测试
go test -short ./...

# 跑所有测试(含集成)
go test ./...

# Fuzzing 30 秒
go test -fuzz=./... -fuzztime=30s

总结

2022 年的 Go 测试生态已经足够成熟:

工具/特性 用途 推荐度
gomock 接口 Mock,隔离依赖 ⭐⭐⭐⭐⭐
testcontainers 真实容器集成测试 ⭐⭐⭐⭐
原生 Fuzzing 边界发现、安全测试 ⭐⭐⭐⭐⭐

写好测试的核心理念:单元测试保证逻辑正确,集成测试保证交互可靠,Fuzzing 保证边界安全,三者缺一不可。