All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
新增功能: - 门店套餐分配管理(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>
374 lines
9.3 KiB
Markdown
374 lines
9.3 KiB
Markdown
# 测试数据库连接管理规范
|
||
|
||
本文档是测试连接管理的**唯一标准**,所有新测试必须遵循此规范。
|
||
|
||
## ⚠️ 运行测试前必须加载环境变量
|
||
|
||
**所有测试命令必须先加载 `.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. 检查是否有遗留的测试数据未被事务回滚
|