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) if app.HoTimeCache != nil { app.HoTimeCache.DisableDbCache() } 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 }