Go 测试最佳实践
概述
测试是保证代码质量的重要手段,本项目采用 测试驱动开发(TDD) 方法,确保所有代码都具备高质量的测试覆盖。本文档详细介绍 Go 语言测试的最佳实践,包括单元测试、集成测试、基准测试、Mock 技术等各个方面。
核心原则
- TDD 优先: 先写测试,再写实现
- 100% 覆盖: pkg 包必须达到 100% 测试覆盖率
- 快速反馈: 单元测试应在毫秒级完成
- 独立性: 测试之间不应相互依赖
- 可读性: 测试代码应清晰表达意图
测试分层
- 单元测试: 测试单个函数或方法
- 集成测试: 测试组件间协作
- 端到端测试: 测试完整业务流程
- 性能测试: 基准测试和压力测试
核心原理
测试金字塔
测试驱动开发流程
Go 测试框架生态
项目测试架构
1. 测试目录结构
internal/
├── app/
│ └── [module]/
│ ├── service.go
│ ├── service_test.go # 服务层单元测试
│ ├── service_integration_test.go # 服务层集成测试
│ ├── repository.go
│ ├── repository_test.go
│ └── testdata/ # 测试数据
│ ├── fixtures/
│ └── golden/
├── pkg/
│ ├── [package]/
│ │ ├── [package].go
│ │ ├── [package]_test.go # 必须有测试
│ │ └── [package]_benchmark_test.go # 性能测试
│ └── validation/
│ ├── validator.go
│ ├── validator_test.go
│ └── validator_integration_test.go
└── test/
├── fixtures/ # 共享测试数据
├── helpers/ # 测试辅助函数
├── mocks/ # Mock 对象
└── testcontainers/ # 测试容器2. 测试分类和命名规范
go
// 单元测试命名:Test + 功能名
func TestCreateLedger(t *testing.T) {}
func TestCreateLedger_WithInvalidName_ShouldReturnError(t *testing.T) {}
// 基准测试命名:Benchmark + 功能名
func BenchmarkCreateLedger(b *testing.B) {}
func BenchmarkCreateLedger_Parallel(b *testing.B) {}
// 示例测试命名:Example + 功能名
func ExampleLedgerService_CreateLedger() {}
// 集成测试命名:TestIntegration + 功能名
func TestIntegrationCreateLedgerFlow(t *testing.T) {}
// 端到端测试命名:TestE2E + 场景名
func TestE2ECompleteAccountingFlow(t *testing.T) {}3. 测试标签和构建约束
go
// 单元测试 (默认运行)
// +build !integration
package ledger
// 集成测试 (需要 -tags=integration)
// +build integration
package ledger
// 端到端测试 (需要 -tags=e2e)
// +build e2e
package ledger
// 性能测试 (需要 -tags=benchmark)
// +build benchmark
package ledger单元测试最佳实践
1. 测试结构和模式
go
package ledger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/FixIterate/lz-stash/internal/domain/ledger"
"github.com/FixIterate/lz-stash/internal/test/mocks"
)
// 使用 testify/suite 组织测试
type LedgerServiceTestSuite struct {
suite.Suite
service *LedgerService
mockRepo *mocks.MockLedgerRepository
mockPublisher *mocks.MockEventPublisher
ctx context.Context
}
// SetupSuite 在整个测试套件开始前运行
func (s *LedgerServiceTestSuite) SetupSuite() {
s.ctx = context.Background()
}
// SetupTest 在每个测试开始前运行
func (s *LedgerServiceTestSuite) SetupTest() {
s.mockRepo = mocks.NewMockLedgerRepository(s.T())
s.mockPublisher = mocks.NewMockEventPublisher(s.T())
s.service = NewLedgerService(
s.mockRepo,
s.mockPublisher,
)
}
// TearDownTest 在每个测试结束后运行
func (s *LedgerServiceTestSuite) TearDownTest() {
// 验证所有 mock 调用都符合预期
s.mockRepo.AssertExpectations(s.T())
s.mockPublisher.AssertExpectations(s.T())
}
// 测试成功场景
func (s *LedgerServiceTestSuite) TestCreateLedger_Success() {
// Arrange (准备)
req := &CreateLedgerRequest{
Name: "测试账本",
Description: "这是一个测试账本",
Currency: "CNY",
}
expectedLedger := &ledger.Ledger{
ID: "ledger-001",
Name: req.Name,
Description: req.Description,
Currency: req.Currency,
CreatedAt: time.Now(),
}
s.mockRepo.On("Save", s.ctx, mock.AnythingOfType("*ledger.Ledger")).
Return(nil).
Once()
s.mockPublisher.On("Publish", s.ctx, mock.AnythingOfType("*events.LedgerCreatedEvent")).
Return(nil).
Once()
// Act (执行)
result, err := s.service.CreateLedger(s.ctx, req)
// Assert (断言)
require.NoError(s.T(), err)
assert.Equal(s.T(), expectedLedger.Name, result.Name)
assert.Equal(s.T(), expectedLedger.Description, result.Description)
assert.Equal(s.T(), expectedLedger.Currency, result.Currency)
assert.NotEmpty(s.T(), result.ID)
assert.NotZero(s.T(), result.CreatedAt)
}
// 测试错误场景
func (s *LedgerServiceTestSuite) TestCreateLedger_InvalidName_ShouldReturnError() {
// Arrange
req := &CreateLedgerRequest{
Name: "", // 无效名称
Description: "测试",
Currency: "CNY",
}
// Act
result, err := s.service.CreateLedger(s.ctx, req)
// Assert
assert.Error(s.T(), err)
assert.Nil(s.T(), result)
assert.Contains(s.T(), err.Error(), "name is required")
// 验证没有调用依赖项 (因为验证失败)
s.mockRepo.AssertNotCalled(s.T(), "Save")
s.mockPublisher.AssertNotCalled(s.T(), "Publish")
}
// 测试边界条件
func (s *LedgerServiceTestSuite) TestCreateLedger_NameTooLong_ShouldReturnError() {
// Arrange
longName := strings.Repeat("a", 101) // 超过100字符限制
req := &CreateLedgerRequest{
Name: longName,
Description: "测试",
Currency: "CNY",
}
// Act
result, err := s.service.CreateLedger(s.ctx, req)
// Assert
assert.Error(s.T(), err)
assert.Nil(s.T(), result)
assert.Contains(s.T(), err.Error(), "name too long")
}
// 测试依赖失败场景
func (s *LedgerServiceTestSuite) TestCreateLedger_RepositoryError_ShouldReturnError() {
// Arrange
req := &CreateLedgerRequest{
Name: "测试账本",
Description: "测试",
Currency: "CNY",
}
expectedError := errors.New("database connection failed")
s.mockRepo.On("Save", s.ctx, mock.AnythingOfType("*ledger.Ledger")).
Return(expectedError).
Once()
// Act
result, err := s.service.CreateLedger(s.ctx, req)
// Assert
assert.Error(s.T(), err)
assert.Nil(s.T(), result)
assert.Contains(s.T(), err.Error(), "database connection failed")
// 验证事件发布没有被调用 (因为保存失败)
s.mockPublisher.AssertNotCalled(s.T(), "Publish")
}
// 运行测试套件
func TestLedgerServiceTestSuite(t *testing.T) {
suite.Run(t, new(LedgerServiceTestSuite))
}2. 表驱动测试 (Table-Driven Tests)
go
func TestValidateAmount(t *testing.T) {
testCases := []struct {
name string
amount int64
currency string
expected bool
expectedErr string
}{
{
name: "valid positive amount",
amount: 10000, // 100.00
currency: "CNY",
expected: true,
},
{
name: "zero amount",
amount: 0,
currency: "CNY",
expected: false,
expectedErr: "amount must be positive",
},
{
name: "negative amount",
amount: -1000,
currency: "CNY",
expected: false,
expectedErr: "amount must be positive",
},
{
name: "invalid currency",
amount: 10000,
currency: "INVALID",
expected: false,
expectedErr: "unsupported currency",
},
{
name: "amount too large",
amount: math.MaxInt64,
currency: "CNY",
expected: false,
expectedErr: "amount too large",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Act
result, err := ValidateAmount(tc.amount, tc.currency)
// Assert
assert.Equal(t, tc.expected, result)
if tc.expectedErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErr)
} else {
assert.NoError(t, err)
}
})
}
}3. 子测试和并行测试
go
func TestLedgerOperations(t *testing.T) {
// 并行运行子测试
t.Run("CreateLedger", func(t *testing.T) {
t.Parallel() // 并行运行
// 测试创建账本
service := setupTestService(t)
result, err := service.CreateLedger(context.Background(), validRequest())
assert.NoError(t, err)
assert.NotNil(t, result)
})
t.Run("UpdateLedger", func(t *testing.T) {
t.Parallel()
// 测试更新账本
service := setupTestService(t)
// ... 测试逻辑
})
t.Run("DeleteLedger", func(t *testing.T) {
t.Parallel()
// 测试删除账本
service := setupTestService(t)
// ... 测试逻辑
})
}
// 测试辅助函数
func setupTestService(t *testing.T) *LedgerService {
t.Helper() // 标记为辅助函数,错误时不显示这个函数的行号
mockRepo := mocks.NewMockLedgerRepository(t)
mockPublisher := mocks.NewMockEventPublisher(t)
return NewLedgerService(mockRepo, mockPublisher)
}Mock 技术详解
1. 使用 testify/mock
go
// 定义 Mock 接口
type MockLedgerRepository struct {
mock.Mock
}
func (m *MockLedgerRepository) Save(ctx context.Context, ledger *ledger.Ledger) error {
args := m.Called(ctx, ledger)
return args.Error(0)
}
func (m *MockLedgerRepository) FindByID(ctx context.Context, id string) (*ledger.Ledger, error) {
args := m.Called(ctx, id)
return args.Get(0).(*ledger.Ledger), args.Error(1)
}
// 使用 Mock
func TestCreateLedger_WithMock(t *testing.T) {
// 创建 Mock
mockRepo := new(MockLedgerRepository)
// 设置期望
mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*ledger.Ledger")).
Return(nil).
Once()
// 创建服务
service := NewLedgerService(mockRepo)
// 执行测试
_, err := service.CreateLedger(context.Background(), validRequest())
// 验证
assert.NoError(t, err)
mockRepo.AssertExpectations(t)
}2. 使用 gomock 自动生成
go
//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go
// 原始接口
type LedgerRepository interface {
Save(ctx context.Context, ledger *ledger.Ledger) error
FindByID(ctx context.Context, id string) (*ledger.Ledger, error)
FindByUserID(ctx context.Context, userID string) ([]*ledger.Ledger, error)
}
// 使用生成的 Mock
func TestCreateLedger_WithGoMock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockLedgerRepository(ctrl)
// 设置期望
mockRepo.EXPECT().
Save(gomock.Any(), gomock.Any()).
Return(nil).
Times(1)
service := NewLedgerService(mockRepo)
_, err := service.CreateLedger(context.Background(), validRequest())
assert.NoError(t, err)
}3. 高级 Mock 技巧
go
// Mock 返回值基于输入参数
mockRepo.On("FindByID", mock.Anything, "existing-id").
Return(&ledger.Ledger{ID: "existing-id"}, nil)
mockRepo.On("FindByID", mock.Anything, "non-existing-id").
Return(nil, ErrLedgerNotFound)
// Mock 方法调用顺序
mockRepo.On("Save", mock.Anything, mock.Anything).
Return(nil).
Once()
mockPublisher.On("Publish", mock.Anything, mock.Anything).
Return(nil).
Once().
After(time.Second) // 延迟执行
// Mock 复杂参数匹配
mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(l *ledger.Ledger) bool {
return l.Name == "测试账本" && l.Currency == "CNY"
})).Return(nil)
// Mock 返回不同的值 (模拟重试场景)
mockRepo.On("Save", mock.Anything, mock.Anything).
Return(errors.New("temporary error")).
Once() // 第一次失败
mockRepo.On("Save", mock.Anything, mock.Anything).
Return(nil).
Once() // 第二次成功集成测试策略
1. 数据库集成测试
go
// +build integration
package ledger
import (
"context"
"database/sql"
"testing"
"github.com/ory/dockertest/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type IntegrationTestSuite struct {
suite.Suite
db *gorm.DB
pool *dockertest.Pool
resource *dockertest.Resource
repository *LedgerRepository
ctx context.Context
}
func (s *IntegrationTestSuite) SetupSuite() {
s.ctx = context.Background()
// 启动 MySQL 容器
var err error
s.pool, err = dockertest.NewPool("")
s.Require().NoError(err)
// 运行 MySQL 容器
s.resource, err = s.pool.Run("mysql", "8.0", []string{
"MYSQL_ROOT_PASSWORD=testpass",
"MYSQL_DATABASE=testdb",
})
s.Require().NoError(err)
// 等待数据库就绪
var db *sql.DB
err = s.pool.Retry(func() error {
var err error
dsn := fmt.Sprintf("root:testpass@tcp(localhost:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
s.resource.GetPort("3306/tcp"))
db, err = sql.Open("mysql", dsn)
if err != nil {
return err
}
return db.Ping()
})
s.Require().NoError(err)
// 创建 GORM 实例
s.db, err = gorm.Open(mysql.New(mysql.Config{
Conn: db,
}), &gorm.Config{})
s.Require().NoError(err)
// 运行数据库迁移
err = s.db.AutoMigrate(&Ledger{}, &Transaction{}, &Tag{}, &Budget{})
s.Require().NoError(err)
// 创建 repository
s.repository = NewLedgerRepository(s.db)
}
func (s *IntegrationTestSuite) TearDownSuite() {
// 清理 Docker 容器
if s.pool != nil && s.resource != nil {
s.pool.Purge(s.resource)
}
}
func (s *IntegrationTestSuite) SetupTest() {
// 清理测试数据
s.db.Exec("TRUNCATE TABLE ledgers")
s.db.Exec("TRUNCATE TABLE transactions")
s.db.Exec("TRUNCATE TABLE tags")
s.db.Exec("TRUNCATE TABLE budgets")
}
func (s *IntegrationTestSuite) TestCreateAndFindLedger() {
// 创建测试数据
ledger := &Ledger{
ID: "test-ledger-001",
Name: "集成测试账本",
Description: "用于集成测试",
Currency: "CNY",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 保存到数据库
err := s.repository.Save(s.ctx, ledger)
s.NoError(err)
// 从数据库查询
found, err := s.repository.FindByID(s.ctx, ledger.ID)
s.NoError(err)
s.NotNil(found)
// 验证数据
assert.Equal(s.T(), ledger.ID, found.ID)
assert.Equal(s.T(), ledger.Name, found.Name)
assert.Equal(s.T(), ledger.Description, found.Description)
assert.Equal(s.T(), ledger.Currency, found.Currency)
}
func (s *IntegrationTestSuite) TestTransactionOperations() {
// 测试完整的事务流程
tx := s.db.Begin()
defer tx.Rollback()
// 在事务中创建账本
ledger := &Ledger{
ID: "tx-test-ledger",
Name: "事务测试账本",
Currency: "CNY",
}
err := tx.Create(ledger).Error
s.NoError(err)
// 在事务中创建交易记录
transaction := &Transaction{
ID: "tx-test-transaction",
LedgerID: ledger.ID,
Type: TransactionTypeExpense,
Amount: 10000,
Description: "测试交易",
}
err = tx.Create(transaction).Error
s.NoError(err)
// 提交事务
err = tx.Commit().Error
s.NoError(err)
// 验证数据已持久化
var count int64
s.db.Model(&Ledger{}).Where("id = ?", ledger.ID).Count(&count)
assert.Equal(s.T(), int64(1), count)
s.db.Model(&Transaction{}).Where("id = ?", transaction.ID).Count(&count)
assert.Equal(s.T(), int64(1), count)
}
func TestIntegrationTestSuite(t *testing.T) {
if testing.Short() {
t.Skip("跳过集成测试")
}
suite.Run(t, new(IntegrationTestSuite))
}2. HTTP API 集成测试
go
// +build integration
func TestHTTPAPIIntegration(t *testing.T) {
// 启动测试服务器
server := setupTestServer(t)
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
t.Run("CreateLedger", func(t *testing.T) {
// 准备请求数据
reqBody := map[string]interface{}{
"name": "HTTP测试账本",
"description": "通过HTTP API创建",
"currency": "CNY",
}
jsonBody, _ := json.Marshal(reqBody)
// 发送 POST 请求
resp, err := client.Post(
server.URL+"/api/v1/ledgers",
"application/json",
bytes.NewBuffer(jsonBody),
)
require.NoError(t, err)
defer resp.Body.Close()
// 验证响应
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
ledger := response["ledger"].(map[string]interface{})
assert.Equal(t, reqBody["name"], ledger["name"])
assert.Equal(t, reqBody["description"], ledger["description"])
assert.NotEmpty(t, ledger["id"])
})
t.Run("GetLedger", func(t *testing.T) {
// 先创建一个账本
ledgerID := createTestLedger(t, client, server.URL)
// 获取账本
resp, err := client.Get(server.URL + "/api/v1/ledgers/" + ledgerID)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
ledger := response["ledger"].(map[string]interface{})
assert.Equal(t, ledgerID, ledger["id"])
})
}
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
// 设置测试数据库
db := setupTestDB(t)
// 创建服务
service := NewLedgerService(
NewLedgerRepository(db),
NewEventPublisher(),
)
// 设置路由
router := gin.New()
router.POST("/api/v1/ledgers", service.CreateLedgerHandler)
router.GET("/api/v1/ledgers/:id", service.GetLedgerHandler)
return httptest.NewServer(router)
}基准测试和性能测试
1. 基准测试基础
go
func BenchmarkCreateLedger(b *testing.B) {
service := setupBenchmarkService()
req := &CreateLedgerRequest{
Name: "基准测试账本",
Description: "性能测试",
Currency: "CNY",
}
b.ResetTimer() // 重置计时器,排除设置时间
for i := 0; i < b.N; i++ {
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
b.Fatal(err)
}
}
}
// 并行基准测试
func BenchmarkCreateLedger_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: "并行测试",
Currency: "CNY",
}
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
b.Fatal(err)
}
}
})
}
// 内存分配基准测试
func BenchmarkCreateLedger_Memory(b *testing.B) {
service := setupBenchmarkService()
req := &CreateLedgerRequest{
Name: "内存测试账本",
Description: "内存分配测试",
Currency: "CNY",
}
b.ResetTimer()
b.ReportAllocs() // 报告内存分配
for i := 0; i < b.N; i++ {
_, err := service.CreateLedger(context.Background(), req)
if err != nil {
b.Fatal(err)
}
}
}2. 复杂场景基准测试
go
// 不同输入大小的基准测试
func BenchmarkValidateTransaction(b *testing.B) {
testCases := []struct {
name string
tagCount int
}{
{"SmallTransaction", 1},
{"MediumTransaction", 10},
{"LargeTransaction", 100},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
transaction := createTransactionWithTags(tc.tagCount)
validator := NewTransactionValidator()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := validator.Validate(transaction)
if err != nil {
b.Fatal(err)
}
}
})
}
}
// 数据库操作基准测试
func BenchmarkRepository_FindByID(b *testing.B) {
db := setupBenchmarkDB()
repo := NewLedgerRepository(db)
// 预填充数据
ledgers := createTestLedgers(1000)
for _, ledger := range ledgers {
repo.Save(context.Background(), ledger)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 随机选择一个 ID
id := ledgers[rand.Intn(len(ledgers))].ID
_, err := repo.FindByID(context.Background(), id)
if err != nil {
b.Fatal(err)
}
}
}3. 内存和 CPU 性能分析
go
// CPU 性能分析
func BenchmarkCPUIntensive(b *testing.B) {
// 启用 CPU 性能分析
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
b.Fatal(err)
}
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// CPU 密集型操作
calculateComplexStatistics()
}
}
// 内存性能分析
func BenchmarkMemoryIntensive(b *testing.B) {
defer func() {
if *memprofile != "" {
f, err := os.Create(*memprofile)
if err != nil {
b.Fatal(err)
}
defer f.Close()
runtime.GC() // 强制 GC
if err := pprof.WriteHeapProfile(f); err != nil {
b.Fatal(err)
}
}
}()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// 内存密集型操作
data := make([]byte, 1024*1024) // 1MB
processLargeData(data)
}
}测试工具和实用程序
1. 测试数据管理
go
// 测试数据工厂
type TestDataFactory struct {
rand *rand.Rand
}
func NewTestDataFactory() *TestDataFactory {
return &TestDataFactory{
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (f *TestDataFactory) CreateLedger() *ledger.Ledger {
return &ledger.Ledger{
ID: f.GenerateID("ledger"),
Name: f.GenerateName("账本"),
Description: f.GenerateDescription(),
Currency: f.RandomCurrency(),
CreatedAt: f.RandomTime(),
UpdatedAt: time.Now(),
}
}
func (f *TestDataFactory) CreateTransaction(ledgerID string) *Transaction {
return &Transaction{
ID: f.GenerateID("tx"),
LedgerID: ledgerID,
Type: f.RandomTransactionType(),
Amount: f.RandomAmount(),
Description: f.GenerateDescription(),
TransactionDate: f.RandomTime(),
CreatedAt: time.Now(),
}
}
func (f *TestDataFactory) GenerateID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, f.rand.Int63())
}
func (f *TestDataFactory) RandomCurrency() string {
currencies := []string{"CNY", "USD", "EUR", "GBP", "JPY"}
return currencies[f.rand.Intn(len(currencies))]
}
func (f *TestDataFactory) RandomAmount() int64 {
return f.rand.Int63n(1000000) + 100 // 1元到10000元
}2. 测试断言辅助
go
// 自定义断言函数
func AssertLedgerEqual(t *testing.T, expected, actual *ledger.Ledger) {
t.Helper()
assert.Equal(t, expected.ID, actual.ID)
assert.Equal(t, expected.Name, actual.Name)
assert.Equal(t, expected.Description, actual.Description)
assert.Equal(t, expected.Currency, actual.Currency)
// 时间断言(允许1秒误差)
assert.WithinDuration(t, expected.CreatedAt, actual.CreatedAt, time.Second)
}
func AssertTransactionEqual(t *testing.T, expected, actual *Transaction) {
t.Helper()
assert.Equal(t, expected.ID, actual.ID)
assert.Equal(t, expected.LedgerID, actual.LedgerID)
assert.Equal(t, expected.Type, actual.Type)
assert.Equal(t, expected.Amount, actual.Amount)
assert.Equal(t, expected.Description, actual.Description)
}
// JSON 响应断言
func AssertJSONResponse(t *testing.T, response *http.Response, expectedStatus int, expectedBody interface{}) {
t.Helper()
assert.Equal(t, expectedStatus, response.StatusCode)
var actualBody interface{}
err := json.NewDecoder(response.Body).Decode(&actualBody)
require.NoError(t, err)
assert.Equal(t, expectedBody, actualBody)
}3. 测试环境管理
go
// 测试环境配置
type TestEnv struct {
DB *gorm.DB
Redis redis.Cmdable
Config *Config
TestData *TestDataFactory
Container *dockertest.Pool
Resources []*dockertest.Resource
}
func SetupTestEnv(t *testing.T) *TestEnv {
t.Helper()
env := &TestEnv{
TestData: NewTestDataFactory(),
}
// 启动 Docker 容器
pool, err := dockertest.NewPool("")
require.NoError(t, err)
env.Container = pool
// 启动 MySQL
env.setupMySQL(t)
// 启动 Redis
env.setupRedis(t)
// 注册清理函数
t.Cleanup(func() {
env.Cleanup()
})
return env
}
func (env *TestEnv) setupMySQL(t *testing.T) {
resource, err := env.Container.Run("mysql", "8.0", []string{
"MYSQL_ROOT_PASSWORD=testpass",
"MYSQL_DATABASE=testdb",
})
require.NoError(t, err)
env.Resources = append(env.Resources, resource)
// 等待连接就绪并创建 GORM 实例
err = env.Container.Retry(func() error {
dsn := fmt.Sprintf("root:testpass@tcp(localhost:%s)/testdb?charset=utf8mb4&parseTime=True&loc=Local",
resource.GetPort("3306/tcp"))
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if err := sqlDB.Ping(); err != nil {
return err
}
env.DB = db
return nil
})
require.NoError(t, err)
// 运行迁移
err = env.DB.AutoMigrate(&Ledger{}, &Transaction{}, &Tag{}, &Budget{})
require.NoError(t, err)
}
func (env *TestEnv) Cleanup() {
for _, resource := range env.Resources {
env.Container.Purge(resource)
}
}测试最佳实践总结
1. 命名和组织
go
// ✅ 好的测试命名
func TestCreateLedger_WithValidInput_ShouldReturnSuccess(t *testing.T) {}
func TestCreateLedger_WithEmptyName_ShouldReturnValidationError(t *testing.T) {}
func TestCreateLedger_WithDatabaseError_ShouldReturnInternalError(t *testing.T) {}
// ❌ 不好的测试命名
func TestCreateLedger1(t *testing.T) {}
func TestCreateLedger2(t *testing.T) {}
func TestCreateLedgerFail(t *testing.T) {}2. 测试独立性
go
// ✅ 每个测试独立
func TestLedgerService(t *testing.T) {
t.Run("CreateLedger", func(t *testing.T) {
service := setupService() // 每个测试独立设置
// 测试逻辑...
})
t.Run("UpdateLedger", func(t *testing.T) {
service := setupService() // 独立设置
// 测试逻辑...
})
}
// ❌ 测试间有依赖
var globalService *LedgerService // 全局变量
func TestCreateLedger(t *testing.T) {
globalService = setupService()
// 测试逻辑...
}
func TestUpdateLedger(t *testing.T) {
// 依赖于前一个测试的结果
// 测试逻辑...
}3. 错误处理
go
// ✅ 详细的错误验证
func TestCreateLedger_InvalidInput(t *testing.T) {
testCases := []struct {
name string
request *CreateLedgerRequest
expectedErr string
}{
{
name: "empty name",
request: &CreateLedgerRequest{Name: ""},
expectedErr: "name is required",
},
{
name: "name too long",
request: &CreateLedgerRequest{Name: strings.Repeat("a", 101)},
expectedErr: "name too long",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
service := setupService()
_, err := service.CreateLedger(context.Background(), tc.request)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErr)
})
}
}
// ❌ 简单的错误检查
func TestCreateLedger_InvalidInput(t *testing.T) {
service := setupService()
_, err := service.CreateLedger(context.Background(), &CreateLedgerRequest{})
assert.Error(t, err) // 没有验证具体错误
}4. 测试覆盖率
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 ./internal/app/ledger/...
# 设置覆盖率阈值
go test -cover ./... | grep -E "coverage: [0-9]+" | awk '{if($2 < 90.0) exit 1}'常见问题和解决方案
1. 测试运行缓慢
问题: 测试套件运行时间过长
解决方案:
go
// ✅ 使用并行测试
func TestLedgerOperations(t *testing.T) {
t.Run("CreateLedger", func(t *testing.T) {
t.Parallel() // 并行运行
// 测试逻辑...
})
t.Run("UpdateLedger", func(t *testing.T) {
t.Parallel()
// 测试逻辑...
})
}
// ✅ 跳过慢速测试
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("跳过慢速测试")
}
// 慢速测试逻辑...
}
// 运行时跳过慢速测试
// go test -short2. 测试数据污染
问题: 测试之间相互影响
解决方案:
go
// ✅ 事务回滚
func TestWithTransaction(t *testing.T) {
tx := db.Begin()
defer tx.Rollback() // 测试结束后回滚
// 在事务中执行测试
repo := NewLedgerRepository(tx)
// 测试逻辑...
}
// ✅ 独立数据库
func TestWithIsolatedDB(t *testing.T) {
db := setupTestDB(t) // 每个测试独立数据库
defer cleanupTestDB(t, db)
// 测试逻辑...
}3. Mock 过度使用
问题: Mock 太多导致测试脆弱
解决方案:
go
// ✅ 适度使用 Mock
func TestLedgerService_CreateLedger(t *testing.T) {
// 只 Mock 真正的外部依赖
mockRepo := mocks.NewMockLedgerRepository(t)
mockPublisher := mocks.NewMockEventPublisher(t)
// 真实的领域对象
service := NewLedgerService(mockRepo, mockPublisher)
// 测试逻辑...
}
// ❌ 过度 Mock
func TestLedgerService_CreateLedger(t *testing.T) {
// Mock 了太多内部组件
mockValidator := mocks.NewMockValidator(t)
mockIDGenerator := mocks.NewMockIDGenerator(t)
mockTimeProvider := mocks.NewMockTimeProvider(t)
mockRepo := mocks.NewMockLedgerRepository(t)
// ... 更多 Mock
}参考资料
官方文档
- Go Testing Package - Go 标准测试库
- Go Testing Guide - Go 官方测试指南
- Testify Documentation - Testify 测试框架
经典书籍
- 《Test-Driven Development》 - Kent Beck
- 《Growing Object-Oriented Software, Guided by Tests》 - Steve Freeman & Nat Pryce
- 《Working Effectively with Unit Tests》 - Jay Fields
最佳实践文章
- Go Testing By Example - Go 官方测试示例
- Advanced Go Testing Tutorial - 高级测试技巧
- Table Driven Tests - 表驱动测试
工具和框架
- GoMock - Mock 代码生成工具
- Dockertest - Docker 集成测试
- Ginkgo - BDD 测试框架
- go-cmp - 深度比较工具
实战练习
练习 1: 编写完整的单元测试
为 Currency 包编写完整的单元测试:
- 货币转换功能
- 汇率计算
- 边界条件测试
- 错误场景覆盖
练习 2: 实现集成测试
为 Transaction 服务实现集成测试:
- 数据库操作测试
- 事务回滚测试
- 并发安全测试
- 性能基准测试
练习 3: Mock 设计和实现
设计并实现完整的 Mock 系统:
- 使用 testify/mock 手动实现
- 使用 gomock 自动生成
- 对比两种方式的优缺点
练习 4: 测试自动化
搭建完整的测试自动化流程:
- CI/CD 集成
- 覆盖率监控
- 测试报告生成
- 性能回归检测
这些练习将帮助你掌握 Go 语言测试的各个方面,建立完整的测试技能体系。