Skip to content

🔗 集成测试策略

集成测试确保 lzt 项目中不同组件之间的协作正常工作。本文档提供完整的集成测试策略和实践指南。

🎯 集成测试概览

测试层级

集成测试类型

  • 组件集成 - 测试内部组件之间的交互
  • 数据库集成 - 测试数据持久层的操作
  • API 集成 - 测试 HTTP/gRPC 接口
  • 外部服务集成 - 测试第三方服务集成
  • 端到端集成 - 测试完整的用户场景

🗄️ 数据库集成测试

测试数据库设置

go
// internal/app/ledger/repository_test.go
//go:build integration

package ledger

import (
    "context"
    "testing"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func setupTestDB(t *testing.T) *gorm.DB {
    // 使用内存 SQLite 数据库
    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    require.NoError(t, err)
    
    // 运行迁移
    err = db.AutoMigrate(&Ledger{}, &Transaction{}, &Tag{}, &Budget{})
    require.NoError(t, err)
    
    // 清理函数
    t.Cleanup(func() {
        sqlDB, err := db.DB()
        if err == nil {
            sqlDB.Close()
        }
    })
    
    return db
}

func TestTransactionRepository_Integration(t *testing.T) {
    db := setupTestDB(t)
    repo := NewGormTransactionRepository(db)
    
    t.Run("Create and Get Transaction", func(t *testing.T) {
        // 创建测试数据
        tx := &Transaction{
            ID:          "test-tx-1",
            LedgerID:    "test-ledger-1",
            Type:        TransactionTypeExpense,
            Amount:      Money{Amount: 1000, Currency: "CNY"},
            Description: "Test transaction",
        }
        
        // 创建交易
        err := repo.Create(context.Background(), tx)
        require.NoError(t, err)
        
        // 检索交易
        retrieved, err := repo.GetByID(context.Background(), tx.ID)
        require.NoError(t, err)
        
        // 验证数据一致性
        assert.Equal(t, tx.ID, retrieved.ID)
        assert.Equal(t, tx.LedgerID, retrieved.LedgerID)
        assert.Equal(t, tx.Type, retrieved.Type)
        assert.Equal(t, tx.Amount.Amount, retrieved.Amount.Amount)
        assert.Equal(t, tx.Description, retrieved.Description)
        assert.NotZero(t, retrieved.CreatedAt)
    })
    
    t.Run("List Transactions with Filter", func(t *testing.T) {
        // 创建多个测试交易
        transactions := []*Transaction{
            {
                ID:       "tx-1",
                LedgerID: "ledger-1",
                Type:     TransactionTypeExpense,
                Amount:   Money{Amount: 1000, Currency: "CNY"},
            },
            {
                ID:       "tx-2",
                LedgerID: "ledger-1",
                Type:     TransactionTypeIncome,
                Amount:   Money{Amount: 2000, Currency: "CNY"},
            },
            {
                ID:       "tx-3",
                LedgerID: "ledger-2",
                Type:     TransactionTypeExpense,
                Amount:   Money{Amount: 1500, Currency: "CNY"},
            },
        }
        
        for _, tx := range transactions {
            err := repo.Create(context.Background(), tx)
            require.NoError(t, err)
        }
        
        // 测试按账本过滤
        filter := ListTransactionsFilter{
            LedgerID: "ledger-1",
            Limit:    10,
            Offset:   0,
        }
        
        results, err := repo.List(context.Background(), filter)
        require.NoError(t, err)
        assert.Len(t, results, 2)
        
        // 验证所有结果都属于同一账本
        for _, tx := range results {
            assert.Equal(t, "ledger-1", tx.LedgerID)
        }
    })
}

数据库事务测试

go
func TestTransactionRepository_Concurrency(t *testing.T) {
    db := setupTestDB(t)
    repo := NewGormTransactionRepository(db)
    
    t.Run("Concurrent Writes", func(t *testing.T) {
        const numWorkers = 10
        const transactionsPerWorker = 20
        
        var wg sync.WaitGroup
        errors := make(chan error, numWorkers*transactionsPerWorker)
        
        for i := 0; i < numWorkers; i++ {
            wg.Add(1)
            go func(workerID int) {
                defer wg.Done()
                
                for j := 0; j < transactionsPerWorker; j++ {
                    tx := &Transaction{
                        ID:       fmt.Sprintf("tx-%d-%d", workerID, j),
                        LedgerID: fmt.Sprintf("ledger-%d", workerID),
                        Type:     TransactionTypeExpense,
                        Amount:   Money{Amount: int64(j + 1), Currency: "CNY"},
                    }
                    
                    if err := repo.Create(context.Background(), tx); err != nil {
                        errors <- err
                    }
                }
            }(i)
        }
        
        wg.Wait()
        close(errors)
        
        // 检查是否有错误
        for err := range errors {
            t.Errorf("Concurrent write failed: %v", err)
        }
        
        // 验证所有交易都已创建
        totalCount := 0
        for i := 0; i < numWorkers; i++ {
            filter := ListTransactionsFilter{
                LedgerID: fmt.Sprintf("ledger-%d", i),
                Limit:    100,
            }
            results, err := repo.List(context.Background(), filter)
            require.NoError(t, err)
            totalCount += len(results)
        }
        
        assert.Equal(t, numWorkers*transactionsPerWorker, totalCount)
    })
}

🌐 API 集成测试

HTTP API 测试

go
// cmd/ledger/server_test.go
//go:build integration

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func setupTestServer(t *testing.T) *httptest.Server {
    // 设置测试数据库
    db := setupTestDB(t)
    
    // 创建服务实例
    repo := NewGormTransactionRepository(db)
    service := NewTransactionService(repo)
    handler := NewHTTPHandler(service)
    
    return httptest.NewServer(handler)
}

func TestHTTPAPI_Integration(t *testing.T) {
    server := setupTestServer(t)
    defer server.Close()
    
    t.Run("Create Transaction via HTTP", func(t *testing.T) {
        // 准备请求数据
        reqData := map[string]interface{}{
            "ledger_id":   "test-ledger",
            "type":        "expense",
            "amount":      1000,
            "currency":    "CNY",
            "description": "Test transaction",
        }
        
        reqBody, err := json.Marshal(reqData)
        require.NoError(t, err)
        
        // 发送 HTTP 请求
        resp, err := http.Post(
            server.URL+"/api/v1/transactions",
            "application/json",
            bytes.NewBuffer(reqBody),
        )
        require.NoError(t, err)
        defer resp.Body.Close()
        
        // 验证响应
        assert.Equal(t, http.StatusCreated, resp.StatusCode)
        
        var responseData map[string]interface{}
        err = json.NewDecoder(resp.Body).Decode(&responseData)
        require.NoError(t, err)
        
        assert.NotEmpty(t, responseData["id"])
        assert.Equal(t, reqData["ledger_id"], responseData["ledger_id"])
        assert.Equal(t, reqData["type"], responseData["type"])
        assert.Equal(t, float64(reqData["amount"].(int)), responseData["amount"])
    })
    
    t.Run("Get Transaction via HTTP", func(t *testing.T) {
        // 首先创建一个交易
        createReq := map[string]interface{}{
            "ledger_id":   "test-ledger",
            "type":        "income",
            "amount":      2000,
            "currency":    "CNY",
            "description": "Test income",
        }
        
        createBody, _ := json.Marshal(createReq)
        createResp, err := http.Post(
            server.URL+"/api/v1/transactions",
            "application/json",
            bytes.NewBuffer(createBody),
        )
        require.NoError(t, err)
        defer createResp.Body.Close()
        
        var createData map[string]interface{}
        err = json.NewDecoder(createResp.Body).Decode(&createData)
        require.NoError(t, err)
        
        transactionID := createData["id"].(string)
        
        // 获取交易
        getResp, err := http.Get(server.URL + "/api/v1/transactions/" + transactionID)
        require.NoError(t, err)
        defer getResp.Body.Close()
        
        assert.Equal(t, http.StatusOK, getResp.StatusCode)
        
        var getData map[string]interface{}
        err = json.NewDecoder(getResp.Body).Decode(&getData)
        require.NoError(t, err)
        
        assert.Equal(t, transactionID, getData["id"])
        assert.Equal(t, createReq["type"], getData["type"])
        assert.Equal(t, float64(createReq["amount"].(int)), getData["amount"])
    })
}

gRPC 集成测试

go
// internal/app/ledger/grpc_test.go
//go:build integration

package ledger

import (
    "context"
    "net"
    "testing"
    "google.golang.org/grpc"
    "google.golang.org/grpc/test/bufconn"
    pb "lzt/gen/ledger/v1"
)

const bufSize = 1024 * 1024

func setupGRPCTestServer(t *testing.T) (*grpc.ClientConn, pb.LedgerServiceClient) {
    // 创建内存监听器
    lis := bufconn.Listen(bufSize)
    
    // 设置测试数据库和服务
    db := setupTestDB(t)
    repo := NewGormTransactionRepository(db)
    service := NewTransactionService(repo)
    
    // 创建 gRPC 服务器
    server := grpc.NewServer()
    pb.RegisterLedgerServiceServer(server, NewGRPCServer(service))
    
    // 启动服务器
    go func() {
        if err := server.Serve(lis); err != nil {
            t.Logf("Server exited with error: %v", err)
        }
    }()
    
    // 创建客户端连接
    conn, err := grpc.DialContext(
        context.Background(),
        "bufnet",
        grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
            return lis.Dial()
        }),
        grpc.WithInsecure(),
    )
    require.NoError(t, err)
    
    client := pb.NewLedgerServiceClient(conn)
    
    t.Cleanup(func() {
        conn.Close()
        server.Stop()
        lis.Close()
    })
    
    return conn, client
}

func TestGRPCAPI_Integration(t *testing.T) {
    _, client := setupGRPCTestServer(t)
    
    t.Run("Create Transaction via gRPC", func(t *testing.T) {
        req := &pb.CreateTransactionRequest{
            LedgerId:    "test-ledger",
            Type:        pb.TransactionType_TRANSACTION_TYPE_EXPENSE,
            Amount:      &pb.Money{Amount: 1500, Currency: pb.Currency_CURRENCY_CNY},
            Description: "gRPC test transaction",
        }
        
        resp, err := client.CreateTransaction(context.Background(), req)
        require.NoError(t, err)
        require.NotNil(t, resp)
        require.NotNil(t, resp.Transaction)
        
        assert.NotEmpty(t, resp.Transaction.Id)
        assert.Equal(t, req.LedgerId, resp.Transaction.LedgerId)
        assert.Equal(t, req.Type, resp.Transaction.Type)
        assert.Equal(t, req.Amount.Amount, resp.Transaction.Amount.Amount)
        assert.Equal(t, req.Description, resp.Transaction.Description)
    })
    
    t.Run("List Transactions via gRPC", func(t *testing.T) {
        // 创建几个测试交易
        for i := 0; i < 3; i++ {
            req := &pb.CreateTransactionRequest{
                LedgerId: "list-test-ledger",
                Type:     pb.TransactionType_TRANSACTION_TYPE_EXPENSE,
                Amount:   &pb.Money{Amount: int64((i + 1) * 1000), Currency: pb.Currency_CURRENCY_CNY},
            }
            
            _, err := client.CreateTransaction(context.Background(), req)
            require.NoError(t, err)
        }
        
        // 列出交易
        listReq := &pb.ListTransactionsRequest{
            LedgerId: "list-test-ledger",
            Pagination: &pb.PaginationRequest{
                PageSize: 10,
            },
        }
        
        listResp, err := client.ListTransactions(context.Background(), listReq)
        require.NoError(t, err)
        require.NotNil(t, listResp)
        
        assert.Len(t, listResp.Transactions, 3)
        assert.Equal(t, int64(3), listResp.Pagination.TotalCount)
        
        // 验证排序(应该按创建时间降序)
        for i := 0; i < len(listResp.Transactions)-1; i++ {
            current := listResp.Transactions[i]
            next := listResp.Transactions[i+1]
            
            assert.True(t, 
                current.CreatedAt.AsTime().After(next.CreatedAt.AsTime()) ||
                current.CreatedAt.AsTime().Equal(next.CreatedAt.AsTime()),
                "Transactions should be sorted by creation time (desc)",
            )
        }
    })
}

🔄 服务集成测试

跨服务集成

go
func TestLedgerBudgetIntegration(t *testing.T) {
    db := setupTestDB(t)
    
    // 创建服务实例
    ledgerRepo := NewGormLedgerRepository(db)
    transactionRepo := NewGormTransactionRepository(db)
    budgetRepo := NewGormBudgetRepository(db)
    
    ledgerService := NewLedgerService(ledgerRepo)
    transactionService := NewTransactionService(transactionRepo)
    budgetService := NewBudgetService(budgetRepo, transactionService)
    
    t.Run("Budget Usage Calculation Integration", func(t *testing.T) {
        // 1. 创建账本
        ledger, err := ledgerService.CreateLedger(context.Background(), &CreateLedgerRequest{
            Name: "Integration Test Ledger",
            Type: LedgerTypePersonal,
        })
        require.NoError(t, err)
        
        // 2. 创建预算
        budget, err := budgetService.CreateBudget(context.Background(), &CreateBudgetRequest{
            LedgerID: ledger.ID,
            Name:     "Monthly Budget",
            Amount:   Money{Amount: 10000, Currency: "CNY"},
            Period:   BudgetPeriodMonthly,
        })
        require.NoError(t, err)
        
        // 3. 创建一些交易
        transactions := []struct {
            amount int64
            txType TransactionType
        }{
            {3000, TransactionTypeExpense},
            {2000, TransactionTypeExpense},
            {5000, TransactionTypeIncome}, // 收入不影响预算
            {1000, TransactionTypeExpense},
        }
        
        for i, tx := range transactions {
            _, err := transactionService.CreateTransaction(context.Background(), &CreateTransactionRequest{
                LedgerID:    ledger.ID,
                Type:        tx.txType,
                Amount:      Money{Amount: tx.amount, Currency: "CNY"},
                Description: fmt.Sprintf("Test transaction %d", i+1),
            })
            require.NoError(t, err)
        }
        
        // 4. 计算预算使用情况
        usage, err := budgetService.GetBudgetUsage(context.Background(), budget.ID)
        require.NoError(t, err)
        
        // 5. 验证计算结果
        expectedUsed := int64(3000 + 2000 + 1000) // 只有支出计入预算
        expectedPercentage := float64(expectedUsed) / float64(budget.Amount.Amount) * 100
        
        assert.Equal(t, expectedUsed, usage.UsedAmount.Amount)
        assert.Equal(t, expectedPercentage, usage.Percentage)
        assert.Equal(t, budget.Amount.Amount-expectedUsed, usage.RemainingAmount.Amount)
    })
}

事件驱动集成测试

go
func TestEventDrivenIntegration(t *testing.T) {
    db := setupTestDB(t)
    eventBus := NewInMemoryEventBus()
    
    // 设置服务
    transactionService := NewTransactionService(db, eventBus)
    notificationService := NewNotificationService(eventBus)
    analyticsService := NewAnalyticsService(db, eventBus)
    
    // 启动事件监听器
    go notificationService.Start()
    go analyticsService.Start()
    
    t.Run("Transaction Created Event Flow", func(t *testing.T) {
        // 创建一个高额交易,应该触发通知
        req := &CreateTransactionRequest{
            LedgerID:    "test-ledger",
            Type:        TransactionTypeExpense,
            Amount:      Money{Amount: 50000, Currency: "CNY"}, // 高额交易
            Description: "大额支出测试",
        }
        
        tx, err := transactionService.CreateTransaction(context.Background(), req)
        require.NoError(t, err)
        
        // 等待事件处理
        time.Sleep(100 * time.Millisecond)
        
        // 验证通知服务收到了事件
        notifications := notificationService.GetSentNotifications()
        assert.Len(t, notifications, 1)
        assert.Contains(t, notifications[0].Message, "大额支出")
        assert.Equal(t, tx.ID, notifications[0].TransactionID)
        
        // 验证分析服务更新了统计
        stats := analyticsService.GetLedgerStats("test-ledger")
        assert.Equal(t, int64(1), stats.TransactionCount)
        assert.Equal(t, req.Amount.Amount, stats.TotalExpense.Amount)
    })
}

🧪 测试环境管理

Docker Compose 测试环境

yaml
# docker-compose.test.yml
version: '3.8'

services:
  mysql-test:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: testpassword
      MYSQL_DATABASE: ledger_test
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password
    ports:
      - "3307:3306"
    volumes:
      - ./scripts/test-schema.sql:/docker-entrypoint-initdb.d/schema.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 5s
      retries: 10

  redis-test:
    image: redis:7-alpine
    ports:
      - "6380:6379"
    command: redis-server --appendonly yes

  ledger-test:
    build: .
    depends_on:
      mysql-test:
        condition: service_healthy
      redis-test:
        condition: service_started
    environment:
      - DB_HOST=mysql-test
      - DB_PORT=3306
      - DB_USER=test_user
      - DB_PASSWORD=test_password
      - DB_NAME=ledger_test
      - REDIS_HOST=redis-test
      - REDIS_PORT=6379
    command: ["go", "test", "-tags=integration", "./..."]

测试配置管理

go
// internal/pkg/config/test_config.go
func NewTestConfig() *Config {
    return &Config{
        Database: DatabaseConfig{
            Driver:   "mysql",
            Host:     getEnv("DB_HOST", "localhost"),
            Port:     getEnvAsInt("DB_PORT", 3307),
            User:     getEnv("DB_USER", "test_user"),
            Password: getEnv("DB_PASSWORD", "test_password"),
            Name:     getEnv("DB_NAME", "ledger_test"),
        },
        Redis: RedisConfig{
            Host: getEnv("REDIS_HOST", "localhost"),
            Port: getEnvAsInt("REDIS_PORT", 6380),
        },
        Server: ServerConfig{
            Port: getEnvAsInt("SERVER_PORT", 8081),
        },
    }
}

📊 集成测试最佳实践

1. 测试隔离

go
func TestWithTransactionRollback(t *testing.T) {
    db := setupTestDB(t)
    
    // 开始事务
    tx := db.Begin()
    defer func() {
        // 测试结束后回滚事务
        tx.Rollback()
    }()
    
    repo := NewGormTransactionRepository(tx)
    
    // 执行测试...
    // 所有数据库操作都在事务中,测试结束后会回滚
}

2. 并行测试

go
func TestConcurrentOperations(t *testing.T) {
    // 标记为并行测试
    t.Parallel()
    
    db := setupTestDB(t)
    
    // 使用独立的数据库实例或命名空间
    testNamespace := fmt.Sprintf("test_%d", time.Now().UnixNano())
    
    // 执行测试...
}

3. 测试数据管理

go
type TestDataManager struct {
    db *gorm.DB
}

func NewTestDataManager(db *gorm.DB) *TestDataManager {
    return &TestDataManager{db: db}
}

func (m *TestDataManager) CreateTestLedger(name string) *Ledger {
    ledger := &Ledger{
        ID:   generateTestID(),
        Name: name,
        Type: LedgerTypePersonal,
    }
    
    err := m.db.Create(ledger).Error
    if err != nil {
        panic(fmt.Sprintf("Failed to create test ledger: %v", err))
    }
    
    return ledger
}

func (m *TestDataManager) CreateTestTransaction(ledgerID string, amount int64) *Transaction {
    tx := &Transaction{
        ID:       generateTestID(),
        LedgerID: ledgerID,
        Type:     TransactionTypeExpense,
        Amount:   Money{Amount: amount, Currency: "CNY"},
    }
    
    err := m.db.Create(tx).Error
    if err != nil {
        panic(fmt.Sprintf("Failed to create test transaction: %v", err))
    }
    
    return tx
}

func (m *TestDataManager) Cleanup() {
    // 清理测试数据
    m.db.Exec("DELETE FROM transactions")
    m.db.Exec("DELETE FROM ledgers")
}

🚀 CI/CD 集成测试

GitHub Actions 配置

yaml
name: Integration Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: ledger_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: '1.24.3'
    
    - name: Wait for MySQL
      run: |
        while ! mysqladmin ping -h127.0.0.1 -P3306 -uroot -ppassword --silent; do
          sleep 1
        done
    
    - name: Run integration tests
      run: go test -tags=integration -v ./...
      env:
        DB_HOST: 127.0.0.1
        DB_PORT: 3306
        DB_USER: root
        DB_PASSWORD: password
        DB_NAME: ledger_test

📚 相关资源

项目测试文档

外部参考


💡 集成测试建议: 集成测试应该关注组件间的交互和数据流。保持测试环境的一致性,使用适当的测试替身来隔离外部依赖。

基于 MIT 许可证发布