Ledger 测试策略
概述
Ledger 模块采用 测试驱动开发(TDD) 作为核心开发方法,建立了完整的测试体系来保证代码质量和系统可靠性。本文档定义了测试策略、质量标准和实施计划。
测试目标
- 100% 覆盖: pkg 包必须达到 100% 测试覆盖率
- 质量保证: 通过全面测试确保系统稳定性
- 快速反馈: 测试应在秒级或分钟级完成
- 持续集成: 自动化测试流程集成到 CI/CD
- 文档驱动: 测试即文档,清晰表达业务逻辑
测试分层
测试分类和要求
1. 单元测试 (Unit Tests) - 70%
目标: 测试单个函数、方法和类的行为
覆盖率要求:
- pkg/ 包: 100% 覆盖率 (强制要求)
- internal/ 包: ≥ 90% 覆盖率
- cmd/ 包: ≥ 80% 覆盖率
执行时间: < 10 秒 (全部单元测试)
示例结构:
go
// pkg/currency/currency_test.go
func TestConvertCurrency(t *testing.T) {
tests := []struct {
name string
from Currency
to Currency
amount int64
expected int64
wantErr bool
}{
{
name: "CNY to CNY",
from: CNY,
to: CNY,
amount: 10000,
expected: 10000,
wantErr: false,
},
{
name: "invalid currency",
from: Currency("INVALID"),
to: CNY,
amount: 10000,
expected: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ConvertCurrency(tt.from, tt.to, tt.amount)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}2. 集成测试 (Integration Tests) - 25%
目标: 测试组件间的协作和数据流
覆盖范围:
- 数据库操作集成
- HTTP API 端到端调用
- gRPC 服务调用
- 缓存系统集成
- 消息队列集成
执行时间: < 2 分钟
标签控制:
go
// +build integration
func TestLedgerServiceIntegration(t *testing.T) {
if testing.Short() {
t.Skip("跳过集成测试")
}
// 集成测试逻辑...
}3. 端到端测试 (E2E Tests) - 5%
目标: 测试完整的用户业务流程
测试场景:
- 完整的记账流程
- 预算设置和监控流程
- 数据导入导出流程
- 多用户协作流程
执行时间: < 10 分钟
标签控制:
go
// +build e2e
func TestE2EAccountingWorkflow(t *testing.T) {
// 端到端测试逻辑...
}测试环境管理
1. 测试数据库配置
yaml
# test-config.yaml
database:
test:
driver: mysql
host: localhost
port: 3306
username: test_user
password: test_pass
database: ledger_test
options:
charset: utf8mb4
parseTime: true
multiStatements: true
redis:
test:
host: localhost
port: 6379
db: 1 # 使用不同的数据库
password: ""2. Docker 测试环境
yaml
# docker-compose.test.yml
version: '3.8'
services:
mysql-test:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: ledger_test
MYSQL_USER: test_user
MYSQL_PASSWORD: test_pass
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql # 内存数据库,提高测试速度
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--skip-log-bin
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"
command: redis-server --appendonly no --save ""3. 测试环境初始化
go
// internal/test/setup.go
type TestEnvironment struct {
DB *gorm.DB
Redis redis.Cmdable
HTTPServer *httptest.Server
GRPCServer *grpc.Server
Config *Config
Cleanup func()
}
func SetupTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
// 启动 Docker 容器
pool, err := dockertest.NewPool("")
require.NoError(t, err)
// MySQL 容器
mysqlResource, db := setupMySQLContainer(t, pool)
// Redis 容器
redisResource, redisClient := setupRedisContainer(t, pool)
// HTTP 测试服务器
httpServer := setupHTTPTestServer(t, db, redisClient)
// gRPC 测试服务器
grpcServer := setupGRPCTestServer(t, db, redisClient)
env := &TestEnvironment{
DB: db,
Redis: redisClient,
HTTPServer: httpServer,
GRPCServer: grpcServer,
Cleanup: func() {
pool.Purge(mysqlResource)
pool.Purge(redisResource)
httpServer.Close()
grpcServer.Stop()
},
}
// 注册清理函数
t.Cleanup(env.Cleanup)
return env
}TDD 实施流程
1. Red-Green-Refactor 循环
2. TDD 实践示例
go
// 第一步: Red - 编写失败测试
func TestCreateLedger_ShouldReturnLedgerWithID(t *testing.T) {
service := NewLedgerService(nil, nil) // 尚未实现
req := &CreateLedgerRequest{
Name: "测试账本",
Description: "TDD 示例",
}
result, err := service.CreateLedger(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotEmpty(t, result.ID)
assert.Equal(t, req.Name, result.Name)
}
// 第二步: Green - 最小实现
func (s *LedgerService) CreateLedger(ctx context.Context, req *CreateLedgerRequest) (*Ledger, error) {
return &Ledger{
ID: "test-id",
Name: req.Name,
}, nil
}
// 第三步: Refactor - 完善实现
func (s *LedgerService) CreateLedger(ctx context.Context, req *CreateLedgerRequest) (*Ledger, error) {
if req.Name == "" {
return nil, errors.New("name is required")
}
ledger := &Ledger{
ID: s.idGenerator.Generate(),
Name: req.Name,
Description: req.Description,
CreatedAt: s.timeProvider.Now(),
}
if err := s.repository.Save(ctx, ledger); err != nil {
return nil, fmt.Errorf("failed to save ledger: %w", err)
}
return ledger, nil
}Mock 策略
1. 依赖抽象
go
// 仓储接口
type LedgerRepository interface {
Save(ctx context.Context, ledger *Ledger) error
FindByID(ctx context.Context, id string) (*Ledger, error)
FindByUserID(ctx context.Context, userID string) ([]*Ledger, error)
Delete(ctx context.Context, id string) error
}
// 时间提供者接口
type TimeProvider interface {
Now() time.Time
Since(t time.Time) time.Duration
}
// ID 生成器接口
type IDGenerator interface {
Generate() string
Validate(id string) bool
}
// 事件发布器接口
type EventPublisher interface {
Publish(ctx context.Context, event DomainEvent) error
}2. Mock 实现
go
// 使用 testify/mock
type MockLedgerRepository struct {
mock.Mock
}
func (m *MockLedgerRepository) Save(ctx context.Context, ledger *Ledger) error {
args := m.Called(ctx, ledger)
return args.Error(0)
}
func (m *MockLedgerRepository) FindByID(ctx context.Context, id string) (*Ledger, error) {
args := m.Called(ctx, id)
return args.Get(0).(*Ledger), args.Error(1)
}
// 使用 gomock (自动生成)
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go
// 测试中使用 Mock
func TestLedgerService_CreateLedger(t *testing.T) {
mockRepo := new(MockLedgerRepository)
mockPublisher := new(MockEventPublisher)
mockIDGen := &MockIDGenerator{NextID: "test-123"}
mockTime := &MockTimeProvider{CurrentTime: time.Now()}
service := NewLedgerService(mockRepo, mockPublisher, mockIDGen, mockTime)
// 设置 Mock 期望
mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(l *Ledger) bool {
return l.Name == "测试账本" && l.ID == "test-123"
})).Return(nil).Once()
mockPublisher.On("Publish", mock.Anything, mock.AnythingOfType("*events.LedgerCreatedEvent")).
Return(nil).Once()
// 执行测试
req := &CreateLedgerRequest{Name: "测试账本"}
result, err := service.CreateLedger(context.Background(), req)
// 验证结果
assert.NoError(t, err)
assert.Equal(t, "test-123", result.ID)
assert.Equal(t, "测试账本", result.Name)
// 验证 Mock 调用
mockRepo.AssertExpectations(t)
mockPublisher.AssertExpectations(t)
}性能测试
1. 基准测试
go
// 基准测试
func BenchmarkLedgerService_CreateLedger(b *testing.B) {
service := setupBenchmarkService()
req := &CreateLedgerRequest{
Name: "基准测试账本",
Description: "性能测试",
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
b.Fatal(err)
}
}
}
// 并行基准测试
func BenchmarkLedgerService_CreateLedger_Parallel(b *testing.B) {
service := setupBenchmarkService()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
req := &CreateLedgerRequest{
Name: fmt.Sprintf("账本-%d", rand.Int()),
Description: "并行测试",
}
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
b.Fatal(err)
}
}
})
}2. 负载测试
go
// 负载测试
func TestLedgerService_LoadTest(t *testing.T) {
if testing.Short() {
t.Skip("跳过负载测试")
}
service := setupLoadTestService(t)
const (
concurrentUsers = 100
requestsPerUser = 50
testDuration = 30 * time.Second
)
var wg sync.WaitGroup
errors := make(chan error, concurrentUsers*requestsPerUser)
startTime := time.Now()
// 启动并发用户
for i := 0; i < concurrentUsers; i++ {
wg.Add(1)
go func(userID int) {
defer wg.Done()
for j := 0; j < requestsPerUser; j++ {
if time.Since(startTime) > testDuration {
return
}
req := &CreateLedgerRequest{
Name: fmt.Sprintf("用户%d-账本%d", userID, j),
}
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
errors <- err
}
}
}(i)
}
wg.Wait()
close(errors)
// 统计结果
errorCount := 0
for err := range errors {
errorCount++
t.Logf("Error: %v", err)
}
totalRequests := concurrentUsers * requestsPerUser
successRate := float64(totalRequests-errorCount) / float64(totalRequests) * 100
t.Logf("Total requests: %d", totalRequests)
t.Logf("Errors: %d", errorCount)
t.Logf("Success rate: %.2f%%", successRate)
// 要求成功率 >= 95%
assert.GreaterOrEqual(t, successRate, 95.0)
}测试数据管理
1. 测试数据工厂
go
// 测试数据工厂
type TestDataFactory struct {
rand *rand.Rand
}
func NewTestDataFactory(seed int64) *TestDataFactory {
return &TestDataFactory{
rand: rand.New(rand.NewSource(seed)),
}
}
func (f *TestDataFactory) CreateLedger(opts ...LedgerOption) *Ledger {
ledger := &Ledger{
ID: f.GenerateID("ledger"),
Name: f.GenerateName("账本"),
Description: f.GenerateDescription(),
Type: f.RandomLedgerType(),
Currency: f.RandomCurrency(),
CreatedAt: f.RandomPastTime(),
UpdatedAt: time.Now(),
}
// 应用选项
for _, opt := range opts {
opt(ledger)
}
return ledger
}
func (f *TestDataFactory) CreateTransaction(ledgerID string, opts ...TransactionOption) *Transaction {
transaction := &Transaction{
ID: f.GenerateID("tx"),
LedgerID: ledgerID,
Type: f.RandomTransactionType(),
Amount: f.RandomAmount(),
Description: f.GenerateDescription(),
TransactionDate: f.RandomPastTime(),
CreatedAt: time.Now(),
}
for _, opt := range opts {
opt(transaction)
}
return transaction
}
// 选项模式
type LedgerOption func(*Ledger)
type TransactionOption func(*Transaction)
func WithLedgerName(name string) LedgerOption {
return func(l *Ledger) { l.Name = name }
}
func WithTransactionAmount(amount int64) TransactionOption {
return func(t *Transaction) { t.Amount = amount }
}
// 使用示例
func TestCreateLedgerWithCustomData(t *testing.T) {
factory := NewTestDataFactory(42) // 固定种子保证可重现
ledger := factory.CreateLedger(
WithLedgerName("自定义账本名称"),
)
transaction := factory.CreateTransaction(
ledger.ID,
WithTransactionAmount(50000),
)
// 测试逻辑...
}2. Golden Files 测试
go
// Golden Files 测试 (用于复杂输出验证)
func TestGenerateReport_GoldenFile(t *testing.T) {
service := setupReportService()
ledger := createTestLedger()
report, err := service.GenerateReport(context.Background(), ledger.ID)
require.NoError(t, err)
// 生成 golden file 路径
goldenFile := filepath.Join("testdata", "golden", "report.json")
if *update {
// 更新 golden file
err := os.MkdirAll(filepath.Dir(goldenFile), 0755)
require.NoError(t, err)
data, err := json.MarshalIndent(report, "", " ")
require.NoError(t, err)
err = os.WriteFile(goldenFile, data, 0644)
require.NoError(t, err)
}
// 读取期望结果
expected, err := os.ReadFile(goldenFile)
require.NoError(t, err)
// 比较结果
actual, err := json.MarshalIndent(report, "", " ")
require.NoError(t, err)
assert.Equal(t, string(expected), string(actual))
}
// 命令行参数支持
var update = flag.Bool("update", false, "update golden files")持续集成配置
1. GitHub Actions 配置
yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [1.21, 1.22]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.mod', 'go.sum') }}
- name: Install dependencies
run: go mod download
- name: Run unit tests
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
- name: Check coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 90" | bc -l) )); then
echo "Coverage is below 90%"
exit 1
fi
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage.out
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: ledger_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.21
- name: Wait for services
run: |
while ! mysqladmin ping -h 127.0.0.1 -P 3306 -u root -prootpass --silent; do
echo "Waiting for MySQL..."
sleep 2
done
while ! redis-cli -h 127.0.0.1 -p 6379 ping; do
echo "Waiting for Redis..."
sleep 2
done
- name: Run integration tests
run: go test -v -tags=integration ./...
env:
MYSQL_HOST: 127.0.0.1
MYSQL_PORT: 3306
MYSQL_USER: root
MYSQL_PASSWORD: rootpass
MYSQL_DATABASE: ledger_test
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
e2e-tests:
runs-on: ubuntu-latest
needs: integration-tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.21
- name: Build application
run: go build -o bin/ledger-service ./cmd/server
- name: Run E2E tests
run: go test -v -tags=e2e ./test/e2e/...
timeout-minutes: 152. Pre-commit Hooks
yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- repo: local
hooks:
- id: go-fmt
name: go-fmt
entry: gofmt
language: system
args: [-w]
files: \.go$
- id: go-vet
name: go-vet
entry: go vet
language: system
pass_filenames: false
files: \.go$
- id: go-test
name: go-test
entry: go test
language: system
args: [-short, ./...]
pass_filenames: false
files: \.go$
- id: go-mod-tidy
name: go-mod-tidy
entry: go mod tidy
language: system
pass_filenames: false
files: go\.(mod|sum)$质量门禁
1. 覆盖率要求
bash
#!/bin/bash
# scripts/check-coverage.sh
# 运行测试并生成覆盖率报告
go test -coverprofile=coverage.out ./...
# 检查整体覆盖率
TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "总体覆盖率: $TOTAL_COVERAGE%"
if (( $(echo "$TOTAL_COVERAGE < 90" | bc -l) )); then
echo "❌ 总体覆盖率低于 90%"
exit 1
fi
# 检查 pkg 包覆盖率 (必须 100%)
PKG_COVERAGE=$(go tool cover -func=coverage.out | grep pkg/ | awk '{sum+=$3; count++} END {print sum/count}')
echo "pkg 包覆盖率: $PKG_COVERAGE%"
if (( $(echo "$PKG_COVERAGE < 100" | bc -l) )); then
echo "❌ pkg 包覆盖率必须达到 100%"
exit 1
fi
echo "✅ 覆盖率检查通过"2. 性能回归检测
bash
#!/bin/bash
# scripts/performance-check.sh
# 运行基准测试
go test -bench=. -benchmem -count=5 ./... > current_benchmark.txt
# 与基线对比 (如果存在)
if [ -f baseline_benchmark.txt ]; then
# 使用 benchcmp 或自定义脚本对比性能
go install golang.org/x/perf/cmd/benchcmp@latest
benchcmp baseline_benchmark.txt current_benchmark.txt
# 检查性能退化
REGRESSION=$(benchcmp baseline_benchmark.txt current_benchmark.txt | grep -E '\+[0-9]+\.[0-9]+%' | wc -l)
if [ $REGRESSION -gt 0 ]; then
echo "❌ 检测到性能回归"
benchcmp baseline_benchmark.txt current_benchmark.txt
exit 1
fi
fi
echo "✅ 性能检查通过"
cp current_benchmark.txt baseline_benchmark.txt测试报告和分析
1. 测试报告生成
go
// 自定义测试报告生成器
type TestReporter struct {
results []TestResult
startTime time.Time
}
type TestResult struct {
Name string
Package string
Duration time.Duration
Status string // PASS, FAIL, SKIP
Error error
Coverage float64
}
func (r *TestReporter) AddResult(result TestResult) {
r.results = append(r.results, result)
}
func (r *TestReporter) GenerateHTMLReport() string {
tmpl := `
<!DOCTYPE html>
<html>
<head>
<title>Ledger 测试报告</title>
<style>
.pass { color: green; }
.fail { color: red; }
.skip { color: orange; }
</style>
</head>
<body>
<h1>Ledger 模块测试报告</h1>
<p>生成时间: {{.Timestamp}}</p>
<h2>测试概览</h2>
<table border="1">
<tr>
<th>总测试数</th>
<th>通过</th>
<th>失败</th>
<th>跳过</th>
<th>总耗时</th>
</tr>
<tr>
<td>{{.TotalTests}}</td>
<td class="pass">{{.PassedTests}}</td>
<td class="fail">{{.FailedTests}}</td>
<td class="skip">{{.SkippedTests}}</td>
<td>{{.TotalDuration}}</td>
</tr>
</table>
<h2>详细结果</h2>
<table border="1">
<tr>
<th>包名</th>
<th>测试名称</th>
<th>状态</th>
<th>耗时</th>
<th>覆盖率</th>
</tr>
{{range .Results}}
<tr>
<td>{{.Package}}</td>
<td>{{.Name}}</td>
<td class="{{.Status}}">{{.Status}}</td>
<td>{{.Duration}}</td>
<td>{{printf "%.2f%%" .Coverage}}</td>
</tr>
{{end}}
</table>
</body>
</html>
`
// 执行模板渲染...
return html
}2. 持续监控
yaml
# 测试指标监控配置
monitoring:
metrics:
- name: test_execution_time
type: histogram
help: "Test execution time in seconds"
labels: ["package", "test_name"]
- name: test_coverage_percentage
type: gauge
help: "Test coverage percentage"
labels: ["package"]
- name: test_failure_rate
type: counter
help: "Test failure rate"
labels: ["package", "failure_reason"]
alerts:
- name: TestCoverageBelow90
condition: test_coverage_percentage < 90
message: "Test coverage dropped below 90%"
- name: TestExecutionTimeHigh
condition: test_execution_time > 300
message: "Test execution time exceeds 5 minutes"
- name: TestFailureRateHigh
condition: rate(test_failure_rate[5m]) > 0.1
message: "Test failure rate exceeds 10%"最佳实践总结
1. 测试设计原则
- 独立性: 测试之间不应相互依赖
- 可重复性: 测试结果应该可重现
- 快速性: 单元测试应在秒级完成
- 清晰性: 测试名称应清楚表达测试意图
- 完整性: 覆盖正常、异常和边界情况
2. 代码组织
- 测试文件与源文件放在同一包下
- 使用
_test.go后缀命名测试文件 - 复杂的测试辅助函数放在
internal/test/包中 - 测试数据放在
testdata/目录下
3. 性能考虑
- 使用
t.Parallel()并行运行独立测试 - 避免在测试中使用
time.Sleep() - 使用 Mock 替代真实的外部依赖
- 合理使用测试缓存和依赖缓存
4. 维护性
- 定期更新测试用例覆盖新功能
- 及时删除过时的测试代码
- 保持测试代码的简洁和可读性
- 使用测试工具链保持代码质量
这个测试策略确保了 Ledger 模块具备高质量的测试覆盖,通过系统化的测试方法保证系统的可靠性和可维护性。