🧪 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 设计和代码结构。