Files
junhong_cmp_fiber/docs/testing/test-connection-guide.md
huang 23eb0307bb
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
feat: 实现门店套餐分配功能并统一测试基础设施
新增功能:
- 门店套餐分配管理(shop_package_allocation):支持门店套餐库存管理
- 门店套餐系列分配管理(shop_series_allocation):支持套餐系列分配和佣金层级设置
- 我的套餐查询(my_package):支持门店查询自己的套餐分配情况

测试改进:
- 统一集成测试基础设施,新增 testutils.NewIntegrationTestEnv
- 重构所有集成测试使用新的测试环境设置
- 移除旧的测试辅助函数和冗余测试文件
- 新增 test_helpers_test.go 统一任务测试辅助

技术细节:
- 新增数据库迁移 000025_create_shop_allocation_tables
- 新增 3 个 Handler、Service、Store 和对应的单元测试
- 更新 OpenAPI 文档和文档生成器
- 测试覆盖率:Service 层 > 90%

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 10:45:16 +08:00

374 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 测试数据库连接管理规范
本文档是测试连接管理的**唯一标准**,所有新测试必须遵循此规范。
## ⚠️ 运行测试前必须加载环境变量
**所有测试命令必须先加载 `.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. 检查是否有遗留的测试数据未被事务回滚