refactor: 通用技能按类别拆分为独立目录
skills/ → skills-dev(9), skills-req(10), skills-ops(4), skills-integration(8), skills-biz(4), skills-workflow(7) generate-marketplace.py 改为自动扫描所有 skills-* 目录。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
208
skills-dev/dev-test-plugin/skills/dev-test/go-testing.md
Normal file
208
skills-dev/dev-test-plugin/skills/dev-test/go-testing.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Go 后端测试
|
||||
|
||||
## 测试框架
|
||||
|
||||
- **testify**: 断言和套件
|
||||
- **httptest**: HTTP 测试
|
||||
- **gomock**: Mock 生成(仅用于 handler 层)
|
||||
|
||||
## ⚠️ Biz 层测试规则:禁止使用 Mock
|
||||
|
||||
**Biz/Service 层测试必须使用真实 PostgreSQL test DB,不允许使用 mock store。**
|
||||
|
||||
Mock store 只是在测试你的 mock 实现,无法验证真实的 SQL 行为、事务、FK 约束等。
|
||||
|
||||
| 层 | 测试方式 | 原因 |
|
||||
|----|---------|------|
|
||||
| model/store | **test DB** (PostgreSQL) | 验证真实 SQL/ORM 行为 |
|
||||
| biz/service | **test DB** (PostgreSQL) + 真实 store | 验证业务逻辑 + 真实数据交互 |
|
||||
| handler | **mock biz + httptest** | 只测 HTTP 路由和参数绑定 |
|
||||
|
||||
```go
|
||||
// ✅ 正确 — biz 层使用真实 test DB + 真实 store
|
||||
func setupBiz(t *testing.T) (*SomeBiz, *gorm.DB) {
|
||||
db := newTestDB(t)
|
||||
s := store.NewSomeStore(db)
|
||||
biz := NewSomeBiz(s)
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM some_table WHERE tenant_id = ?", testTenantID)
|
||||
})
|
||||
return biz, db
|
||||
}
|
||||
|
||||
// ❌ 错误 — biz 层使用 mock store(等于没测)
|
||||
mockStore := store.NewMockIStore(ctrl)
|
||||
mockStore.EXPECT().Get(gomock.Any(), id).Return(fakeData, nil)
|
||||
biz := NewSomeBiz(mockStore)
|
||||
```
|
||||
|
||||
### testdb_test.go 模板
|
||||
|
||||
```go
|
||||
package biz
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const testTenantID int64 = 99
|
||||
|
||||
func newTestDB(t *testing.T, models ...interface{}) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "host=localhost user=coolbuy-dev dbname=coolbuy_paas_test sslmode=disable"
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(models...))
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 所有测试
|
||||
go test ./...
|
||||
make test
|
||||
|
||||
# 带覆盖率
|
||||
make cover
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# 特定包
|
||||
go test -v ./internal/twms/biz/...
|
||||
|
||||
# 特定函数
|
||||
go test -v -run TestFunctionName ./...
|
||||
```
|
||||
|
||||
## Biz 层单元测试模板(真实 DB)
|
||||
|
||||
```go
|
||||
package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"project/internal/user/model"
|
||||
"project/internal/user/store"
|
||||
)
|
||||
|
||||
func setupUserBiz(t *testing.T) (*UserBiz, *gorm.DB) {
|
||||
db := newTestDB(t, &model.User{})
|
||||
s := store.NewUserStore(db)
|
||||
biz := NewUserBiz(s)
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM users WHERE tenant_id = ?", testTenantID)
|
||||
})
|
||||
return biz, db
|
||||
}
|
||||
|
||||
func createTestUser(t *testing.T, db *gorm.DB, username string) *model.User {
|
||||
t.Helper()
|
||||
user := &model.User{TenantID: testTenantID, Username: username, Email: username + "@test.com"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
func TestUserBiz_Get(t *testing.T) {
|
||||
biz, db := setupUserBiz(t)
|
||||
user := createTestUser(t, db, "john")
|
||||
|
||||
result, err := biz.Get(context.Background(), user.ID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "john", result.Username)
|
||||
}
|
||||
|
||||
func TestUserBiz_Get_NotFound(t *testing.T) {
|
||||
biz, _ := setupUserBiz(t)
|
||||
|
||||
_, err := biz.Get(context.Background(), 99999)
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
```
|
||||
|
||||
## 表驱动测试
|
||||
|
||||
```go
|
||||
func TestValidateUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "john_doe", false},
|
||||
{"too_short", "ab", true},
|
||||
{"too_long", strings.Repeat("a", 65), true},
|
||||
{"special_chars", "user@name", true},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateUsername(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Handler 测试
|
||||
|
||||
```go
|
||||
func TestUserController_List(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockBiz := biz.NewMockIBiz(ctrl)
|
||||
mockUserBiz := biz.NewMockUserBiz(ctrl)
|
||||
|
||||
mockBiz.EXPECT().Users().Return(mockUserBiz).AnyTimes()
|
||||
mockUserBiz.EXPECT().List(gomock.Any(), gomock.Any()).Return(&v1.ListUsersResponse{
|
||||
Total: 1,
|
||||
Users: []*v1.User{{Id: 1, Username: "test"}},
|
||||
}, nil)
|
||||
|
||||
controller := NewUserController(mockBiz)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/v1/users?page=1&limit=10", nil)
|
||||
|
||||
controller.List(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
```
|
||||
|
||||
## Mock 生成
|
||||
|
||||
```bash
|
||||
# 生成 Mock
|
||||
mockgen -source=internal/twms/store/store.go \
|
||||
-destination=internal/twms/store/mock_store.go \
|
||||
-package=store
|
||||
|
||||
# go:generate 方式
|
||||
//go:generate mockgen -source=store.go -destination=mock_store.go -package=store
|
||||
```
|
||||
Reference in New Issue
Block a user