refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作 - 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚 - 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理 - 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// TestApp 测试应用,封装 Application 提供测试能力
|
||||
type TestApp struct {
|
||||
*Application
|
||||
projs TestProj
|
||||
collector *TestCollector
|
||||
}
|
||||
|
||||
// TestResponse httptest 的原始响应封装
|
||||
type TestResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// CoverageReport 覆盖率报告
|
||||
type CoverageReport struct {
|
||||
Total int
|
||||
Covered int
|
||||
Missing []string
|
||||
Details []MethodCoverage
|
||||
}
|
||||
|
||||
// MethodCoverage 单个接口的覆盖详情
|
||||
type MethodCoverage struct {
|
||||
Path string
|
||||
HasTest bool
|
||||
CaseCount int
|
||||
Passed int
|
||||
Failed int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// TestRecord 单条测试记录
|
||||
type TestRecord struct {
|
||||
Path string
|
||||
Desc string
|
||||
CaseName string
|
||||
Passed bool
|
||||
Duration time.Duration
|
||||
Method string
|
||||
Query map[string]interface{}
|
||||
JsonBody interface{}
|
||||
FormBody map[string]interface{}
|
||||
HasFile bool
|
||||
FileField string
|
||||
ResponseBody map[string]interface{}
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
type TestCollector struct {
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
}
|
||||
|
||||
func (c *TestCollector) Add(r TestRecord) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Records = append(c.Records, r)
|
||||
}
|
||||
|
||||
// NewTestApp 创建测试应用实例
|
||||
// configPath: 配置文件路径(如 "../config/config.json")
|
||||
// projects: 项目定义(路由 + 测试)
|
||||
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
for _, lis := range listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
router := Router{}
|
||||
for projName, projDef := range projects {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
|
||||
return &TestApp{
|
||||
Application: app,
|
||||
projs: projects,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
func (that *Application) SetupForTest(router Router) {
|
||||
if that.Router == nil {
|
||||
that.Router = Router{}
|
||||
}
|
||||
for k := range router {
|
||||
v := router[k]
|
||||
if that.Router[k] == nil {
|
||||
that.Router[k] = v
|
||||
}
|
||||
for k1 := range v {
|
||||
v1 := v[k1]
|
||||
if that.Router[k][k1] == nil {
|
||||
that.Router[k][k1] = v1
|
||||
}
|
||||
for k2 := range v1 {
|
||||
v2 := v1[k2]
|
||||
that.Router[k][k1][k2] = v2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
that.MethodRouter = MethodRouter{}
|
||||
modeRouterStrict := true
|
||||
if that.Config.GetBool("modeRouterStrict") == false {
|
||||
modeRouterStrict = false
|
||||
}
|
||||
for pk, pv := range that.Router {
|
||||
if !modeRouterStrict {
|
||||
pk = strings.ToLower(pk)
|
||||
}
|
||||
for ck, cv := range pv {
|
||||
if !modeRouterStrict {
|
||||
ck = strings.ToLower(ck)
|
||||
}
|
||||
for mk, mv := range cv {
|
||||
if !modeRouterStrict {
|
||||
mk = strings.ToLower(mk)
|
||||
}
|
||||
that.MethodRouter["/"+pk+"/"+ck+"/"+mk] = mv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequest 发送测试 HTTP 请求
|
||||
func (that *TestApp) TestRequest(method, path string, body *http.Request) TestResponse {
|
||||
w := httptest.NewRecorder()
|
||||
that.Application.ServeHTTP(w, body)
|
||||
return TestResponse{
|
||||
StatusCode: w.Code,
|
||||
Body: w.Body.Bytes(),
|
||||
Header: w.Header(),
|
||||
}
|
||||
}
|
||||
|
||||
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
|
||||
func (that *TestApp) RunTests(t *testing.T) {
|
||||
for projName, projDef := range that.projs {
|
||||
projName := projName
|
||||
projDef := projDef
|
||||
t.Run(projName, func(t *testing.T) {
|
||||
for ctrName, ctrTest := range projDef.Tests {
|
||||
ctrName := ctrName
|
||||
ctrTest := ctrTest
|
||||
t.Run(ctrName, func(t *testing.T) {
|
||||
for methodName, apiTest := range ctrTest {
|
||||
methodName := methodName
|
||||
apiTest := apiTest
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
api := &Api{
|
||||
app: that,
|
||||
path: path,
|
||||
t: t,
|
||||
}
|
||||
t.Run(apiTest.Desc, func(t *testing.T) {
|
||||
api.t = t
|
||||
apiTest.Func(api)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PrintCoverage 输出 API 测试覆盖率报告
|
||||
func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
report := CoverageReport{}
|
||||
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
that.collector.mu.Lock()
|
||||
for _, r := range that.collector.Records {
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
that.collector.mu.Unlock()
|
||||
|
||||
for projName, projDef := range that.projs {
|
||||
for ctrName, ctr := range projDef.Proj {
|
||||
for methodName := range ctr {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
report.Total++
|
||||
|
||||
ctrTest, hasCtr := projDef.Tests[ctrName]
|
||||
if !hasCtr {
|
||||
report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName)
|
||||
report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false})
|
||||
continue
|
||||
}
|
||||
_, hasMethod := ctrTest[methodName]
|
||||
if !hasMethod {
|
||||
report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName)
|
||||
report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false})
|
||||
continue
|
||||
}
|
||||
|
||||
report.Covered++
|
||||
mc := MethodCoverage{Path: path, HasTest: true}
|
||||
if records, ok := pathRecords[path]; ok {
|
||||
mc.CaseCount = len(records)
|
||||
for _, r := range records {
|
||||
mc.Duration += r.Duration
|
||||
if r.Passed {
|
||||
mc.Passed++
|
||||
} else {
|
||||
mc.Failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Details = append(report.Details, mc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n========== API 测试覆盖率报告 ==========")
|
||||
if report.Total > 0 {
|
||||
fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n",
|
||||
report.Total, report.Covered,
|
||||
float64(report.Covered)/float64(report.Total)*100)
|
||||
} else {
|
||||
fmt.Println("总接口: 0")
|
||||
}
|
||||
|
||||
fmt.Println("\n已覆盖的接口:")
|
||||
for _, d := range report.Details {
|
||||
if d.HasTest && d.CaseCount > 0 {
|
||||
fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n",
|
||||
d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration)
|
||||
} else if d.HasTest {
|
||||
fmt.Printf(" + %s\t(已定义, 未运行)\n", d.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(report.Missing) > 0 {
|
||||
fmt.Println("\n未覆盖的接口:")
|
||||
for _, path := range report.Missing {
|
||||
fmt.Println(" -", path)
|
||||
}
|
||||
}
|
||||
fmt.Println("========================================")
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// DB 获取测试应用的数据库实例(带 testTx)
|
||||
func (that *TestApp) DB() *HoTimeDB {
|
||||
return &that.Db
|
||||
}
|
||||
Reference in New Issue
Block a user