Skip to content

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: 15

2. 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 模块具备高质量的测试覆盖,通过系统化的测试方法保证系统的可靠性和可维护性。

基于 MIT 许可证发布