# 测试数据库连接管理规范 本文档是测试连接管理的**唯一标准**,所有新测试必须遵循此规范。 ## ⚠️ 运行测试前必须加载环境变量 **所有测试命令必须先加载 `.env.local` 环境变量**,否则测试将因缺少数据库/Redis 配置而失败。 ### 命令格式 ```bash # ✅ 正确:先 source 环境变量 source .env.local && go test -v ./internal/service/xxx/... # ✅ 正确:运行所有测试 source .env.local && go test ./... # ❌ 错误:直接运行测试(会因缺少配置而失败) go test -v ./internal/service/xxx/... ``` ### 环境变量文件 - **`.env.local`**: 本地开发/测试环境配置(不提交到 Git) - 包含数据库连接、Redis 地址、JWT 密钥等必要配置 - 如果文件不存在,从 `.env.example` 复制并填写实际值 ### 常见错误 如果看到以下错误,说明未加载环境变量: ``` --- SKIP: TestXxx (0.00s) test_helpers.go:xx: 跳过测试:无法连接测试数据库 ``` 或: ``` panic: 配置加载失败: 缺少必要的数据库配置 ``` **解决方案**:确保运行 `source .env.local` 后再执行测试。 --- ## 快速开始 ```go func TestXxx(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) store := postgres.NewXxxStore(tx, rdb) // 测试代码... // 测试结束后自动回滚,无需手动清理 } ``` ## 核心 API ### NewTestTransaction(t) - 创建测试事务 ```go func NewTestTransaction(t *testing.T) *gorm.DB ``` - 返回独立事务,测试结束后自动回滚 - 使用 `t.Cleanup()` 确保即使 panic 也能清理 - **所有数据库操作都应使用此函数返回的 tx** ### GetTestDB(t) - 获取全局数据库连接 ```go func GetTestDB(t *testing.T) *gorm.DB ``` - 全局单例,整个测试套件只创建一次 - AutoMigrate 只在首次调用时执行 - 通常不直接使用,而是通过 `NewTestTransaction` 间接使用 ### GetTestRedis(t) - 获取全局 Redis 连接 ```go func GetTestRedis(t *testing.T) *redis.Client ``` - 全局单例,复用连接 - 需配合 `CleanTestRedisKeys` 使用以避免键污染 ### CleanTestRedisKeys(t, rdb) - 清理测试 Redis 键 ```go func CleanTestRedisKeys(t *testing.T, rdb *redis.Client) ``` - 测试开始前清理已有键 - 测试结束后自动清理 - 键前缀格式: `test:{TestName}:*` ### GetTestRedisKeyPrefix(t) - 获取 Redis 键前缀 ```go func GetTestRedisKeyPrefix(t *testing.T) string ``` - 返回格式: `test:{TestName}:` - 用于在测试中创建带前缀的键 ## 使用示例 ### 基础单元测试 ```go func TestUserStore_Create(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) store := postgres.NewUserStore(tx, rdb) ctx := context.Background() user := &model.User{Name: "测试用户"} err := store.Create(ctx, user) require.NoError(t, err) assert.NotZero(t, user.ID) } ``` ### Table-Driven Tests ```go func TestUserStore_Validate(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) store := postgres.NewUserStore(tx, rdb) ctx := context.Background() tests := []struct { name string user *model.User wantErr bool }{ {"有效用户", &model.User{Name: "张三"}, false}, {"空名称", &model.User{Name: ""}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := store.Create(ctx, tt.user) if tt.wantErr { assert.Error(t, err) } else { require.NoError(t, err) } }) } } ``` ### 需要 Redis 操作的测试 ```go func TestCacheStore_Get(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) ctx := context.Background() prefix := testutils.GetTestRedisKeyPrefix(t) // 使用测试前缀创建键 key := prefix + "user:1" rdb.Set(ctx, key, "cached_data", time.Hour) // 验证 val, err := rdb.Get(ctx, key).Result() require.NoError(t, err) assert.Equal(t, "cached_data", val) // 测试结束后自动清理 key } ``` ## 常见陷阱 ### 1. 子测试中不要调用 NewTestTransaction ❌ 错误: ```go func TestXxx(t *testing.T) { t.Run("子测试", func(t *testing.T) { tx := testutils.NewTestTransaction(t) // 错误! }) } ``` ✅ 正确: ```go func TestXxx(t *testing.T) { tx := testutils.NewTestTransaction(t) // 在父测试中创建 t.Run("子测试1", func(t *testing.T) { // 使用父测试的 tx }) t.Run("子测试2", func(t *testing.T) { // 共享同一个 tx }) } ``` ### 2. 不要在测试中调用 db.Close() 事务和连接由框架管理,不要手动关闭。 ### 3. 并发测试需要独立事务 如果使用 `t.Parallel()`,每个测试必须有独立的事务: ```go func TestConcurrent(t *testing.T) { t.Run("test1", func(t *testing.T) { t.Parallel() tx := testutils.NewTestTransaction(t) // 每个并发测试独立事务 // ... }) t.Run("test2", func(t *testing.T) { t.Parallel() tx := testutils.NewTestTransaction(t) // ... }) } ``` ## 性能对比 | 指标 | 旧方案 (SetupTestDB) | 新方案 (NewTestTransaction) | |------|---------------------|----------------------------| | 单测平均耗时 | ~350ms | ~50ms | | 204 个测试总耗时 | ~71 秒 | ~10.5 秒 | | 数据库连接数 | 204 个 | 1 个 | | 内存占用 | 高 | 低 (降低 ~80%) | ## 从旧 API 迁移 旧方式: ```go db, rdb := testutils.SetupTestDB(t) defer testutils.TeardownTestDB(t, db, rdb) store := postgres.NewXxxStore(db, rdb) ``` 新方式: ```go tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) testutils.CleanTestRedisKeys(t, rdb) store := postgres.NewXxxStore(tx, rdb) ``` ## 集成测试环境 对于需要完整 HTTP 请求测试的场景,使用 `IntegrationTestEnv`: ### 基础用法 ```go func TestAPI_Create(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) t.Run("成功创建资源", func(t *testing.T) { reqBody := dto.CreateRequest{ Name: fmt.Sprintf("test_%d", time.Now().UnixNano()), } jsonBody, _ := json.Marshal(reqBody) resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/resources", jsonBody) require.NoError(t, err) assert.Equal(t, fiber.StatusOK, resp.StatusCode) }) } ``` ### IntegrationTestEnv API | 方法 | 说明 | |------|------| | `NewIntegrationTestEnv(t)` | 创建集成测试环境,自动初始化所有依赖 | | `AsSuperAdmin()` | 以超级管理员身份发送请求 | | `AsUser(account)` | 以指定账号身份发送请求 | | `Request(method, path, body)` | 发送 HTTP 请求 | | `CreateTestAccount(...)` | 创建测试账号 | | `CreateTestShop(...)` | 创建测试店铺 | | `CreateTestRole(...)` | 创建测试角色 | | `CreateTestPermission(...)` | 创建测试权限 | ### 数据隔离最佳实践 **必须使用动态生成的测试数据**,避免固定值导致的测试冲突: ```go t.Run("创建资源", func(t *testing.T) { // ✅ 正确:使用动态值 name := fmt.Sprintf("test_resource_%d", time.Now().UnixNano()) phone := fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000) // ❌ 错误:使用固定值(会导致并发测试冲突) name := "test_resource" phone := "13800000001" }) ``` ### 完整示例 ```go func TestAccountAPI_Create(t *testing.T) { env := testutils.NewIntegrationTestEnv(t) t.Run("成功创建平台账号", func(t *testing.T) { username := fmt.Sprintf("platform_user_%d", time.Now().UnixNano()) phone := fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000) reqBody := dto.CreateAccountRequest{ Username: username, Phone: phone, Password: "Password123", UserType: constants.UserTypePlatform, } jsonBody, _ := json.Marshal(reqBody) resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody) require.NoError(t, err) assert.Equal(t, fiber.StatusOK, resp.StatusCode) // 验证数据库中账号已创建 var count int64 env.TX.Model(&model.Account{}).Where("username = ?", username).Count(&count) assert.Equal(t, int64(1), count) }) } ``` ## 故障排查 ### 连接超时 如果测试跳过并显示"无法连接测试数据库": 1. 检查网络连接 2. 验证数据库服务状态 3. 确认 DSN 配置正确 ### 事务死锁 如果测试卡住: 1. 检查是否有未提交的长事务 2. 避免在单个测试中执行耗时操作 3. 确保测试 < 1 秒完成 ### Redis 键冲突 如果出现数据污染: 1. 确保使用 `CleanTestRedisKeys` 2. 检查是否正确使用 `GetTestRedisKeyPrefix` 3. 验证键名是否包含测试名称前缀 ### 测试数据冲突 如果看到 "用户名已存在" 或 "手机号已存在" 错误: 1. 确保使用 `time.Now().UnixNano()` 生成唯一值 2. 不要在子测试之间共享固定的测试数据 3. 检查是否有遗留的测试数据未被事务回滚