Skip to content

🔬 单元测试指南

单元测试是 lzt 项目质量保证的基础,本文档提供编写高质量单元测试的完整指南。

🎯 单元测试原则

核心特征

  • 独立性 - 测试之间相互独立,不依赖执行顺序
  • 可重复性 - 在任何环境中都能产生相同结果
  • 快速执行 - 单个测试应该在毫秒级完成
  • 自我验证 - 测试结果明确,不需要人工判断

F.I.R.S.T 原则

  • Fast - 快速执行
  • Independent - 独立运行
  • Repeatable - 可重复执行
  • Self-Validating - 自我验证
  • Timely - 及时编写

📝 测试结构

AAA 模式

go
func TestCreateTransaction(t *testing.T) {
    // Arrange - 准备测试数据
    service := NewTransactionService(mockRepo, mockValidator)
    req := &CreateTransactionRequest{
        LedgerID: "test-ledger",
        Type:     TransactionTypeExpense,
        Amount:   Money{Amount: 1000, Currency: "CNY"},
    }
    
    // Act - 执行被测试的操作
    result, err := service.CreateTransaction(context.Background(), req)
    
    // Assert - 验证结果
    assert.NoError(t, err)
    assert.NotNil(t, result)
    assert.Equal(t, req.LedgerID, result.LedgerID)
}

Given-When-Then 模式

go
func TestBudgetCalculation(t *testing.T) {
    t.Run("Given valid budget and transactions, When calculating usage, Then returns correct percentage", func(t *testing.T) {
        // Given
        budget := &Budget{
            Amount: Money{Amount: 10000, Currency: "CNY"},
        }
        transactions := []*Transaction{
            {Amount: Money{Amount: 3000, Currency: "CNY"}},
            {Amount: Money{Amount: 2000, Currency: "CNY"}},
        }
        
        // When
        usage := CalculateBudgetUsage(budget, transactions)
        
        // Then
        assert.Equal(t, 50.0, usage.Percentage)
        assert.Equal(t, int64(5000), usage.UsedAmount.Amount)
        assert.Equal(t, int64(5000), usage.RemainingAmount.Amount)
    })
}

🧪 测试分类和策略

1. 纯函数测试

go
// pkg/money/money_test.go
func TestMoneyAdd(t *testing.T) {
    tests := []struct {
        name     string
        money1   Money
        money2   Money
        expected Money
        wantErr  bool
    }{
        {
            name:     "same currency addition",
            money1:   Money{Amount: 1000, Currency: "CNY"},
            money2:   Money{Amount: 2000, Currency: "CNY"},
            expected: Money{Amount: 3000, Currency: "CNY"},
            wantErr:  false,
        },
        {
            name:    "different currency addition",
            money1:  Money{Amount: 1000, Currency: "CNY"},
            money2:  Money{Amount: 2000, Currency: "USD"},
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := tt.money1.Add(tt.money2)
            
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            
            assert.NoError(t, err)
            assert.Equal(t, tt.expected, result)
        })
    }
}

2. 方法测试

go
// pkg/bubble/progress_test.go
func TestProgressModel_Update(t *testing.T) {
    model := NewProgressModel([]Task{
        NewSimpleTask("Task 1", "data1"),
        NewSimpleTask("Task 2", "data2"),
    })
    
    // 测试任务开始
    msg := TaskStartedMsg{TaskIndex: 0}
    updatedModel, cmd := model.Update(msg)
    
    assert.NotNil(t, updatedModel)
    assert.NotNil(t, cmd)
    
    progressModel := updatedModel.(ProgressModel)
    assert.Equal(t, 0, progressModel.currentTaskIndex)
    assert.True(t, progressModel.tasks[0].IsProcessing())
}

3. 接口测试

go
// internal/app/ledger/service_test.go
func TestTransactionService_Create(t *testing.T) {
    // 设置模拟对象
    mockRepo := &MockTransactionRepository{}
    mockValidator := &MockValidator{}
    service := NewTransactionService(mockRepo, mockValidator)
    
    // 设置期望行为
    mockValidator.On("Validate", mock.Anything).Return(nil)
    mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*Transaction")).Return(nil)
    
    // 执行测试
    req := &CreateTransactionRequest{
        LedgerID: "test-ledger",
        Type:     TransactionTypeExpense,
        Amount:   Money{Amount: 1000, Currency: "CNY"},
    }
    
    result, err := service.CreateTransaction(context.Background(), req)
    
    // 验证结果
    assert.NoError(t, err)
    assert.NotNil(t, result)
    assert.NotEmpty(t, result.ID)
    
    // 验证模拟对象的调用
    mockValidator.AssertExpectations(t)
    mockRepo.AssertExpectations(t)
}

🔧 测试工具和技巧

断言库使用

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

func TestAssertions(t *testing.T) {
    // require - 失败时停止测试
    user := getUser()
    require.NotNil(t, user, "用户不能为 nil")
    
    // assert - 失败时继续执行
    assert.Equal(t, "John", user.Name)
    assert.True(t, user.Age > 0)
    assert.Contains(t, user.Email, "@")
    
    // 集合断言
    expectedRoles := []string{"admin", "user"}
    assert.ElementsMatch(t, expectedRoles, user.Roles)
    
    // 错误断言
    err := user.Validate()
    assert.NoError(t, err)
    
    // 自定义断言
    assert.Condition(t, func() bool {
        return user.CreatedAt.Before(time.Now())
    }, "创建时间应该在当前时间之前")
}

测试辅助函数

go
// 测试数据构建
func createTestTransaction(options ...func(*Transaction)) *Transaction {
    tx := &Transaction{
        ID:          generateTestID(),
        LedgerID:    "default-ledger",
        Type:        TransactionTypeExpense,
        Amount:      Money{Amount: 1000, Currency: "CNY"},
        Description: "Test transaction",
        CreatedAt:   time.Now(),
    }
    
    for _, option := range options {
        option(tx)
    }
    
    return tx
}

// 使用选项模式
func withAmount(amount int64) func(*Transaction) {
    return func(tx *Transaction) {
        tx.Amount.Amount = amount
    }
}

func withType(txType TransactionType) func(*Transaction) {
    return func(tx *Transaction) {
        tx.Type = txType
    }
}

// 测试中使用
func TestTransactionProcessing(t *testing.T) {
    tx := createTestTransaction(
        withAmount(5000),
        withType(TransactionTypeIncome),
    )
    
    result := ProcessTransaction(tx)
    assert.Equal(t, int64(5000), result.ProcessedAmount)
}

表驱动测试

go
func TestValidateTransactionRequest(t *testing.T) {
    tests := []struct {
        name    string
        req     *CreateTransactionRequest
        wantErr bool
        errMsg  string
    }{
        {
            name: "valid request",
            req: &CreateTransactionRequest{
                LedgerID: "valid-ledger",
                Type:     TransactionTypeExpense,
                Amount:   Money{Amount: 1000, Currency: "CNY"},
            },
            wantErr: false,
        },
        {
            name: "empty ledger ID",
            req: &CreateTransactionRequest{
                LedgerID: "",
                Type:     TransactionTypeExpense,
                Amount:   Money{Amount: 1000, Currency: "CNY"},
            },
            wantErr: true,
            errMsg:  "ledger_id is required",
        },
        {
            name: "negative amount",
            req: &CreateTransactionRequest{
                LedgerID: "valid-ledger",
                Type:     TransactionTypeExpense,
                Amount:   Money{Amount: -1000, Currency: "CNY"},
            },
            wantErr: true,
            errMsg:  "amount must be positive",
        },
    }
    
    validator := NewTransactionValidator()
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validator.Validate(tt.req)
            
            if tt.wantErr {
                assert.Error(t, err)
                if tt.errMsg != "" {
                    assert.Contains(t, err.Error(), tt.errMsg)
                }
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

🎭 模拟和存根

接口模拟

go
// 定义接口
type PaymentGateway interface {
    ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error)
    RefundPayment(ctx context.Context, paymentID string) error
}

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

func (m *MockPaymentGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
    args := m.Called(ctx, req)
    return args.Get(0).(*PaymentResponse), args.Error(1)
}

func (m *MockPaymentGateway) RefundPayment(ctx context.Context, paymentID string) error {
    args := m.Called(ctx, paymentID)
    return args.Error(0)
}

// 测试中使用
func TestPaymentService_ProcessPayment(t *testing.T) {
    mockGateway := new(MockPaymentGateway)
    service := NewPaymentService(mockGateway)
    
    // 设置模拟行为
    expectedResponse := &PaymentResponse{
        ID:     "payment-123",
        Status: "completed",
    }
    mockGateway.On("ProcessPayment", mock.Anything, mock.AnythingOfType("*PaymentRequest")).
        Return(expectedResponse, nil)
    
    // 执行测试
    req := &PaymentRequest{Amount: 1000, Currency: "CNY"}
    response, err := service.ProcessPayment(context.Background(), req)
    
    // 验证
    assert.NoError(t, err)
    assert.Equal(t, expectedResponse.ID, response.ID)
    mockGateway.AssertExpectations(t)
}

测试替身模式

go
// Fake - 有工作实现但简化的版本
type FakeTransactionRepository struct {
    transactions map[string]*Transaction
    mutex        sync.RWMutex
}

func NewFakeTransactionRepository() *FakeTransactionRepository {
    return &FakeTransactionRepository{
        transactions: make(map[string]*Transaction),
    }
}

func (r *FakeTransactionRepository) Create(ctx context.Context, tx *Transaction) error {
    r.mutex.Lock()
    defer r.mutex.Unlock()
    
    if tx.ID == "" {
        tx.ID = generateID()
    }
    r.transactions[tx.ID] = tx
    return nil
}

func (r *FakeTransactionRepository) GetByID(ctx context.Context, id string) (*Transaction, error) {
    r.mutex.RLock()
    defer r.mutex.RUnlock()
    
    tx, exists := r.transactions[id]
    if !exists {
        return nil, ErrTransactionNotFound
    }
    return tx, nil
}

// Stub - 预定义响应的简单实现
type StubEmailService struct {
    SentEmails []Email
}

func (s *StubEmailService) SendEmail(email Email) error {
    s.SentEmails = append(s.SentEmails, email)
    return nil
}

func TestNotificationService_SendWelcomeEmail(t *testing.T) {
    emailStub := &StubEmailService{}
    service := NewNotificationService(emailStub)
    
    err := service.SendWelcomeEmail("user@example.com")
    
    assert.NoError(t, err)
    assert.Len(t, emailStub.SentEmails, 1)
    assert.Equal(t, "user@example.com", emailStub.SentEmails[0].To)
    assert.Contains(t, emailStub.SentEmails[0].Subject, "Welcome")
}

🔍 边界条件测试

输入边界测试

go
func TestValidateAmount_BoundaryConditions(t *testing.T) {
    tests := []struct {
        name    string
        amount  int64
        wantErr bool
    }{
        {"zero amount", 0, true},
        {"minimum valid amount", 1, false},
        {"normal amount", 1000, false},
        {"maximum int64", math.MaxInt64, false},
        {"negative amount", -1, true},
        {"large negative", math.MinInt64, true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            money := Money{Amount: tt.amount, Currency: "CNY"}
            err := ValidateAmount(money)
            
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

状态转换测试

go
func TestTransactionStatus_Transitions(t *testing.T) {
    tests := []struct {
        name           string
        currentStatus  TransactionStatus
        newStatus      TransactionStatus
        expectedResult bool
    }{
        {"pending to completed", StatusPending, StatusCompleted, true},
        {"pending to cancelled", StatusPending, StatusCancelled, true},
        {"completed to pending", StatusCompleted, StatusPending, false},
        {"completed to refunded", StatusCompleted, StatusRefunded, true},
        {"cancelled to completed", StatusCancelled, StatusCompleted, false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tx := &Transaction{Status: tt.currentStatus}
            
            err := tx.ChangeStatus(tt.newStatus)
            
            if tt.expectedResult {
                assert.NoError(t, err)
                assert.Equal(t, tt.newStatus, tx.Status)
            } else {
                assert.Error(t, err)
                assert.Equal(t, tt.currentStatus, tx.Status)
            }
        })
    }
}

📊 测试覆盖率

覆盖率检查

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 ./pkg/bubble/

覆盖率目标

go
// 确保关键函数有测试覆盖
func TestCriticalFunction_AllPaths(t *testing.T) {
    // 测试所有代码路径
    t.Run("success path", func(t *testing.T) {
        // 正常流程测试
    })
    
    t.Run("error path 1", func(t *testing.T) {
        // 错误处理测试
    })
    
    t.Run("edge case", func(t *testing.T) {
        // 边界情况测试
    })
}

🚀 高级测试技巧

测试套件

go
type TransactionServiceTestSuite struct {
    suite.Suite
    service *TransactionService
    mockRepo *MockTransactionRepository
}

func (suite *TransactionServiceTestSuite) SetupTest() {
    suite.mockRepo = new(MockTransactionRepository)
    suite.service = NewTransactionService(suite.mockRepo)
}

func (suite *TransactionServiceTestSuite) TearDownTest() {
    suite.mockRepo.AssertExpectations(suite.T())
}

func (suite *TransactionServiceTestSuite) TestCreateTransaction() {
    suite.mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*Transaction")).Return(nil)
    
    req := &CreateTransactionRequest{
        LedgerID: "test-ledger",
        Type:     TransactionTypeExpense,
        Amount:   Money{Amount: 1000, Currency: "CNY"},
    }
    
    result, err := suite.service.CreateTransaction(context.Background(), req)
    
    suite.NoError(err)
    suite.NotNil(result)
}

func TestTransactionServiceTestSuite(t *testing.T) {
    suite.Run(t, new(TransactionServiceTestSuite))
}

属性测试

go
func TestMoneyAddition_Properties(t *testing.T) {
    // 交换律:a + b = b + a
    t.Run("commutative property", func(t *testing.T) {
        for i := 0; i < 100; i++ {
            a := Money{Amount: int64(rand.Intn(10000)), Currency: "CNY"}
            b := Money{Amount: int64(rand.Intn(10000)), Currency: "CNY"}
            
            result1, err1 := a.Add(b)
            result2, err2 := b.Add(a)
            
            assert.Equal(t, err1, err2)
            if err1 == nil {
                assert.Equal(t, result1, result2)
            }
        }
    })
    
    // 结合律:(a + b) + c = a + (b + c)
    t.Run("associative property", func(t *testing.T) {
        for i := 0; i < 100; i++ {
            a := Money{Amount: int64(rand.Intn(1000)), Currency: "CNY"}
            b := Money{Amount: int64(rand.Intn(1000)), Currency: "CNY"}
            c := Money{Amount: int64(rand.Intn(1000)), Currency: "CNY"}
            
            // (a + b) + c
            ab, err1 := a.Add(b)
            require.NoError(t, err1)
            result1, err2 := ab.Add(c)
            require.NoError(t, err2)
            
            // a + (b + c)
            bc, err3 := b.Add(c)
            require.NoError(t, err3)
            result2, err4 := a.Add(bc)
            require.NoError(t, err4)
            
            assert.Equal(t, result1, result2)
        }
    })
}

📚 相关资源

项目测试文档

外部参考


💡 测试建议: 单元测试应该快速、独立、可重复。重点关注业务逻辑和边界条件,确保每个函数都有充分的测试覆盖。

基于 MIT 许可证发布