Files
hotime/testing_helper.go
T
hoteas 3430bdec19 enhance(tests): 改进测试用例记录与覆盖率报告
- 在 ApiCase 中添加失败原因记录,提升测试结果的可读性
- 更新 CoverageReport 结构,增加总用例、通过用例和失败用例计数
- 优化 PrintCoverage 方法,支持详细输出未通过接口及其失败原因
- 更新 Swagger 生成逻辑,包含失败原因字段,增强调试信息
- 改进 SQL 错误计数逻辑,确保在测试模式下准确记录 SQL 错误
2026-04-13 06:45:16 +08:00

403 lines
10 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 (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"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
TotalCases int
PassedCases int
FailedCases int
}
// MethodCoverage 单个接口的覆盖详情
type MethodCoverage struct {
Path string
HasTest bool
CaseCount int
Passed int
Failed int
Duration time.Duration
FailedCases []FailedCase
}
// FailedCase 失败用例信息
type FailedCase struct {
CaseName string
FailReason string
}
// 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
FailReason 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 {
origStdout := os.Stdout
origStderr := os.Stderr
devNull, _ := os.Open(os.DevNull)
os.Stdout = devNull
os.Stderr = devNull
app := Init(configPath)
os.Stdout = origStdout
os.Stderr = origStderr
devNull.Close()
app.WebConnectLog = nil
app.Log.SetOutput(io.Discard)
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()
}
app.Log.SetOutput(os.Stderr)
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()
methodBuf := &bytes.Buffer{}
that.Db.SetTestLogBuffer(methodBuf)
defer that.Db.SetTestLogBuffer(nil)
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)
}
visited := that.collector.Visited
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++
mc.FailedCases = append(mc.FailedCases, FailedCase{
CaseName: r.CaseName,
FailReason: r.FailReason,
})
}
}
}
report.Details = append(report.Details, mc)
}
}
}
// 汇总全部用例统计
for _, d := range report.Details {
report.TotalCases += d.CaseCount
report.PassedCases += d.Passed
report.FailedCases += d.Failed
}
ranCount := len(visited)
isPartialRun := ranCount > 0 && ranCount < report.Covered
// 分拣本次运行中通过/失败的接口
var passedDetails, failedDetails []MethodCoverage
for _, d := range report.Details {
if !d.HasTest || d.CaseCount == 0 {
continue
}
if isPartialRun && !visited[d.Path] {
continue
}
if d.Failed > 0 {
failedDetails = append(failedDetails, d)
} else {
passedDetails = append(passedDetails, d)
}
}
// 计算本次运行的用例统计(部分运行时只统计实际运行的接口)
ranInterfaces := len(failedDetails) + len(passedDetails)
var ranTotalCases, ranPassedCases, ranFailedCases int
if isPartialRun {
for _, d := range failedDetails {
ranTotalCases += d.CaseCount
ranPassedCases += d.Passed
ranFailedCases += d.Failed
}
for _, d := range passedDetails {
ranTotalCases += d.CaseCount
ranPassedCases += d.Passed
ranFailedCases += d.Failed
}
} else {
ranTotalCases = report.TotalCases
ranPassedCases = report.PassedCases
ranFailedCases = report.FailedCases
ranInterfaces = len(failedDetails) + len(passedDetails)
}
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")
}
if isPartialRun && ranTotalCases > 0 {
passRate := float64(ranPassedCases) / float64(ranTotalCases) * 100
fmt.Printf("本次运行: %d 个接口 | 总用例: %d | 通过: %d | 未通过: %d | 通过率: %.1f%%\n",
ranInterfaces, ranTotalCases, ranPassedCases, ranFailedCases, passRate)
} else if ranTotalCases > 0 {
passRate := float64(ranPassedCases) / float64(ranTotalCases) * 100
fmt.Printf("总用例: %d | 通过: %d | 未通过: %d | 通过率: %.1f%%\n",
ranTotalCases, ranPassedCases, ranFailedCases, passRate)
}
if len(failedDetails) > 0 {
fmt.Printf("\n---------- 未通过的接口 (%d个) ----------\n", len(failedDetails))
for _, d := range failedDetails {
fmt.Printf(" ✗ %s\t%d 用例 (%d 通过, %d 失败) %v\n",
d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration)
for _, fc := range d.FailedCases {
if fc.FailReason != "" {
fmt.Printf(" [失败] %s — %s\n", fc.CaseName, fc.FailReason)
} else {
fmt.Printf(" [失败] %s\n", fc.CaseName)
}
}
}
}
if len(passedDetails) > 0 {
fmt.Println("\n---------- 通过的接口 ----------")
for _, d := range passedDetails {
fmt.Printf(" ✓ %s\t%d 用例 (%d 通过, %d 失败) %v\n",
d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration)
}
}
if len(report.Missing) > 0 {
fmt.Printf("\n未覆盖的接口: (%d 个)\n", len(report.Missing))
for _, path := range report.Missing {
fmt.Println(" -", path)
}
}
fmt.Println("========================================")
return report
}
// DB 获取测试应用的数据库实例(带 testTx)
func (that *TestApp) DB() *HoTimeDB {
return &that.Db
}