Files
hotime/testing_helper.go
T
hoteas 37a67f5810 feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应
- 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器
- 新增 Resp 方法,获取最近一次请求的响应以便于断言
- 在 TestCollector 中添加 Visited 字段,记录已调用的路径
- 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据
- 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
2026-03-30 01:55:07 +08:00

295 lines
7.0 KiB
Go
Raw 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.
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{}
Note string
ExpectResult interface{}
VerifyError string
}
// TestCollector 线程安全的测试记录收集器
type TestCollector struct {
mu sync.Mutex
Records []TestRecord
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
}
func (c *TestCollector) Add(r TestRecord) {
c.mu.Lock()
defer c.mu.Unlock()
c.Records = append(c.Records, r)
}
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
func (c *TestCollector) MarkVisited(path string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Visited == nil {
c.Visited = map[string]bool{}
}
c.Visited[path] = true
}
// 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) {
path := "/" + projName + "/" + ctrName + "/" + methodName
that.collector.MarkVisited(path)
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启测试事务失败:", err)
}
defer that.Db.RollbackTestTx()
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
}