文章

测试体系

测试体系

测试类型总览

类型命令文件命名函数签名目的
单元测试go testxxx_test.gofunc TestXxx(t *testing.T)验证函数/方法正确性
基准测试go test -benchxxx_test.gofunc BenchmarkXxx(b *testing.B)测量性能
模糊测试go test -fuzzxxx_test.gofunc FuzzXxx(f *testing.F)自动发现边界 case
示例测试go testxxx_test.gofunc ExampleXxx()文档 + 可执行示例
集成测试go test -tags=integrationxxx_test.gofunc TestIntegration(t *testing.T)跨组件验证

单元测试

基本结构

// user_service_test.go
package service

import (
    "context"
    "testing"
)

func TestGetUser(t *testing.T) {
    // 准备
    repo := &MockUserRepo{
        users: map[string]*entity.User{
            "1": {ID: "1", Name: "小徐"},
        },
    }
    svc := NewUserService(repo)

    // 执行
    user, err := svc.GetByID(context.Background(), "1")

    // 断言
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "小徐" {
        t.Errorf("expected name=小徐, got %s", user.Name)
    }
}

表驱动测试(推荐模式)

func TestCreateUser(t *testing.T) {
    tests := []struct {
        name    string
        input   *dto.CreateUserReq
        wantErr bool
        errMsg  string
    }{
        {
            name:    "正常创建",
            input:   &dto.CreateUserReq{Name: "小徐", Age: 28},
            wantErr: false,
        },
        {
            name:    "名字为空",
            input:   &dto.CreateUserReq{Name: "", Age: 28},
            wantErr: true,
            errMsg:  "name is required",
        },
        {
            name:    "年龄为负",
            input:   &dto.CreateUserReq{Name: "小徐", Age: -1},
            wantErr: true,
            errMsg:  "age must be positive",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := NewUserService(&MockUserRepo{})
            _, err := svc.Create(context.Background(), tt.input)

            if tt.wantErr {
                if err == nil {
                    t.Fatal("expected error, got nil")
                }
                if !strings.Contains(err.Error(), tt.errMsg) {
                    t.Errorf("error message = %q, want contain %q", err.Error(), tt.errMsg)
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
        })
    }
}

t.Run 子测试

func TestUserValidation(t *testing.T) {
    t.Run("valid_user", func(t *testing.T) {
        // 子测试可以并行
        t.Parallel()
        // ...
    })

    t.Run("invalid_email", func(t *testing.T) {
        t.Parallel()
        // ...
    })

    t.Run("empty_name", func(t *testing.T) {
        t.Parallel()
        // ...
    })
}
# 运行特定子测试
go test -run TestUserValidation/valid_user ./...
go test -run TestUserValidation/invalid_email ./...

测试辅助函数

// t.Helper() 标记辅助函数,失败时报告调用者位置
func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper()
    if !reflect.DeepEqual(got, want) {
        t.Errorf("got %v, want %v", got, want)
    }
}

// 用法
func TestSomething(t *testing.T) {
    assertEqual(t, result, expected)
}

setup / teardown

func TestMain(m *testing.M) {
    // 全局 setup
    setupTestDB()
    setupTestRedis()

    // 运行所有测试
    code := m.Run()

    // 全局 teardown
    teardownTestDB()
    teardownTestRedis()

    os.Exit(code)
}

Mock 与依赖注入

使用接口 + 手写 Mock

// 定义接口
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*entity.User, error)
    Create(ctx context.Context, user *entity.User) error
}

// 手写 Mock
type MockUserRepository struct {
    users  map[string]*entity.User
    saved  []*entity.User
    err    error
}

func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *MockUserRepository) Create(ctx context.Context, user *entity.User) error {
    m.saved = append(m.saved, user)
    return nil
}

使用 mockgen 生成 Mock

//go:generate mockgen -source=user_repository.go -destination=mock_user_repository.go -package=mock

// 使用 testify/mock
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
    args := m.Called(ctx, id)
    return args.Get(0).(*entity.User), args.Error(1)
}

// 测试中使用
func TestGetUser(t *testing.T) {
    mockRepo := new(MockUserRepository)
    mockRepo.On("FindByID", mock.Anything, "1").Return(&entity.User{ID: "1", Name: "小徐"}, nil)

    svc := NewUserService(mockRepo)
    user, err := svc.GetByID(context.Background(), "1")

    assert.NoError(t, err)
    assert.Equal(t, "小徐", user.Name)
    mockRepo.AssertExpectations(t)
}

httptest(HTTP 测试)

func TestUserHandler(t *testing.T) {
    // 准备 handler
    handler := NewUserHandler(mockSvc)

    // 创建测试服务器
    ts := httptest.NewServer(handler)
    defer ts.Close()

    // 发送请求
    resp, err := http.Get(ts.URL + "/api/v1/users/1")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()

    // 断言
    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
    }

    var user dto.UserDTO
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        t.Fatal(err)
    }
    if user.Name != "小徐" {
        t.Errorf("name = %s, want 小徐", user.Name)
    }
}

测试 HTTP Handler(不启动服务器)

func TestCreateUserHandler(t *testing.T) {
    body := strings.NewReader(`{"name":"小徐","age":28}`)
    req := httptest.NewRequest("POST", "/api/v1/users", body)
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    handler.CreateUser(w, req)

    resp := w.Result()
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusCreated {
        t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusCreated)
    }
}

基准测试

func BenchmarkStringConcat(b *testing.B) {
    parts := []string{"Hello", " ", "World", "!"}
    b.ResetTimer()  // 排除准备阶段

    for i := 0; i < b.N; i++ {
        _ = strings.Join(parts, "")
    }
}

// 对比不同实现
func BenchmarkStringBuilder(b *testing.B) {
    parts := []string{"Hello", " ", "World", "!"}
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for _, p := range parts {
            sb.WriteString(p)
        }
        _ = sb.String()
    }
}
$ go test -bench=. -benchmem ./...
# 输出示例:
# BenchmarkStringConcat-8       30000000    45.2 ns/op    32 B/op    1 allocs/op
# BenchmarkStringBuilder-8      50000000    28.1 ns/op    24 B/op    1 allocs/op

# 列含义:
# -8                GOMAXPROCS
# 30000000          执行次数
# 45.2 ns/op        每次操作耗时
# 32 B/op           每次操作内存分配
# 1 allocs/op       每次操作分配次数

子基准测试对比

func BenchmarkSort(b *testing.B) {
    sizes := []int{100, 1000, 10000}

    for _, size := range sizes {
        b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
            data := generateRandomSlice(size)
            b.ResetTimer()

            for i := 0; i < b.N; i++ {
                sorted := make([]int, len(data))
                copy(sorted, data)
                sort.Ints(sorted)
            }
        })
    }
}

模糊测试(Go 1.18+)

func FuzzParseURL(f *testing.F) {
    // 种子语料
    f.Add("http://example.com/path")
    f.Add("https://example.com:8080/path?q=1")
    f.Add("ftp://localhost")

    f.Fuzz(func(t *testing.T, raw string) {
        u, err := url.Parse(raw)
        if err != nil {
            return  // 解析失败是正常的
        }

        // 往返测试:解析后再格式化,再解析,结果应一致
        reformatted := u.String()
        u2, err := url.Parse(reformatted)
        if err != nil {
            t.Fatalf("re-parse failed: %v", err)
        }

        if u.Host != u2.Host || u.Path != u2.Path {
            t.Errorf("roundtrip mismatch: %q -> %q", u.String(), u2.String())
        }
    })
}
# 运行模糊测试
go test -fuzz=FuzzParseURL -fuzztime=30s ./internal/urlutil

# 模糊测试发现的 crash case 会保存到 testdata/fuzz/
# 后续 go test 会自动运行这些 case 作为回归测试

示例测试

func ExampleJoin() {
    result := strings.Join([]string{"a", "b", "c"}, "-")
    fmt.Println(result)
    // Output: a-b-c
}

// 无 Output 注释的示例不会被测试执行,仅用于文档
func ExampleUsage() {
    // 不会被 go test 验证
}

// Example 前缀的函数用于包级文档
func Example() {
    fmt.Println("包的示例")
    // Output: 包的示例
}

测试覆盖率

# 生成覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out

# 输出示例:
# github.com/care-mate/internal/service/user_service.go:18:  GetByID     85.7%
# github.com/care-mate/internal/service/user_service.go:35:  Create      100.0%
# github.com/care-mate/internal/service/user_service.go:52:  Update      66.7%
# total:                                                      82.3%

# HTML 可视化
go tool cover -html=coverage.out -o coverage.html
覆盖率目标范围说明
80%+核心业务逻辑service/repository 层
60%+handler 层HTTP 处理器
90%+工具函数pkg/ 下的纯函数
30%+基础设施层DB/Redis 等外部依赖

第三方测试库对比

用途示例推荐度
testify断言 + Mockassert.Equal(t, got, want)★★★★★
gomockMock 生成mockgen 生成接口 mock★★★★☆
testcontainers-go集成测试容器启动真实 PostgreSQL/Redis★★★★☆
go-sqlmock数据库 MockMock sql.DB 查询★★★★☆
httpmockHTTP MockMock 外部 API 调用★★★☆☆
faker测试数据生成随机生成结构体数据★★★☆☆
ginkgoBDD 测试框架Describe("...", func(){...})★★☆☆☆(Go 社区更偏好标准库风格)