Skip to content

Go 测试最佳实践

概述

测试是保证代码质量的重要手段,本项目采用 测试驱动开发(TDD) 方法,确保所有代码都具备高质量的测试覆盖。本文档详细介绍 Go 语言测试的最佳实践,包括单元测试、集成测试、基准测试、Mock 技术等各个方面。

核心原则

  • TDD 优先: 先写测试,再写实现
  • 100% 覆盖: pkg 包必须达到 100% 测试覆盖率
  • 快速反馈: 单元测试应在毫秒级完成
  • 独立性: 测试之间不应相互依赖
  • 可读性: 测试代码应清晰表达意图

测试分层

  • 单元测试: 测试单个函数或方法
  • 集成测试: 测试组件间协作
  • 端到端测试: 测试完整业务流程
  • 性能测试: 基准测试和压力测试

核心原理

测试金字塔

测试驱动开发流程

Go 测试框架生态

项目测试架构

1. 测试目录结构

internal/
├── app/
│   └── [module]/
│       ├── service.go
│       ├── service_test.go          # 服务层单元测试
│       ├── service_integration_test.go # 服务层集成测试
│       ├── repository.go
│       ├── repository_test.go
│       └── testdata/                # 测试数据
│           ├── fixtures/
│           └── golden/
├── pkg/
│   ├── [package]/
│   │   ├── [package].go
│   │   ├── [package]_test.go        # 必须有测试
│   │   └── [package]_benchmark_test.go # 性能测试
│   └── validation/
│       ├── validator.go
│       ├── validator_test.go
│       └── validator_integration_test.go
└── test/
    ├── fixtures/                    # 共享测试数据
    ├── helpers/                     # 测试辅助函数
    ├── mocks/                       # Mock 对象
    └── testcontainers/              # 测试容器

2. 测试分类和命名规范

go
// 单元测试命名:Test + 功能名
func TestCreateLedger(t *testing.T) {}
func TestCreateLedger_WithInvalidName_ShouldReturnError(t *testing.T) {}

// 基准测试命名:Benchmark + 功能名
func BenchmarkCreateLedger(b *testing.B) {}
func BenchmarkCreateLedger_Parallel(b *testing.B) {}

// 示例测试命名:Example + 功能名
func ExampleLedgerService_CreateLedger() {}

// 集成测试命名:TestIntegration + 功能名
func TestIntegrationCreateLedgerFlow(t *testing.T) {}

// 端到端测试命名:TestE2E + 场景名
func TestE2ECompleteAccountingFlow(t *testing.T) {}

3. 测试标签和构建约束

go
// 单元测试 (默认运行)
// +build !integration

package ledger

// 集成测试 (需要 -tags=integration)
// +build integration

package ledger

// 端到端测试 (需要 -tags=e2e)
// +build e2e

package ledger

// 性能测试 (需要 -tags=benchmark) 
// +build benchmark

package ledger

单元测试最佳实践

1. 测试结构和模式

go
package ledger

import (
    "context"
    "testing"
    "time"
    
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/stretchr/testify/suite"
    
    "github.com/FixIterate/lz-stash/internal/domain/ledger"
    "github.com/FixIterate/lz-stash/internal/test/mocks"
)

// 使用 testify/suite 组织测试
type LedgerServiceTestSuite struct {
    suite.Suite
    
    service     *LedgerService
    mockRepo    *mocks.MockLedgerRepository
    mockPublisher *mocks.MockEventPublisher
    ctx         context.Context
}

// SetupSuite 在整个测试套件开始前运行
func (s *LedgerServiceTestSuite) SetupSuite() {
    s.ctx = context.Background()
}

// SetupTest 在每个测试开始前运行
func (s *LedgerServiceTestSuite) SetupTest() {
    s.mockRepo = mocks.NewMockLedgerRepository(s.T())
    s.mockPublisher = mocks.NewMockEventPublisher(s.T())
    
    s.service = NewLedgerService(
        s.mockRepo,
        s.mockPublisher,
    )
}

// TearDownTest 在每个测试结束后运行
func (s *LedgerServiceTestSuite) TearDownTest() {
    // 验证所有 mock 调用都符合预期
    s.mockRepo.AssertExpectations(s.T())
    s.mockPublisher.AssertExpectations(s.T())
}

// 测试成功场景
func (s *LedgerServiceTestSuite) TestCreateLedger_Success() {
    // Arrange (准备)
    req := &CreateLedgerRequest{
        Name:        "测试账本",
        Description: "这是一个测试账本",
        Currency:    "CNY",
    }
    
    expectedLedger := &ledger.Ledger{
        ID:          "ledger-001",
        Name:        req.Name,
        Description: req.Description,
        Currency:    req.Currency,
        CreatedAt:   time.Now(),
    }
    
    s.mockRepo.On("Save", s.ctx, mock.AnythingOfType("*ledger.Ledger")).
        Return(nil).
        Once()
    
    s.mockPublisher.On("Publish", s.ctx, mock.AnythingOfType("*events.LedgerCreatedEvent")).
        Return(nil).
        Once()
    
    // Act (执行)
    result, err := s.service.CreateLedger(s.ctx, req)
    
    // Assert (断言)
    require.NoError(s.T(), err)
    assert.Equal(s.T(), expectedLedger.Name, result.Name)
    assert.Equal(s.T(), expectedLedger.Description, result.Description)
    assert.Equal(s.T(), expectedLedger.Currency, result.Currency)
    assert.NotEmpty(s.T(), result.ID)
    assert.NotZero(s.T(), result.CreatedAt)
}

// 测试错误场景
func (s *LedgerServiceTestSuite) TestCreateLedger_InvalidName_ShouldReturnError() {
    // Arrange
    req := &CreateLedgerRequest{
        Name:        "", // 无效名称
        Description: "测试",
        Currency:    "CNY",
    }
    
    // Act
    result, err := s.service.CreateLedger(s.ctx, req)
    
    // Assert
    assert.Error(s.T(), err)
    assert.Nil(s.T(), result)
    assert.Contains(s.T(), err.Error(), "name is required")
    
    // 验证没有调用依赖项 (因为验证失败)
    s.mockRepo.AssertNotCalled(s.T(), "Save")
    s.mockPublisher.AssertNotCalled(s.T(), "Publish")
}

// 测试边界条件
func (s *LedgerServiceTestSuite) TestCreateLedger_NameTooLong_ShouldReturnError() {
    // Arrange
    longName := strings.Repeat("a", 101) // 超过100字符限制
    req := &CreateLedgerRequest{
        Name:        longName,
        Description: "测试",
        Currency:    "CNY",
    }
    
    // Act
    result, err := s.service.CreateLedger(s.ctx, req)
    
    // Assert
    assert.Error(s.T(), err)
    assert.Nil(s.T(), result)
    assert.Contains(s.T(), err.Error(), "name too long")
}

// 测试依赖失败场景
func (s *LedgerServiceTestSuite) TestCreateLedger_RepositoryError_ShouldReturnError() {
    // Arrange
    req := &CreateLedgerRequest{
        Name:        "测试账本",
        Description: "测试",
        Currency:    "CNY",
    }
    
    expectedError := errors.New("database connection failed")
    s.mockRepo.On("Save", s.ctx, mock.AnythingOfType("*ledger.Ledger")).
        Return(expectedError).
        Once()
    
    // Act
    result, err := s.service.CreateLedger(s.ctx, req)
    
    // Assert
    assert.Error(s.T(), err)
    assert.Nil(s.T(), result)
    assert.Contains(s.T(), err.Error(), "database connection failed")
    
    // 验证事件发布没有被调用 (因为保存失败)
    s.mockPublisher.AssertNotCalled(s.T(), "Publish")
}

// 运行测试套件
func TestLedgerServiceTestSuite(t *testing.T) {
    suite.Run(t, new(LedgerServiceTestSuite))
}

2. 表驱动测试 (Table-Driven Tests)

go
func TestValidateAmount(t *testing.T) {
    testCases := []struct {
        name        string
        amount      int64
        currency    string
        expected    bool
        expectedErr string
    }{
        {
            name:     "valid positive amount",
            amount:   10000, // 100.00
            currency: "CNY",
            expected: true,
        },
        {
            name:        "zero amount",
            amount:      0,
            currency:    "CNY",
            expected:    false,
            expectedErr: "amount must be positive",
        },
        {
            name:        "negative amount",
            amount:      -1000,
            currency:    "CNY", 
            expected:    false,
            expectedErr: "amount must be positive",
        },
        {
            name:        "invalid currency",
            amount:      10000,
            currency:    "INVALID",
            expected:    false,
            expectedErr: "unsupported currency",
        },
        {
            name:        "amount too large",
            amount:      math.MaxInt64,
            currency:    "CNY",
            expected:    false,
            expectedErr: "amount too large",
        },
    }
    
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // Act
            result, err := ValidateAmount(tc.amount, tc.currency)
            
            // Assert
            assert.Equal(t, tc.expected, result)
            
            if tc.expectedErr != "" {
                require.Error(t, err)
                assert.Contains(t, err.Error(), tc.expectedErr)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

3. 子测试和并行测试

go
func TestLedgerOperations(t *testing.T) {
    // 并行运行子测试
    t.Run("CreateLedger", func(t *testing.T) {
        t.Parallel() // 并行运行
        
        // 测试创建账本
        service := setupTestService(t)
        result, err := service.CreateLedger(context.Background(), validRequest())
        
        assert.NoError(t, err)
        assert.NotNil(t, result)
    })
    
    t.Run("UpdateLedger", func(t *testing.T) {
        t.Parallel()
        
        // 测试更新账本
        service := setupTestService(t)
        // ... 测试逻辑
    })
    
    t.Run("DeleteLedger", func(t *testing.T) {
        t.Parallel()
        
        // 测试删除账本
        service := setupTestService(t)
        // ... 测试逻辑
    })
}

// 测试辅助函数
func setupTestService(t *testing.T) *LedgerService {
    t.Helper() // 标记为辅助函数,错误时不显示这个函数的行号
    
    mockRepo := mocks.NewMockLedgerRepository(t)
    mockPublisher := mocks.NewMockEventPublisher(t)
    
    return NewLedgerService(mockRepo, mockPublisher)
}

Mock 技术详解

1. 使用 testify/mock

go
// 定义 Mock 接口
type MockLedgerRepository struct {
    mock.Mock
}

func (m *MockLedgerRepository) Save(ctx context.Context, ledger *ledger.Ledger) error {
    args := m.Called(ctx, ledger)
    return args.Error(0)
}

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

// 使用 Mock
func TestCreateLedger_WithMock(t *testing.T) {
    // 创建 Mock
    mockRepo := new(MockLedgerRepository)
    
    // 设置期望
    mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*ledger.Ledger")).
        Return(nil).
        Once()
    
    // 创建服务
    service := NewLedgerService(mockRepo)
    
    // 执行测试
    _, err := service.CreateLedger(context.Background(), validRequest())
    
    // 验证
    assert.NoError(t, err)
    mockRepo.AssertExpectations(t)
}

2. 使用 gomock 自动生成

go
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go

// 原始接口
type LedgerRepository interface {
    Save(ctx context.Context, ledger *ledger.Ledger) error
    FindByID(ctx context.Context, id string) (*ledger.Ledger, error)
    FindByUserID(ctx context.Context, userID string) ([]*ledger.Ledger, error)
}

// 使用生成的 Mock
func TestCreateLedger_WithGoMock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := mocks.NewMockLedgerRepository(ctrl)
    
    // 设置期望
    mockRepo.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        Return(nil).
        Times(1)
    
    service := NewLedgerService(mockRepo)
    
    _, err := service.CreateLedger(context.Background(), validRequest())
    assert.NoError(t, err)
}

3. 高级 Mock 技巧

go
// Mock 返回值基于输入参数
mockRepo.On("FindByID", mock.Anything, "existing-id").
    Return(&ledger.Ledger{ID: "existing-id"}, nil)

mockRepo.On("FindByID", mock.Anything, "non-existing-id").
    Return(nil, ErrLedgerNotFound)

// Mock 方法调用顺序
mockRepo.On("Save", mock.Anything, mock.Anything).
    Return(nil).
    Once()

mockPublisher.On("Publish", mock.Anything, mock.Anything).
    Return(nil).
    Once().
    After(time.Second) // 延迟执行

// Mock 复杂参数匹配
mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(l *ledger.Ledger) bool {
    return l.Name == "测试账本" && l.Currency == "CNY"
})).Return(nil)

// Mock 返回不同的值 (模拟重试场景)
mockRepo.On("Save", mock.Anything, mock.Anything).
    Return(errors.New("temporary error")).
    Once() // 第一次失败

mockRepo.On("Save", mock.Anything, mock.Anything).
    Return(nil).
    Once() // 第二次成功

集成测试策略

1. 数据库集成测试

go
// +build integration

package ledger

import (
    "context"
    "database/sql"
    "testing"
    
    "github.com/ory/dockertest/v3"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type IntegrationTestSuite struct {
    suite.Suite
    
    db       *gorm.DB
    pool     *dockertest.Pool
    resource *dockertest.Resource
    
    repository *LedgerRepository
    ctx        context.Context
}

func (s *IntegrationTestSuite) SetupSuite() {
    s.ctx = context.Background()
    
    // 启动 MySQL 容器
    var err error
    s.pool, err = dockertest.NewPool("")
    s.Require().NoError(err)
    
    // 运行 MySQL 容器
    s.resource, err = s.pool.Run("mysql", "8.0", []string{
        "MYSQL_ROOT_PASSWORD=testpass",
        "MYSQL_DATABASE=testdb",
    })
    s.Require().NoError(err)
    
    // 等待数据库就绪
    var db *sql.DB
    err = s.pool.Retry(func() error {
        var err error
        dsn := fmt.Sprintf("root:testpass@tcp(localhost:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
            s.resource.GetPort("3306/tcp"))
        
        db, err = sql.Open("mysql", dsn)
        if err != nil {
            return err
        }
        return db.Ping()
    })
    s.Require().NoError(err)
    
    // 创建 GORM 实例
    s.db, err = gorm.Open(mysql.New(mysql.Config{
        Conn: db,
    }), &gorm.Config{})
    s.Require().NoError(err)
    
    // 运行数据库迁移
    err = s.db.AutoMigrate(&Ledger{}, &Transaction{}, &Tag{}, &Budget{})
    s.Require().NoError(err)
    
    // 创建 repository
    s.repository = NewLedgerRepository(s.db)
}

func (s *IntegrationTestSuite) TearDownSuite() {
    // 清理 Docker 容器
    if s.pool != nil && s.resource != nil {
        s.pool.Purge(s.resource)
    }
}

func (s *IntegrationTestSuite) SetupTest() {
    // 清理测试数据
    s.db.Exec("TRUNCATE TABLE ledgers")
    s.db.Exec("TRUNCATE TABLE transactions")
    s.db.Exec("TRUNCATE TABLE tags")
    s.db.Exec("TRUNCATE TABLE budgets")
}

func (s *IntegrationTestSuite) TestCreateAndFindLedger() {
    // 创建测试数据
    ledger := &Ledger{
        ID:          "test-ledger-001",
        Name:        "集成测试账本",
        Description: "用于集成测试",
        Currency:    "CNY",
        CreatedAt:   time.Now(),
        UpdatedAt:   time.Now(),
    }
    
    // 保存到数据库
    err := s.repository.Save(s.ctx, ledger)
    s.NoError(err)
    
    // 从数据库查询
    found, err := s.repository.FindByID(s.ctx, ledger.ID)
    s.NoError(err)
    s.NotNil(found)
    
    // 验证数据
    assert.Equal(s.T(), ledger.ID, found.ID)
    assert.Equal(s.T(), ledger.Name, found.Name)
    assert.Equal(s.T(), ledger.Description, found.Description)
    assert.Equal(s.T(), ledger.Currency, found.Currency)
}

func (s *IntegrationTestSuite) TestTransactionOperations() {
    // 测试完整的事务流程
    tx := s.db.Begin()
    defer tx.Rollback()
    
    // 在事务中创建账本
    ledger := &Ledger{
        ID:       "tx-test-ledger",
        Name:     "事务测试账本",
        Currency: "CNY",
    }
    
    err := tx.Create(ledger).Error
    s.NoError(err)
    
    // 在事务中创建交易记录
    transaction := &Transaction{
        ID:          "tx-test-transaction",
        LedgerID:    ledger.ID,
        Type:        TransactionTypeExpense,
        Amount:      10000,
        Description: "测试交易",
    }
    
    err = tx.Create(transaction).Error
    s.NoError(err)
    
    // 提交事务
    err = tx.Commit().Error
    s.NoError(err)
    
    // 验证数据已持久化
    var count int64
    s.db.Model(&Ledger{}).Where("id = ?", ledger.ID).Count(&count)
    assert.Equal(s.T(), int64(1), count)
    
    s.db.Model(&Transaction{}).Where("id = ?", transaction.ID).Count(&count)
    assert.Equal(s.T(), int64(1), count)
}

func TestIntegrationTestSuite(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过集成测试")
    }
    
    suite.Run(t, new(IntegrationTestSuite))
}

2. HTTP API 集成测试

go
// +build integration

func TestHTTPAPIIntegration(t *testing.T) {
    // 启动测试服务器
    server := setupTestServer(t)
    defer server.Close()
    
    client := &http.Client{Timeout: 5 * time.Second}
    
    t.Run("CreateLedger", func(t *testing.T) {
        // 准备请求数据
        reqBody := map[string]interface{}{
            "name":        "HTTP测试账本",
            "description": "通过HTTP API创建",
            "currency":    "CNY",
        }
        
        jsonBody, _ := json.Marshal(reqBody)
        
        // 发送 POST 请求
        resp, err := client.Post(
            server.URL+"/api/v1/ledgers",
            "application/json",
            bytes.NewBuffer(jsonBody),
        )
        require.NoError(t, err)
        defer resp.Body.Close()
        
        // 验证响应
        assert.Equal(t, http.StatusCreated, resp.StatusCode)
        
        var response map[string]interface{}
        err = json.NewDecoder(resp.Body).Decode(&response)
        require.NoError(t, err)
        
        ledger := response["ledger"].(map[string]interface{})
        assert.Equal(t, reqBody["name"], ledger["name"])
        assert.Equal(t, reqBody["description"], ledger["description"])
        assert.NotEmpty(t, ledger["id"])
    })
    
    t.Run("GetLedger", func(t *testing.T) {
        // 先创建一个账本
        ledgerID := createTestLedger(t, client, server.URL)
        
        // 获取账本
        resp, err := client.Get(server.URL + "/api/v1/ledgers/" + ledgerID)
        require.NoError(t, err)
        defer resp.Body.Close()
        
        assert.Equal(t, http.StatusOK, resp.StatusCode)
        
        var response map[string]interface{}
        err = json.NewDecoder(resp.Body).Decode(&response)
        require.NoError(t, err)
        
        ledger := response["ledger"].(map[string]interface{})
        assert.Equal(t, ledgerID, ledger["id"])
    })
}

func setupTestServer(t *testing.T) *httptest.Server {
    t.Helper()
    
    // 设置测试数据库
    db := setupTestDB(t)
    
    // 创建服务
    service := NewLedgerService(
        NewLedgerRepository(db),
        NewEventPublisher(),
    )
    
    // 设置路由
    router := gin.New()
    router.POST("/api/v1/ledgers", service.CreateLedgerHandler)
    router.GET("/api/v1/ledgers/:id", service.GetLedgerHandler)
    
    return httptest.NewServer(router)
}

基准测试和性能测试

1. 基准测试基础

go
func BenchmarkCreateLedger(b *testing.B) {
    service := setupBenchmarkService()
    req := &CreateLedgerRequest{
        Name:        "基准测试账本",
        Description: "性能测试",
        Currency:    "CNY",
    }
    
    b.ResetTimer() // 重置计时器,排除设置时间
    
    for i := 0; i < b.N; i++ {
        _, err := service.CreateLedger(context.Background(), req)
        if err != nil {
            b.Fatal(err)
        }
    }
}

// 并行基准测试
func BenchmarkCreateLedger_Parallel(b *testing.B) {
    service := setupBenchmarkService()
    
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            req := &CreateLedgerRequest{
                Name:        fmt.Sprintf("账本-%d", rand.Int()),
                Description: "并行测试",
                Currency:    "CNY",
            }
            
            _, err := service.CreateLedger(context.Background(), req)
            if err != nil {
                b.Fatal(err)
            }
        }
    })
}

// 内存分配基准测试
func BenchmarkCreateLedger_Memory(b *testing.B) {
    service := setupBenchmarkService()
    req := &CreateLedgerRequest{
        Name:        "内存测试账本",
        Description: "内存分配测试",
        Currency:    "CNY",
    }
    
    b.ResetTimer()
    b.ReportAllocs() // 报告内存分配
    
    for i := 0; i < b.N; i++ {
        _, err := service.CreateLedger(context.Background(), req)
        if err != nil {
            b.Fatal(err)
        }
    }
}

2. 复杂场景基准测试

go
// 不同输入大小的基准测试
func BenchmarkValidateTransaction(b *testing.B) {
    testCases := []struct {
        name     string
        tagCount int
    }{
        {"SmallTransaction", 1},
        {"MediumTransaction", 10},
        {"LargeTransaction", 100},
    }
    
    for _, tc := range testCases {
        b.Run(tc.name, func(b *testing.B) {
            transaction := createTransactionWithTags(tc.tagCount)
            validator := NewTransactionValidator()
            
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                err := validator.Validate(transaction)
                if err != nil {
                    b.Fatal(err)
                }
            }
        })
    }
}

// 数据库操作基准测试
func BenchmarkRepository_FindByID(b *testing.B) {
    db := setupBenchmarkDB()
    repo := NewLedgerRepository(db)
    
    // 预填充数据
    ledgers := createTestLedgers(1000)
    for _, ledger := range ledgers {
        repo.Save(context.Background(), ledger)
    }
    
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        // 随机选择一个 ID
        id := ledgers[rand.Intn(len(ledgers))].ID
        
        _, err := repo.FindByID(context.Background(), id)
        if err != nil {
            b.Fatal(err)
        }
    }
}

3. 内存和 CPU 性能分析

go
// CPU 性能分析
func BenchmarkCPUIntensive(b *testing.B) {
    // 启用 CPU 性能分析
    if *cpuprofile != "" {
        f, err := os.Create(*cpuprofile)
        if err != nil {
            b.Fatal(err)
        }
        defer f.Close()
        
        pprof.StartCPUProfile(f)
        defer pprof.StopCPUProfile()
    }
    
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        // CPU 密集型操作
        calculateComplexStatistics()
    }
}

// 内存性能分析
func BenchmarkMemoryIntensive(b *testing.B) {
    defer func() {
        if *memprofile != "" {
            f, err := os.Create(*memprofile)
            if err != nil {
                b.Fatal(err)
            }
            defer f.Close()
            
            runtime.GC() // 强制 GC
            if err := pprof.WriteHeapProfile(f); err != nil {
                b.Fatal(err)
            }
        }
    }()
    
    b.ResetTimer()
    b.ReportAllocs()
    
    for i := 0; i < b.N; i++ {
        // 内存密集型操作
        data := make([]byte, 1024*1024) // 1MB
        processLargeData(data)
    }
}

测试工具和实用程序

1. 测试数据管理

go
// 测试数据工厂
type TestDataFactory struct {
    rand *rand.Rand
}

func NewTestDataFactory() *TestDataFactory {
    return &TestDataFactory{
        rand: rand.New(rand.NewSource(time.Now().UnixNano())),
    }
}

func (f *TestDataFactory) CreateLedger() *ledger.Ledger {
    return &ledger.Ledger{
        ID:          f.GenerateID("ledger"),
        Name:        f.GenerateName("账本"),
        Description: f.GenerateDescription(),
        Currency:    f.RandomCurrency(),
        CreatedAt:   f.RandomTime(),
        UpdatedAt:   time.Now(),
    }
}

func (f *TestDataFactory) CreateTransaction(ledgerID string) *Transaction {
    return &Transaction{
        ID:              f.GenerateID("tx"),
        LedgerID:        ledgerID,
        Type:            f.RandomTransactionType(),
        Amount:          f.RandomAmount(),
        Description:     f.GenerateDescription(),
        TransactionDate: f.RandomTime(),
        CreatedAt:       time.Now(),
    }
}

func (f *TestDataFactory) GenerateID(prefix string) string {
    return fmt.Sprintf("%s-%d", prefix, f.rand.Int63())
}

func (f *TestDataFactory) RandomCurrency() string {
    currencies := []string{"CNY", "USD", "EUR", "GBP", "JPY"}
    return currencies[f.rand.Intn(len(currencies))]
}

func (f *TestDataFactory) RandomAmount() int64 {
    return f.rand.Int63n(1000000) + 100 // 1元到10000元
}

2. 测试断言辅助

go
// 自定义断言函数
func AssertLedgerEqual(t *testing.T, expected, actual *ledger.Ledger) {
    t.Helper()
    
    assert.Equal(t, expected.ID, actual.ID)
    assert.Equal(t, expected.Name, actual.Name)
    assert.Equal(t, expected.Description, actual.Description)
    assert.Equal(t, expected.Currency, actual.Currency)
    
    // 时间断言(允许1秒误差)
    assert.WithinDuration(t, expected.CreatedAt, actual.CreatedAt, time.Second)
}

func AssertTransactionEqual(t *testing.T, expected, actual *Transaction) {
    t.Helper()
    
    assert.Equal(t, expected.ID, actual.ID)
    assert.Equal(t, expected.LedgerID, actual.LedgerID)
    assert.Equal(t, expected.Type, actual.Type)
    assert.Equal(t, expected.Amount, actual.Amount)
    assert.Equal(t, expected.Description, actual.Description)
}

// JSON 响应断言
func AssertJSONResponse(t *testing.T, response *http.Response, expectedStatus int, expectedBody interface{}) {
    t.Helper()
    
    assert.Equal(t, expectedStatus, response.StatusCode)
    
    var actualBody interface{}
    err := json.NewDecoder(response.Body).Decode(&actualBody)
    require.NoError(t, err)
    
    assert.Equal(t, expectedBody, actualBody)
}

3. 测试环境管理

go
// 测试环境配置
type TestEnv struct {
    DB          *gorm.DB
    Redis       redis.Cmdable
    Config      *Config
    TestData    *TestDataFactory
    Container   *dockertest.Pool
    Resources   []*dockertest.Resource
}

func SetupTestEnv(t *testing.T) *TestEnv {
    t.Helper()
    
    env := &TestEnv{
        TestData: NewTestDataFactory(),
    }
    
    // 启动 Docker 容器
    pool, err := dockertest.NewPool("")
    require.NoError(t, err)
    env.Container = pool
    
    // 启动 MySQL
    env.setupMySQL(t)
    
    // 启动 Redis
    env.setupRedis(t)
    
    // 注册清理函数
    t.Cleanup(func() {
        env.Cleanup()
    })
    
    return env
}

func (env *TestEnv) setupMySQL(t *testing.T) {
    resource, err := env.Container.Run("mysql", "8.0", []string{
        "MYSQL_ROOT_PASSWORD=testpass",
        "MYSQL_DATABASE=testdb",
    })
    require.NoError(t, err)
    
    env.Resources = append(env.Resources, resource)
    
    // 等待连接就绪并创建 GORM 实例
    err = env.Container.Retry(func() error {
        dsn := fmt.Sprintf("root:testpass@tcp(localhost:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
            resource.GetPort("3306/tcp"))
        
        db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
        if err != nil {
            return err
        }
        
        sqlDB, err := db.DB()
        if err != nil {
            return err
        }
        
        if err := sqlDB.Ping(); err != nil {
            return err
        }
        
        env.DB = db
        return nil
    })
    require.NoError(t, err)
    
    // 运行迁移
    err = env.DB.AutoMigrate(&Ledger{}, &Transaction{}, &Tag{}, &Budget{})
    require.NoError(t, err)
}

func (env *TestEnv) Cleanup() {
    for _, resource := range env.Resources {
        env.Container.Purge(resource)
    }
}

测试最佳实践总结

1. 命名和组织

go
// ✅ 好的测试命名
func TestCreateLedger_WithValidInput_ShouldReturnSuccess(t *testing.T) {}
func TestCreateLedger_WithEmptyName_ShouldReturnValidationError(t *testing.T) {}
func TestCreateLedger_WithDatabaseError_ShouldReturnInternalError(t *testing.T) {}

// ❌ 不好的测试命名
func TestCreateLedger1(t *testing.T) {}
func TestCreateLedger2(t *testing.T) {}
func TestCreateLedgerFail(t *testing.T) {}

2. 测试独立性

go
// ✅ 每个测试独立
func TestLedgerService(t *testing.T) {
    t.Run("CreateLedger", func(t *testing.T) {
        service := setupService() // 每个测试独立设置
        // 测试逻辑...
    })
    
    t.Run("UpdateLedger", func(t *testing.T) {
        service := setupService() // 独立设置
        // 测试逻辑...
    })
}

// ❌ 测试间有依赖
var globalService *LedgerService // 全局变量

func TestCreateLedger(t *testing.T) {
    globalService = setupService()
    // 测试逻辑...
}

func TestUpdateLedger(t *testing.T) {
    // 依赖于前一个测试的结果
    // 测试逻辑...
}

3. 错误处理

go
// ✅ 详细的错误验证
func TestCreateLedger_InvalidInput(t *testing.T) {
    testCases := []struct {
        name        string
        request     *CreateLedgerRequest
        expectedErr string
    }{
        {
            name:        "empty name",
            request:     &CreateLedgerRequest{Name: ""},
            expectedErr: "name is required",
        },
        {
            name:        "name too long",
            request:     &CreateLedgerRequest{Name: strings.Repeat("a", 101)},
            expectedErr: "name too long",
        },
    }
    
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            service := setupService()
            
            _, err := service.CreateLedger(context.Background(), tc.request)
            
            require.Error(t, err)
            assert.Contains(t, err.Error(), tc.expectedErr)
        })
    }
}

// ❌ 简单的错误检查
func TestCreateLedger_InvalidInput(t *testing.T) {
    service := setupService()
    
    _, err := service.CreateLedger(context.Background(), &CreateLedgerRequest{})
    
    assert.Error(t, err) // 没有验证具体错误
}

4. 测试覆盖率

bash
# 生成测试覆盖率报告
go test -coverprofile=coverage.out ./...

# 查看覆盖率
go tool cover -func=coverage.out

# 生成 HTML 报告
go tool cover -html=coverage.out -o coverage.html

# 只测试特定包
go test -cover ./internal/app/ledger/...

# 设置覆盖率阈值
go test -cover ./... | grep -E "coverage: [0-9]+" | awk '{if($2 < 90.0) exit 1}'

常见问题和解决方案

1. 测试运行缓慢

问题: 测试套件运行时间过长

解决方案:

go
// ✅ 使用并行测试
func TestLedgerOperations(t *testing.T) {
    t.Run("CreateLedger", func(t *testing.T) {
        t.Parallel() // 并行运行
        // 测试逻辑...
    })
    
    t.Run("UpdateLedger", func(t *testing.T) {
        t.Parallel()
        // 测试逻辑...
    })
}

// ✅ 跳过慢速测试
func TestSlowOperation(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过慢速测试")
    }
    // 慢速测试逻辑...
}

// 运行时跳过慢速测试
// go test -short

2. 测试数据污染

问题: 测试之间相互影响

解决方案:

go
// ✅ 事务回滚
func TestWithTransaction(t *testing.T) {
    tx := db.Begin()
    defer tx.Rollback() // 测试结束后回滚
    
    // 在事务中执行测试
    repo := NewLedgerRepository(tx)
    // 测试逻辑...
}

// ✅ 独立数据库
func TestWithIsolatedDB(t *testing.T) {
    db := setupTestDB(t) // 每个测试独立数据库
    defer cleanupTestDB(t, db)
    
    // 测试逻辑...
}

3. Mock 过度使用

问题: Mock 太多导致测试脆弱

解决方案:

go
// ✅ 适度使用 Mock
func TestLedgerService_CreateLedger(t *testing.T) {
    // 只 Mock 真正的外部依赖
    mockRepo := mocks.NewMockLedgerRepository(t)
    mockPublisher := mocks.NewMockEventPublisher(t)
    
    // 真实的领域对象
    service := NewLedgerService(mockRepo, mockPublisher)
    
    // 测试逻辑...
}

// ❌ 过度 Mock
func TestLedgerService_CreateLedger(t *testing.T) {
    // Mock 了太多内部组件
    mockValidator := mocks.NewMockValidator(t)
    mockIDGenerator := mocks.NewMockIDGenerator(t)
    mockTimeProvider := mocks.NewMockTimeProvider(t)
    mockRepo := mocks.NewMockLedgerRepository(t)
    // ... 更多 Mock
}

参考资料

官方文档

  1. Go Testing Package - Go 标准测试库
  2. Go Testing Guide - Go 官方测试指南
  3. Testify Documentation - Testify 测试框架

经典书籍

  1. 《Test-Driven Development》 - Kent Beck
  2. 《Growing Object-Oriented Software, Guided by Tests》 - Steve Freeman & Nat Pryce
  3. 《Working Effectively with Unit Tests》 - Jay Fields

最佳实践文章

  1. Go Testing By Example - Go 官方测试示例
  2. Advanced Go Testing Tutorial - 高级测试技巧
  3. Table Driven Tests - 表驱动测试

工具和框架

  1. GoMock - Mock 代码生成工具
  2. Dockertest - Docker 集成测试
  3. Ginkgo - BDD 测试框架
  4. go-cmp - 深度比较工具

实战练习

练习 1: 编写完整的单元测试

为 Currency 包编写完整的单元测试:

  • 货币转换功能
  • 汇率计算
  • 边界条件测试
  • 错误场景覆盖

练习 2: 实现集成测试

为 Transaction 服务实现集成测试:

  • 数据库操作测试
  • 事务回滚测试
  • 并发安全测试
  • 性能基准测试

练习 3: Mock 设计和实现

设计并实现完整的 Mock 系统:

  • 使用 testify/mock 手动实现
  • 使用 gomock 自动生成
  • 对比两种方式的优缺点

练习 4: 测试自动化

搭建完整的测试自动化流程:

  • CI/CD 集成
  • 覆盖率监控
  • 测试报告生成
  • 性能回归检测

这些练习将帮助你掌握 Go 语言测试的各个方面,建立完整的测试技能体系。

基于 MIT 许可证发布