🔬 单元测试指南
单元测试是 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)
}
})
}📚 相关资源
项目测试文档
- TDD 测试驱动开发 - TDD 实践指南
- 集成测试 - 组件集成测试
- 基准测试 - 性能测试
外部参考
💡 测试建议: 单元测试应该快速、独立、可重复。重点关注业务逻辑和边界条件,确保每个函数都有充分的测试覆盖。