mcpField
G
golang-testing-strategies

golang-testing-strategies

github.com/bobmatnyc测试与质量78449 次安装15 周前更新
代码仓库

关于 golang-testing-strategies

面向 Go 项目开发者的测试策略指南,介绍标准 testing 包及 Testify、Gomock 等工具。涵盖单元与集成测试、基准测试、并发安全和覆盖率实践。

主要功能

  1. 01表驱动测试与子测试
  2. 02Testify 断言和测试套件
  3. 03Gomock 接口模拟
  4. 04基准测试与内存分析
  5. 05竞态检测和覆盖率
  6. 06CI 测试集成

适用场景

  1. 01为 Go 库或应用编写测试
  2. 02模拟数据库、API 等外部依赖
  3. 03检查并发代码的竞态
  4. 04将测试与覆盖率接入 CI

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill golang-testing-strategies

安装后在 Claude Code 中输入/golang-testing-strategies确认已加载。

附带文件metadata.json

详细介绍

Go 测试策略

概述

Go 提供了强大的内置测试框架(testing 包),强调简单易用和提升开发者效率。结合 testify 和 gomock 等社区工具,Go 测试能够以最少的样板代码实现全面的测试覆盖率。

主要特性:

  • 📋 表驱动测试:用于测试多个输入的惯用模式
  • ✅ Testify:易读的断言和测试套件
  • 🎭 Gomock:类型安全的接口模拟
  • ⚡ 基准测试:内置性能测试
  • 🔍 竞态检测器:验证并发代码安全性
  • 📊 覆盖率:原生覆盖率报告和强制执行
  • 🚀 CI 集成:测试缓存和并行执行

何时使用此技能

在以下情况启用此技能:

  • 为 Go 库或应用程序编写测试套件
  • 为新项目搭建测试基础设施
  • 模拟外部依赖(数据库、API、服务)
  • 对性能关键代码路径进行基准测试
  • 确保并发实现具备线程安全性
  • 将测试集成到 CI/CD 流水线
  • 从其他测试框架迁移

核心测试原则

Go 测试理念
  1. 简单胜过魔法:尽可能使用标准库
  2. 表驱动测试:通过单个函数测试多种场景
  3. 子测试:使用 t.Run() 组织相关测试
  4. 基于接口的模拟:通过接口模拟依赖项
  5. 测试文件与代码同目录:将 *_test.go 文件放在代码旁边
  6. 包命名:外部测试使用 package_test,内部测试使用 package
测试组织

文件命名约定:

  • 单元测试:file_test.go
  • 集成测试:file_integration_test.go
  • 基准测试:在同一测试文件中使用 Benchmark 前缀

包结构:

mypackage/
├── user.go
├── user_test.go              // Internal tests (same package)
├── user_external_test.go     // External tests (package mypackage_test)
├── integration_test.go       // Integration tests
└── testdata/                 // Test fixtures (ignored by go build)
    └── golden.json

表驱动测试模式

基本结构

用于测试多个输入的惯用 Go 测试模式:

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        input   User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid user",
            input:   User{Name: "Alice", Age: 30, Email: "[email protected]"},
            wantErr: false,
        },
        {
            name:    "empty name",
            input:   User{Name: "", Age: 30, Email: "[email protected]"},
            wantErr: true,
            errMsg:  "name is required",
        },
        {
            name:    "invalid email",
            input:   User{Name: "Bob", Age: 25, Email: "invalid"},
            wantErr: true,
            errMsg:  "invalid email format",
        },
        {
            name:    "negative age",
            input:   User{Name: "Charlie", Age: -5, Email: "[email protected]"},
            wantErr: true,
            errMsg:  "age must be positive",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.input)

            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if tt.wantErr && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}
并行测试执行

为相互独立的测试启用并行执行:

func TestConcurrentOperations(t *testing.T) {
    tests := []struct {
        name string
        fn   func() int
        want int
    }{
        {"operation 1", func() int { return compute1() }, 42},
        {"operation 2", func() int { return compute2() }, 84},
        {"operation 3", func() int { return compute3() }, 126},
    }

    for _, tt := range tests {
        tt := tt // Capture range variable
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Run tests concurrently

            got := tt.fn()
            if got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

Testify 框架

安装
go get github.com/stretchr/testify
断言

使用易读的断言替代冗长的错误检查:

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestCalculator(t *testing.T) {
    calc := NewCalculator()

    // assert: Test continues on failure
    assert.Equal(t, 5, calc.Add(2, 3))
    assert.NotNil(t, calc)
    assert.True(t, calc.IsReady())

    // require: Test stops on failure (for critical assertions)
    result, err := calc.Divide(10, 2)
    require.NoError(t, err) // Stop if error occurs
    assert.Equal(t, 5, result)
}

func TestUserOperations(t *testing.T) {
    user := &User{ID: 1, Name: "Alice", Email: "[email protected]"}

    // Object matching
    assert.Equal(t, 1, user.ID)
    assert.Contains(t, user.Email, "@")
    assert.Len(t, user.Name, 5)

    // Partial matching
    assert.ObjectsAreEqual(user, &User{
        ID:    1,
        Name:  "Alice",
        Email: assert.AnythingOfType("string"),
    })
}
测试套件

通过设置和清理组织相关测试:

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

type UserServiceTestSuite struct {
    suite.Suite
    db      *sql.DB
    service *UserService
}

// SetupSuite runs once before all tests
func (s *UserServiceTestSuite) SetupSuite() {
    s.db = setupTestDatabase()
    s.service = NewUserService(s.db)
}

// TearDownSuite runs once after all tests
func (s *UserServiceTestSuite) TearDownSuite() {
    s.db.Close()
}

// SetupTest runs before each test
func (s *UserServiceTestSuite) SetupTest() {
    cleanDatabase(s.db)
}

// TearDownTest runs after each test
func (s *UserServiceTestSuite) TearDownTest() {
    // Cleanup if needed
}

// Test methods must start with "Test"
func (s *UserServiceTestSuite) TestCreateUser() {
    user := &User{Name: "Alice", Email: "[email protected]"}

    err := s.service.Create(user)
    s.NoError(err)
    s.NotEqual(0, user.ID) // ID assigned
}

func (s *UserServiceTestSuite) TestGetUser() {
    // Setup
    user := &User{Name: "Bob", Email: "[email protected]"}
    s.service.Create(user)

    // Test
    retrieved, err := s.service.GetByID(user.ID)
    s.NoError(err)
    s.Equal(user.Name, retrieved.Name)
}

// Run the suite
func TestUserServiceTestSuite(t *testing.T) {
    suite.Run(t, new(UserServiceTestSuite))
}

Gomock 接口模拟

安装
go install github.com/golang/mock/mockgen@latest
生成模拟对象
// user_repository.go
package repository

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

type UserRepository interface {
    GetByID(id int) (*User, error)
    Create(user *User) error
    Update(user *User) error
    Delete(id int) error
}

生成模拟对象:

go generate ./...
# Or manually:
mockgen -source=user_repository.go -destination=mocks/mock_user_repository.go -package=mocks
在测试中使用模拟对象
import (
    "testing"
    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    "myapp/repository/mocks"
)

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

    // Create mock
    mockRepo := mocks.NewMockUserRepository(ctrl)

    // Set expectations
    expectedUser := &User{ID: 1, Name: "Alice"}
    mockRepo.EXPECT().
        GetByID(1).
        Return(expectedUser, nil).
        Times(1)

    // Test
    service := NewUserService(mockRepo)
    user, err := service.GetUser(1)

    // Assertions
    assert.NoError(t, err)
    assert.Equal(t, expectedUser, user)
}

func TestUserService_CreateUser_Validation(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockUserRepository(ctrl)

    // Expect Create to NOT be called (validation should fail first)
    mockRepo.EXPECT().Create(gomock.Any()).Times(0)

    service := NewUserService(mockRepo)
    err := service.CreateUser(&User{Name: ""}) // Invalid user

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "name is required")
}
自定义匹配器
// Custom matcher for complex validation
type userMatcher struct {
    expectedEmail string
}

func (m userMatcher) Matches(x interface{}) bool {
    user, ok := x.(*User)
    if !ok {
        return false
    }
    return user.Email == m.expectedEmail
}

func (m userMatcher) String() string {
    return "matches user with email: " + m.expectedEmail
}

func UserWithEmail(email string) gomock.Matcher {
    return userMatcher{expectedEmail: email}
}

// Usage in test
func TestCustomMatcher(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockUserRepository(ctrl)

    mockRepo.EXPECT().
        Create(UserWithEmail("[email protected]")).
        Return(nil)

    service := NewUserService(mockRepo)
    service.CreateUser(&User{Name: "Alice", Email: "[email protected]"})
}

基准测试

基本基准测试
func BenchmarkAdd(b *testing.B) {
    calc := NewCalculator()

    for i := 0; i < b.N; i++ {
        calc.Add(2, 3)
    }
}

func BenchmarkStringConcatenation(b *testing.B) {
    b.Run("plus operator", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = "hello" + "world"
        }
    })

    b.Run("strings.Builder", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            sb.WriteString("hello")
            sb.WriteString("world")
            _ = sb.String()
        }
    })
}
运行基准测试
# Run all benchmarks
go test -bench=.

# Run specific benchmark
go test -bench=BenchmarkAdd

# With memory allocation stats
go test -bench=. -benchmem

# Compare benchmarks
go test -bench=. -benchmem > old.txt
# Make changes
go test -bench=. -benchmem > new.txt
benchstat old.txt new.txt
基准测试输出示例
BenchmarkAdd-8                  1000000000      0.25 ns/op      0 B/op      0 allocs/op
BenchmarkStringBuilder-8        50000000        28.5 ns/op      64 B/op     1 allocs/op

解读:50000000 次迭代,每次操作 28.5 ns/op,每次操作分配 64 B/op 字节,每次操作分配 1 allocs/op 次

高级测试模式

使用 httptest 测试 HTTP 处理器
import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestUserHandler(t *testing.T) {
    handler := http.HandlerFunc(UserHandler)

    req := httptest.NewRequest("GET", "/users/1", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Contains(t, rec.Body.String(), "Alice")
}

func TestHTTPClient(t *testing.T) {
    // Mock server
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/api/users", r.URL.Path)
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"id": 1, "name": "Alice"}`))
    }))
    defer server.Close()

    // Test client against mock server
    client := NewAPIClient(server.URL)
    user, err := client.GetUser(1)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}
竞态检测器

检测并发代码中的数据竞争:

go test -race ./...

并发安全测试示例:

func TestConcurrentMapAccess(t *testing.T) {
    cache := NewSafeCache()

    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(val int) {
            defer wg.Done()
            cache.Set(fmt.Sprintf("key%d", val), val)
        }(i)
    }

    wg.Wait()
    assert.Equal(t, 100, cache.Len())
}
黄金文件测试

根据预期输出文件进行测试:

func TestRenderTemplate(t *testing.T) {
    output := RenderTemplate("user", User{Name: "Alice"})

    goldenFile := "testdata/user_template.golden"

    if *update {
        // Update golden file: go test -update
        os.WriteFile(goldenFile, []byte(output), 0644)
    }

    expected, err := os.ReadFile(goldenFile)
    require.NoError(t, err)

    assert.Equal(t, string(expected), output)
}

var update = flag.Bool("update", false, "update golden files")

CI/CD 集成

GitHub Actions 示例
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.23'

      - name: Run tests
        run: go test -v -race -coverprofile=coverage.out ./...

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.out

      - name: Check coverage threshold
        run: |
          go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//' | \
          awk '{if ($1 < 80) exit 1}'
覆盖率强制执行
# Generate coverage report
go test -coverprofile=coverage.out ./...

# View coverage in terminal
go tool cover -func=coverage.out

# Generate HTML report
go tool cover -html=coverage.out -o coverage.html

# Check coverage threshold (fail if < 80%)
go test -coverprofile=coverage.out ./... && \
go tool cover -func=coverage.out | grep total | awk '{if (substr($3, 1, length($3)-1) < 80) exit 1}'

决策树

何时使用各测试工具

适用于标准 testing 包的场景:

  • 断言较少的简单单元测试
  • 无需模拟外部依赖
  • 性能基准测试
  • 倾向于使用最少依赖

适用于 Testify 的场景:

  • 需要易读的断言(assert.Equal 优于冗长的检查)
  • 包含设置和清理的测试套件
  • 多个相似的测试用例
  • 倾向于使用表达力强的测试代码

适用于 Gomock 的场景:

  • 测试具有接口依赖的代码
  • 需要精确验证调用(次数、顺序)
  • 涉及多种场景的复杂模拟行为
  • 需要类型安全的模拟

适用于基准测试的场景:

  • 优化性能关键代码
  • 比较算法实现
  • 检测性能回归
  • 分析内存分配情况

适用于 httptest 的场景:

  • 测试 HTTP 处理程序
  • 模拟外部 HTTP API
  • 对 HTTP 客户端进行集成测试
  • 测试中间件链

适用于竞态检测器的场景:

  • 编写并发代码
  • 使用 goroutine 和通道
  • 多个 goroutine 共享状态
  • 在 CI/CD 中检测所有并发代码

应避免的反模式

❌ 不要模拟所有内容

// WRONG: Over-mocking makes tests brittle
mockLogger := mocks.NewMockLogger(ctrl)
mockConfig := mocks.NewMockConfig(ctrl)
mockMetrics := mocks.NewMockMetrics(ctrl)
// Too many mocks = fragile test

✅ 应该:只模拟外部依赖

// CORRECT: Mock only database, use real logger/config
mockRepo := mocks.NewMockUserRepository(ctrl)
service := NewUserService(mockRepo, realLogger, realConfig)

❌ 不要测试实现细节

// WRONG: Testing internal state
assert.Equal(t, "processing", service.internalState)

✅ 应该:测试公开行为

// CORRECT: Test observable outcomes
user, err := service.GetUser(1)
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)

❌ 不要忽略错误情况

// WRONG: Only testing happy path
func TestGetUser(t *testing.T) {
    user, _ := service.GetUser(1) // Ignoring error!
    assert.NotNil(t, user)
}

✅ 应该:测试错误条件

// CORRECT: Test both success and error cases
func TestGetUser_NotFound(t *testing.T) {
    user, err := service.GetUser(999)
    assert.Error(t, err)
    assert.Nil(t, user)
    assert.Contains(t, err.Error(), "not found")
}

最佳实践

  1. 将测试文件与代码放在一起:将 *_test.go 文件放在源代码旁边
  2. 使用子测试:通过 t.Run() 组织相关测试
  3. 安全时并行运行:为相互独立的测试启用 t.Parallel()
  4. 模拟接口:通过接口依赖进行设计,确保代码易于测试
  5. 测试错误:验证成功和失败路径
  6. 对关键路径进行基准测试:分析对性能敏感的代码
  7. 运行竞态检测器:并发代码始终使用 -race
  8. 强制执行覆盖率要求:在 CI 中设置最低阈值(通常为 80%)
  9. 使用黄金文件:通过预期文件测试复杂输出
  10. 保持测试快速:模拟耗时操作,快速运行时使用 -short 标志

资源

官方文档:

测试框架:

近期指南(2025):

相关技能:

  • golang-engineer:Go 核心模式与并发
  • verification-before-completion:将测试作为“完成”标准的一部分
  • testing-anti-patterns:避免常见的测试错误

快速参考

运行测试
go test ./...                    # All tests
go test -v ./...                 # Verbose output
go test -short ./...             # Skip slow tests
go test -run TestUserCreate      # Specific test
go test -race ./...              # With race detector
go test -cover ./...             # With coverage
go test -coverprofile=c.out ./... # Coverage file
go test -bench=. -benchmem       # Benchmarks with memory
生成模拟对象
go generate ./...                           # All //go:generate directives
mockgen -source=interface.go -destination=mock.go
覆盖率分析
go tool cover -func=coverage.out           # Coverage per function
go tool cover -html=coverage.out           # HTML report

Token 估算:约 4,500 个 Token(入口 + 全部内容) 版本:1.0.0 最后更新:2025-12-03

评论

支持 Markdown