Skip to content

🧪 TDD 测试驱动开发

测试驱动开发 (Test-Driven Development, TDD) 是 lzt 项目的核心开发方法论,确保代码质量和设计合理性。

🎯 TDD 核心理念

TDD 循环

三个阶段详解

🔴 红色阶段 (Red)

  • 目标: 编写一个失败的测试
  • 原则: 测试应该描述期望的行为
  • 实践: 先思考接口设计,再考虑实现

🟢 绿色阶段 (Green)

  • 目标: 编写最少的代码使测试通过
  • 原则: 快速实现,不考虑完美性
  • 实践: 关注功能实现,忽略代码质量

🔵 重构阶段 (Refactor)

  • 目标: 在保持测试通过的前提下优化代码
  • 原则: 提高代码质量,消除重复
  • 实践: 持续改进设计和性能

📝 实践示例

示例1: Bubble 进度条组件

第1步: 红色阶段

go
// pkg/bubble/progress_test.go
func TestProcessStrings(t *testing.T) {
    // 期望的行为:处理字符串列表并返回结果
    items := []string{"task1", "task2", "task3"}
    var processed []string
    
    processFunc := func(item string) error {
        processed = append(processed, "processed_"+item)
        return nil
    }
    
    err := bubble.ProcessStrings(items, processFunc)
    
    assert.NoError(t, err)
    assert.Equal(t, 3, len(processed))
    assert.Equal(t, "processed_task1", processed[0])
}

第2步: 绿色阶段

go
// pkg/bubble/convenience.go
func ProcessStrings(items []string, processFunc func(string) error) error {
    for _, item := range items {
        if err := processFunc(item); err != nil {
            return err
        }
    }
    return nil
}

第3步: 重构阶段

go
// pkg/bubble/convenience.go (重构后)
func ProcessStrings(items []string, processFunc func(string) error) error {
    if processFunc == nil {
        return errors.New("processFunc cannot be nil")
    }
    
    for i, item := range items {
        if err := processFunc(item); err != nil {
            return fmt.Errorf("processing item %d (%s) failed: %w", i, item, err)
        }
    }
    return nil
}

示例2: Ledger 交易创建

第1步: 红色阶段

go
// internal/app/ledger/service_test.go
func TestCreateTransaction(t *testing.T) {
    // 准备测试数据
    service := setupTestService(t)
    req := &CreateTransactionRequest{
        LedgerID:    "ledger-123",
        Type:        TransactionTypeExpense,
        Amount:      Money{Amount: 1000, Currency: "CNY"},
        Description: "午餐",
    }
    
    // 执行测试
    tx, err := service.CreateTransaction(context.Background(), req)
    
    // 验证结果
    assert.NoError(t, err)
    assert.NotEmpty(t, tx.ID)
    assert.Equal(t, req.LedgerID, tx.LedgerID)
    assert.Equal(t, req.Type, tx.Type)
    assert.Equal(t, req.Amount, tx.Amount)
}

第2步: 绿色阶段

go
// internal/app/ledger/service.go
func (s *Service) CreateTransaction(ctx context.Context, req *CreateTransactionRequest) (*Transaction, error) {
    tx := &Transaction{
        ID:          generateID(),
        LedgerID:    req.LedgerID,
        Type:        req.Type,
        Amount:      req.Amount,
        Description: req.Description,
        CreatedAt:   time.Now(),
    }
    
    return tx, nil
}

第3步: 重构阶段

go
// internal/app/ledger/service.go (重构后)
func (s *Service) CreateTransaction(ctx context.Context, req *CreateTransactionRequest) (*Transaction, error) {
    // 验证请求
    if err := s.validateCreateTransactionRequest(req); err != nil {
        return nil, fmt.Errorf("validation failed: %w", err)
    }
    
    // 检查账本是否存在
    if _, err := s.repo.GetLedger(ctx, req.LedgerID); err != nil {
        return nil, fmt.Errorf("ledger not found: %w", err)
    }
    
    // 创建交易实体
    tx := &Transaction{
        ID:          s.idGen.Generate(),
        LedgerID:    req.LedgerID,
        Type:        req.Type,
        Amount:      req.Amount,
        Description: req.Description,
        CreatedAt:   time.Now(),
    }
    
    // 持久化
    if err := s.repo.CreateTransaction(ctx, tx); err != nil {
        return nil, fmt.Errorf("create transaction failed: %w", err)
    }
    
    return tx, nil
}

🔧 TDD 工具和技巧

测试工具栈

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

表驱动测试

go
func TestValidateAmount(t *testing.T) {
    tests := []struct {
        name    string
        amount  Money
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid positive amount",
            amount:  Money{Amount: 1000, Currency: "CNY"},
            wantErr: false,
        },
        {
            name:    "zero amount",
            amount:  Money{Amount: 0, Currency: "CNY"},
            wantErr: true,
            errMsg:  "amount must be positive",
        },
        {
            name:    "negative amount",
            amount:  Money{Amount: -1000, Currency: "CNY"},
            wantErr: true,
            errMsg:  "amount must be positive",
        },
        {
            name:    "invalid currency",
            amount:  Money{Amount: 1000, Currency: ""},
            wantErr: true,
            errMsg:  "currency is required",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateAmount(tt.amount)
            
            if tt.wantErr {
                assert.Error(t, err)
                assert.Contains(t, err.Error(), tt.errMsg)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

模拟和依赖注入

go
// 定义接口
type TransactionRepository interface {
    Create(ctx context.Context, tx *Transaction) error
    GetByID(ctx context.Context, id string) (*Transaction, error)
}

// 模拟实现
type MockTransactionRepository struct {
    mock.Mock
}

func (m *MockTransactionRepository) Create(ctx context.Context, tx *Transaction) error {
    args := m.Called(ctx, tx)
    return args.Error(0)
}

func (m *MockTransactionRepository) GetByID(ctx context.Context, id string) (*Transaction, error) {
    args := m.Called(ctx, id)
    return args.Get(0).(*Transaction), args.Error(1)
}

// 测试中使用模拟
func TestServiceWithMock(t *testing.T) {
    mockRepo := new(MockTransactionRepository)
    service := NewService(mockRepo)
    
    // 设置期望
    mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*Transaction")).Return(nil)
    
    // 执行测试
    req := &CreateTransactionRequest{
        LedgerID: "test-ledger",
        Type:     TransactionTypeExpense,
        Amount:   Money{Amount: 1000, Currency: "CNY"},
    }
    
    tx, err := service.CreateTransaction(context.Background(), req)
    
    // 验证
    assert.NoError(t, err)
    assert.NotNil(t, tx)
    mockRepo.AssertExpectations(t)
}

📊 TDD 最佳实践

1. 测试命名规范

go
// ✅ 好的测试名称
func TestCreateTransaction_ValidRequest_ReturnsTransaction(t *testing.T) {}
func TestCreateTransaction_InvalidLedgerID_ReturnsError(t *testing.T) {}
func TestCreateTransaction_NilRequest_ReturnsValidationError(t *testing.T) {}

// ❌ 不好的测试名称
func TestCreateTransaction1(t *testing.T) {}
func TestCreateTransaction2(t *testing.T) {}
func TestError(t *testing.T) {}

2. 单一断言原则

go
// ✅ 每个测试一个关注点
func TestCreateTransaction_SetsCorrectID(t *testing.T) {
    tx, err := service.CreateTransaction(ctx, req)
    require.NoError(t, err)
    assert.NotEmpty(t, tx.ID)
}

func TestCreateTransaction_SetsCorrectTimestamp(t *testing.T) {
    before := time.Now()
    tx, err := service.CreateTransaction(ctx, req)
    after := time.Now()
    
    require.NoError(t, err)
    assert.True(t, tx.CreatedAt.After(before))
    assert.True(t, tx.CreatedAt.Before(after))
}

3. 测试数据构建器

go
// 测试数据构建器
type TransactionRequestBuilder struct {
    req *CreateTransactionRequest
}

func NewTransactionRequestBuilder() *TransactionRequestBuilder {
    return &TransactionRequestBuilder{
        req: &CreateTransactionRequest{
            LedgerID: "default-ledger",
            Type:     TransactionTypeExpense,
            Amount:   Money{Amount: 1000, Currency: "CNY"},
        },
    }
}

func (b *TransactionRequestBuilder) WithLedgerID(id string) *TransactionRequestBuilder {
    b.req.LedgerID = id
    return b
}

func (b *TransactionRequestBuilder) WithAmount(amount int64) *TransactionRequestBuilder {
    b.req.Amount.Amount = amount
    return b
}

func (b *TransactionRequestBuilder) Build() *CreateTransactionRequest {
    return b.req
}

// 使用构建器
func TestCreateTransaction_WithCustomData(t *testing.T) {
    req := NewTransactionRequestBuilder().
        WithLedgerID("custom-ledger").
        WithAmount(5000).
        Build()
    
    tx, err := service.CreateTransaction(ctx, req)
    
    assert.NoError(t, err)
    assert.Equal(t, "custom-ledger", tx.LedgerID)
    assert.Equal(t, int64(5000), tx.Amount.Amount)
}

🚀 高级 TDD 技巧

1. 契约测试

go
// 定义接口契约测试
func TestTransactionRepositoryContract(t *testing.T, repo TransactionRepository) {
    t.Run("Create_ValidTransaction_Success", func(t *testing.T) {
        tx := &Transaction{
            ID:       "test-tx-1",
            LedgerID: "test-ledger",
            Type:     TransactionTypeExpense,
            Amount:   Money{Amount: 1000, Currency: "CNY"},
        }
        
        err := repo.Create(context.Background(), tx)
        assert.NoError(t, err)
    })
    
    t.Run("GetByID_ExistingTransaction_ReturnsTransaction", func(t *testing.T) {
        // 测试获取已存在的交易
    })
}

// 不同实现的测试
func TestGormTransactionRepository(t *testing.T) {
    db := setupTestDB(t)
    repo := NewGormTransactionRepository(db)
    TestTransactionRepositoryContract(t, repo)
}

func TestMemoryTransactionRepository(t *testing.T) {
    repo := NewMemoryTransactionRepository()
    TestTransactionRepositoryContract(t, repo)
}

2. 并发测试

go
func TestConcurrentTransactionCreation(t *testing.T) {
    service := setupTestService(t)
    const numGoroutines = 10
    const transactionsPerGoroutine = 100
    
    var wg sync.WaitGroup
    errors := make(chan error, numGoroutines*transactionsPerGoroutine)
    
    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func(goroutineID int) {
            defer wg.Done()
            
            for j := 0; j < transactionsPerGoroutine; j++ {
                req := &CreateTransactionRequest{
                    LedgerID: fmt.Sprintf("ledger-%d", goroutineID),
                    Type:     TransactionTypeExpense,
                    Amount:   Money{Amount: int64(j + 1), Currency: "CNY"},
                }
                
                _, err := service.CreateTransaction(context.Background(), req)
                if err != nil {
                    errors <- err
                }
            }
        }(i)
    }
    
    wg.Wait()
    close(errors)
    
    for err := range errors {
        t.Errorf("Concurrent operation failed: %v", err)
    }
}

📚 相关资源

项目实践

外部参考


💡 TDD 建议: TDD 不仅仅是测试方法,更是设计方法。通过先写测试,我们能够更好地思考 API 设计和代码结构。

基于 MIT 许可证发布