diff --git a/.cursor/plans/fix_caller_detection_05e66771.plan.md b/.cursor/plans/fix_caller_detection_05e66771.plan.md new file mode 100644 index 0000000..5351669 --- /dev/null +++ b/.cursor/plans/fix_caller_detection_05e66771.plan.md @@ -0,0 +1,139 @@ +--- +name: Fix caller detection +overview: 修复三个日志/测试相关 bug:(1) 调用者智能识别漏掉 log/ 包自身 (2) 测试模式 SQL 出错不会导致用例失败 (3) 测试模式 WebConnectLog 写入已关闭的 NUL 文件 +todos: + - id: fix-framework-detection + content: 在 isHoTimeFrameworkFile 中 runtime/ 检查之后,添加 log/ 前缀检查 + status: completed + - id: fix-sql-error-test-fail + content: 测试模式 SQL 出错时自动让当前用例失败(db 增加错误计数 + execute 中检查) + status: completed + - id: fix-zerolog-nul-closed + content: NewTestApp 中 Init 完成后将 WebConnectLog 置为 nil,避免写入已关闭的 NUL 文件 + status: completed +isProject: false +--- + +# 修复日志/测试三个 Bug + +--- + +## Bug 1:调用者智能识别始终显示 log/logger.go:318 + +### 根因 + +[log/logger.go](log/logger.go) 中的 `isHoTimeFrameworkFile` 函数对 `log/` 包自身存在识别盲区。 + +```mermaid +flowchart TD + A["应用代码 或 db/query.go 调用 Logger.Debug()"] --> B["zerolog Event.Msg()"] + B --> C["callerMarshalFunc (log/logger.go:317)"] + C --> D["findCaller 通过 runtime.Caller(i) 逐帧遍历调用栈"] + D --> E{"isHoTimeFrameworkFile?"} + E -->|"是框架文件 -> 跳过,继续向上"| D + E -->|"非框架文件 -> 作为调用者返回"| F["显示在日志输出中"] +``` + +`findCaller` 在 `i=1` 时拿到 `log/logger.go:318`。经过 `shortenPath` 截断后变成 `log/logger.go`。两条检测路径都漏掉了它: + +- **路径一(第 392 行)**:`frameworkDirs` 包含 `"log/"`,但要求路径含 `"hotime"`。`shortenPath` 只保留 2 段路径,`log/logger.go` 中没有 `"hotime"`。**未命中。** +- **路径二(第 410 行)**:`frameworkCoreDirs` 只有 `db/`、`common/`、`code/`、`cache/`,**没有 `"log/"`**。**未命中。** + +### 修复 + +在 [log/logger.go](log/logger.go) 第 387-389 行 `runtime/` 检查之后加入 `log/` 前缀检查: + +```go +if strings.HasPrefix(file, "runtime/") { + return true +} +if strings.HasPrefix(file, "log/") { + return true +} +``` + +--- + +## Bug 2:测试模式 SQL 执行失败不会导致用例失败 + +### 根因 + +[db/query.go](db/query.go) `logSQL` 在测试模式下只是把 SQL 错误写入 `testLogBuf`,但 [testing_api.go](testing_api.go) 的 `execute` 中只在断言失败时才打印这些日志,**从不检查是否有 SQL 错误**。即使请求处理过程中数据库出错,只要 HTTP 响应的 status/msg 符合预期,测试就通过了。 + +当前流程: +```mermaid +flowchart LR + A["ServeHTTP"] --> B["检查 status/msg/result"] + B -->|"通过"| C["FlushTestLog 丢弃"] + B -->|"失败"| D["FlushTestLog 打印"] +``` + +### 修复 + +在 [db/db.go](db/db.go) 增加一个测试 SQL 错误计数器: + +```go +testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic) +``` + +在 [db/query.go](db/query.go) `logSQL` 中,当 `err != nil` 且 `testLogBuf != nil` 时,`atomic.AddInt32(&that.testSQLErrCount, 1)`。 + +在 [db/db.go](db/db.go) 增加两个方法: + +```go +func (that *HoTimeDB) TestSQLErrorCount() int32 { + return atomic.LoadInt32(&that.testSQLErrCount) +} +func (that *HoTimeDB) ResetTestSQLErrorCount() { + atomic.StoreInt32(&that.testSQLErrCount, 0) +} +``` + +在 [testing_api.go](testing_api.go) `execute` 中,`ServeHTTP` 之后、断言之前,检查计数: + +```go +c.api.app.Db.FlushTestLog() // 先清空请求前的残留 +// ... ServeHTTP ... +sqlErrCount := c.api.app.Db.TestSQLErrorCount() +c.api.app.Db.ResetTestSQLErrorCount() +if sqlErrCount > 0 { + sqlLog := c.api.app.Db.FlushTestLog() + t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog) +} +``` + +修复后流程: +```mermaid +flowchart LR + A["ServeHTTP"] --> B{"SQL 出错?"} + B -->|"是"| C["t.Fatalf 用例直接失败"] + B -->|"否"| D["正常断言 status/msg/result"] +``` + +--- + +## Bug 3:zerolog: could not write event: write NUL: file already closed + +### 根因 + +[testing_helper.go](testing_helper.go) `NewTestApp` 中的初始化顺序: + +``` +1. os.Stderr = devNull (重定向到 NUL) +2. Init(configPath) (创建 WebConnectLog,其 ConsoleWriter.Out = devNull) +3. os.Stderr = origStderr (恢复真实 stderr) +4. devNull.Close() (关闭 NUL 文件) +5. app.Log.SetOutput(io.Discard) (只修复了主日志) + -- WebConnectLog 仍然引用已关闭的 devNull -- +6. 测试请求触发 WebConnectLog.Info() → 写入已关闭文件 → 报错 +``` + +### 修复 + +在 [testing_helper.go](testing_helper.go) `devNull.Close()` 之后、`app.Log.SetOutput(io.Discard)` 之前,加一行: + +```go +app.WebConnectLog = nil +``` + +测试环境不需要访问日志。`application.go` 第 350 行 `if that.WebConnectLog != nil` 已有 nil 保护,置 nil 即可安全跳过。 diff --git a/.cursor/plans/fix_caller_detection_v2_2c0111aa.plan.md b/.cursor/plans/fix_caller_detection_v2_2c0111aa.plan.md new file mode 100644 index 0000000..48203e7 --- /dev/null +++ b/.cursor/plans/fix_caller_detection_v2_2c0111aa.plan.md @@ -0,0 +1,127 @@ +--- +name: Fix caller detection v2 +overview: 重新设计 findCaller 的调用者过滤逻辑:(1) 删除 maxFrameworkDepth 提前退出 (2) 收窄 isHoTimeFrameworkFile 定义为仅跳过日志/DB执行管线,让框架业务文件(code/makecode.go、application.go、context.go 等)作为有意义的调用者返回。 +todos: + - id: rewrite-findcaller + content: 重写 findCaller:删除 maxFrameworkDepth、applicationFile 跟踪,简化为跳过基础设施 + 返回第一个业务文件 + status: completed + - id: rewrite-framework-check + content: 重命名 isHoTimeFrameworkFile 为 isInfrastructureFile,收窄到只匹配 log/db/cache/common/dri,移除 code/ 和 application.go 等 + status: completed +isProject: false +--- + +# 修复调用者智能识别(v2) + +## 根因分析 + +用户看到 `[db/crud.go:149]`,实际调用者是 `code/makecode.go`。两个问题叠加: + +**问题 A**: `maxFrameworkDepth = 10` 过低。zerolog 内部帧(~5) + log/logger.go(1) + db/query.go(3-4) + db/crud.go(1) 轻松达到 10,触发提前返回,根本没走到 `code/makecode.go`。 + +**问题 B**: 即使去掉 `maxFrameworkDepth`,`code/makecode.go` 在 `frameworkCoreDirs` 中被 `"code/"` + `"makecode.go"` 命中,也会被跳过。最终回退到 `applicationFile`(application.go)或 `lastFrameworkFile`,都不是用户想要的。 + +## 设计思路 + +用户原话:"应用层触发的就打印应用层的,如果是框架层的业务造成的就打印框架层对应位置的,但肯定不是 log/logger.go"。 + +核心区分: +- **基础设施文件**(应跳过):日志管线(log/、zerolog/)、DB 执行管线(db/\*)、缓存中间件(cache/\*)、Go runtime、通用工具(common/\*)、数据库驱动(dri/\*) +- **业务文件**(应返回):`code/makecode.go`、`application.go`、`context.go`、`session.go`、所有应用层代码 + +跳过基础设施,返回第一个业务文件。无论这个业务文件是应用层还是框架层。 + +## 具体修改 + +### 1. [log/logger.go](log/logger.go) -- `findCaller` 简化 + +- **删除** `maxFrameworkDepth` 常量(第 315 行)和 `frameworkCount >= maxFrameworkDepth` 提前退出(第 343-345 行)。`i < 20` 循环上限已足够保护。 +- **删除** `applicationFile` / `applicationLine` 跟踪及其回退逻辑(不再需要 application.go 特殊处理)。 +- **删除** `frameworkCount` 计数器(不再需要)。 +- 简化后的 `findCaller`: + +```go +func findCaller(origFile string, origLine int) string { + var lastInfraFile string + var lastInfraLine int + + for i := 1; i < 20; i++ { + _, file, line, ok := runtime.Caller(i) + if !ok { + break + } + shortFile := shortenPath(file) + + if isInfrastructureFile(shortFile) { + lastInfraFile = shortFile + lastInfraLine = line + continue + } + + return fmt.Sprintf("%s:%d", shortFile, line) + } + + if lastInfraFile != "" { + return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine) + } + return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine) +} +``` + +### 2. [log/logger.go](log/logger.go) -- 重命名 + 收窄 `isHoTimeFrameworkFile` + +重命名为 `isInfrastructureFile`,仅匹配日志/DB/缓存/通用工具等"管道"文件: + +```go +func isInfrastructureFile(file string) bool { + // 第三方日志库 + if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") { + return true + } + if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") { + return true + } + if strings.HasPrefix(file, "runtime/") { + return true + } + // HoTime 日志包 + if strings.HasPrefix(file, "log/") { + return true + } + + // DB 执行管线(db/ 下全部文件) + infraPrefixes := []string{"db/", "cache/", "common/", "dri/"} + for _, prefix := range infraPrefixes { + if strings.HasPrefix(file, prefix) { + return true + } + } + + // 兼容路径含 hotime 的场景(module 全路径未被 shortenPath 完全截断时) + lowerFile := strings.ToLower(file) + if strings.Contains(lowerFile, "hotime") { + infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"} + for _, dir := range infraDirs { + if strings.Contains(file, dir) { + return true + } + } + } + + return false +} +``` + +关键变更: +- `"code/"` 从所有列表中移除 -- `code/makecode.go` 不再被跳过 +- `application.go`、`context.go`、`session.go` 等单文件匹配全部移除 -- 框架业务文件不再被跳过 +- `frameworkCoreDirs` 中的 `"code/"` 及其文件名(makecode.go、template.go、config.go)移除 + +## 修复后效果 + +| 调用链 | 显示结果 | +|--------|---------| +| app/user.go -> db.GetMap -> logSQL | `app/user.go:XX` | +| code/makecode.go -> db.GetList -> logSQL | `code/makecode.go:XX` | +| application.go SetConfig -> db -> logSQL | `application.go:XX` | +| context.go session -> db -> logSQL | `context.go:XX` | diff --git a/db/db.go b/db/db.go index a1b418e..b2ed802 100644 --- a/db/db.go +++ b/db/db.go @@ -41,9 +41,10 @@ type HoTimeDB struct { Dialect Dialect // 数据库方言适配器 testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 testMu *sync.Mutex // 保护 testTx 单连接不被并发访问 - testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出 - testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入 - txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享) + testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出 + testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入 + testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic) + txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享) } // GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error) @@ -215,6 +216,16 @@ func (that *HoTimeDB) FlushTestLog() string { return s } +// TestSQLErrorCount 返回测试模式下的 SQL 错误计数 +func (that *HoTimeDB) TestSQLErrorCount() int32 { + return atomic.LoadInt32(&that.testSQLErrCount) +} + +// ResetTestSQLErrorCount 重置测试 SQL 错误计数 +func (that *HoTimeDB) ResetTestSQLErrorCount() { + atomic.StoreInt32(&that.testSQLErrCount, 0) +} + // BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行 func (that *HoTimeDB) BeginTestTx() error { tx, err := that.DB.Begin() diff --git a/db/query.go b/db/query.go index 643dc60..e818238 100644 --- a/db/query.go +++ b/db/query.go @@ -9,6 +9,7 @@ import ( "reflect" "strconv" "strings" + "sync/atomic" "time" . "code.hoteas.com/golang/hotime/common" @@ -168,6 +169,7 @@ func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) { that.testLogBufMu.Lock() if that.testLogBuf != nil { that.testLogBuf.WriteString(msg + "\n") + atomic.AddInt32(&that.testSQLErrCount, 1) } else if that.Log != nil { that.Log.Error().Msg(msg) } diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index ecb28b9..88c425c 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -774,28 +774,58 @@ func TestMain(m *testing.M) { } ``` -**输出示例:** +**输出示例(全部通过):** ``` ========== API 测试覆盖率报告 ========== 总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3% +总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0% -已覆盖的接口: - + /api/user/login 4 用例 (4 通过, 0 失败) 12ms - + /api/user/info 2 用例 (2 通过, 0 失败) 5ms - + /api/user/token 2 用例 (2 通过, 0 失败) 4ms - + /api/user/create 4 用例 (4 通过, 0 失败) 18ms - + /api/user/forget 2 用例 (2 通过, 0 失败) 6ms - + /api/user/file 2 用例 (2 通过, 0 失败) 9ms +---------- 通过的接口 ---------- + ✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms + ✓ /api/user/info 2 用例 (2 通过, 0 失败) 5ms + ✓ /api/user/token 2 用例 (2 通过, 0 失败) 4ms + ✓ /api/user/create 4 用例 (4 通过, 0 失败) 18ms + ✓ /api/user/forget 2 用例 (2 通过, 0 失败) 6ms + ✓ /api/user/file 2 用例 (2 通过, 0 失败) 9ms -未覆盖的接口: +未覆盖的接口: (36 个) - api/goods/list - api/goods/create - api/order/list - - ... (共36个未覆盖) + - ... ======================================== ``` +**输出示例(有失败):** + +``` +========== API 测试覆盖率报告 ========== +总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3% +总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5% + +---------- 未通过的接口 (1个) ---------- + ✗ /api/order/create 5 用例 (3 通过, 2 失败) 25ms + [失败] 正常创建订单 — Verify: order 表未写入订单记录 + [失败] 库存扣减 — 状态码不匹配: 期望 0, 实际 4 + +---------- 通过的接口 ---------- + ✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms + ✓ /api/user/info 2 用例 (2 通过, 0 失败) 5ms + ... + +未覆盖的接口: (36 个) + - api/goods/list + - ... +======================================== +``` + +报告分为四个区域: +- **汇总区**:接口覆盖率 + 用例通过率,一目了然 +- **未通过的接口**:失败接口单独置顶,每个失败用例附带失败原因(状态码不匹配/消息不匹配/响应结构校验失败/Verify 校验失败) +- **通过的接口**:正常通过的接口列表 +- **未覆盖的接口**:尚未编写测试的接口 + ### api-spec.json 覆盖率字段说明 `GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段: diff --git a/log/logger.go b/log/logger.go index 8ab5f48..62f153b 100644 --- a/log/logger.go +++ b/log/logger.go @@ -312,18 +312,13 @@ func (w *TemplateFileWriter) Write(p []byte) (n int, err error) { // --- 调用者智能过滤 --- -const maxFrameworkDepth = 10 - func callerMarshalFunc(_ uintptr, file string, line int) string { return findCaller(file, line) } func findCaller(origFile string, origLine int) string { - frameworkCount := 0 - var lastFrameworkFile string - var lastFrameworkLine int - var applicationFile string - var applicationLine int + var lastInfraFile string + var lastInfraLine int for i := 1; i < 20; i++ { _, file, line, ok := runtime.Caller(i) @@ -332,28 +327,17 @@ func findCaller(origFile string, origLine int) string { } shortFile := shortenPath(file) - if isHoTimeFrameworkFile(shortFile) { - frameworkCount++ - lastFrameworkFile = shortFile - lastFrameworkLine = line - if strings.Contains(shortFile, "application.go") { - applicationFile = shortFile - applicationLine = line - } - if frameworkCount >= maxFrameworkDepth { - return fmt.Sprintf("%s:%d", shortFile, line) - } + if isInfrastructureFile(shortFile) { + lastInfraFile = shortFile + lastInfraLine = line continue } return fmt.Sprintf("%s:%d", shortFile, line) } - if applicationFile != "" { - return fmt.Sprintf("%s:%d", applicationFile, applicationLine) - } - if lastFrameworkFile != "" { - return fmt.Sprintf("%s:%d", lastFrameworkFile, lastFrameworkLine) + if lastInfraFile != "" { + return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine) } return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine) } @@ -375,52 +359,37 @@ func shortenPath(file string) string { return file } -func isHoTimeFrameworkFile(file string) bool { - // zerolog 内部文件 +// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线, +// 这些文件在调用栈中会被跳过,以显示真正的业务调用者。 +// 框架业务文件(application.go、context.go、code/makecode.go 等)不在此列, +// 会作为有意义的调用者返回。 +func isInfrastructureFile(file string) bool { if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") { return true } - // logrus 遗留(vendor 中可能存在) if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") { return true } if strings.HasPrefix(file, "runtime/") { return true } + if strings.HasPrefix(file, "log/") { + return true + } - lowerFile := strings.ToLower(file) - if strings.Contains(lowerFile, "hotime") { - frameworkDirs := []string{"db/", "common/", "code/", "cache/", "log/", "dri/"} - for _, dir := range frameworkDirs { - if strings.Contains(file, dir) { - return true - } - } - if strings.HasSuffix(file, "application.go") || - strings.HasSuffix(file, "context.go") || - strings.HasSuffix(file, "session.go") || - strings.HasSuffix(file, "const.go") || - strings.HasSuffix(file, "type.go") || - strings.HasSuffix(file, "var.go") || - strings.HasSuffix(file, "mime.go") { + infraPrefixes := []string{"db/", "cache/", "common/", "dri/"} + for _, prefix := range infraPrefixes { + if strings.HasPrefix(file, prefix) { return true } } - frameworkCoreDirs := []string{"db/", "common/", "code/", "cache/"} - for _, dir := range frameworkCoreDirs { - if strings.HasPrefix(file, dir) { - frameworkFiles := []string{ - "query.go", "crud.go", "where.go", "builder.go", "db.go", - "dialect.go", "aggregate.go", "transaction.go", "identifier.go", - "error.go", "func.go", "map.go", "obj.go", "slice.go", - "makecode.go", "template.go", "config.go", - "cache.go", "cache_db.go", "cache_memory.go", "cache_redis.go", - } - for _, f := range frameworkFiles { - if strings.HasSuffix(file, f) { - return true - } + lowerFile := strings.ToLower(file) + if strings.Contains(lowerFile, "hotime") { + infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"} + for _, dir := range infraDirs { + if strings.Contains(file, dir) { + return true } } } diff --git a/testing_api.go b/testing_api.go index 4df5981..f91de7b 100644 --- a/testing_api.go +++ b/testing_api.go @@ -262,14 +262,62 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i t.Run(desc, func(t *testing.T) { start := time.Now() + passed := true + var failReason, verifyError string + var body Map + defer func() { + record := TestRecord{ + Path: c.api.path, + Desc: desc, + CaseName: desc, + Passed: passed, + Duration: time.Since(start), + Method: method, + Note: c.note, + VerifyError: verifyError, + FailReason: failReason, + } + if c.query != nil { + record.Query = c.query + } + if c.jsonBody != nil { + record.JsonBody = c.jsonBody + } + if c.formBody != nil { + record.FormBody = c.formBody + } + if c.fileContent != nil { + record.HasFile = true + record.FileField = c.fileField + } + if len(body) > 0 { + record.ResponseBody = body + } + expectResp := Map{"status": expectStatus} + if expectStatus != 0 { + if expectMsg != "" { + errorInfo := Map{"msg": expectMsg} + errorType := c.api.app.Config.GetMap("error").GetString(ObjToStr(expectStatus)) + if errorType != "" { + errorInfo["type"] = errorType + } + expectResp["result"] = errorInfo + expectResp["error"] = errorInfo + } + } else if hasExpectResult { + expectResp["result"] = expectResult + } + record.ExpectResult = expectResp + c.api.app.collector.Add(record) + }() + + c.api.app.Db.ResetTestSQLErrorCount() req := c.buildRequest(method) w := httptest.NewRecorder() c.api.app.Application.ServeHTTP(w, req) - duration := time.Since(start) - - body := Map{} + body = Map{} if len(w.Body.Bytes()) > 0 { if obj, err := JsonToObj(w.Body.String()); err == nil { if m, ok := obj.(map[string]interface{}); ok { @@ -285,9 +333,16 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i t: t, } - passed := true + if sqlErrCount := c.api.app.Db.TestSQLErrorCount(); sqlErrCount > 0 { + passed = false + failReason = fmt.Sprintf("SQL错误: 请求处理中有 %d 次 SQL 错误", sqlErrCount) + sqlLog := c.api.app.Db.FlushTestLog() + t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog) + } + actualStatus := body.GetInt("status") if actualStatus != expectStatus { + failReason = fmt.Sprintf("状态码不匹配: 期望 %d, 实际 %d", expectStatus, actualStatus) t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String()) passed = false } @@ -295,6 +350,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i if passed && expectMsg != "" { actualMsg := resp.GetMsg() if actualMsg != expectMsg { + failReason = fmt.Sprintf("消息不匹配: 期望 %q, 实际 %q", expectMsg, actualMsg) t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String()) passed = false } @@ -302,17 +358,18 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i if passed && hasExpectResult { if !validateShape(body["result"], expectResult, "result", t) { + failReason = "响应结构校验失败" passed = false } } c.api.lastResp = resp - var verifyError string if passed && c.verifyFn != nil { if err := c.verifyFn(c.api); err != nil { t.Errorf("Verify 校验失败: %s", err.Error()) passed = false verifyError = err.Error() + failReason = "Verify: " + err.Error() } } @@ -323,49 +380,6 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i } else { c.api.app.Db.FlushTestLog() } - - record := TestRecord{ - Path: c.api.path, - Desc: desc, - CaseName: desc, - Passed: passed, - Duration: duration, - Method: method, - Note: c.note, - VerifyError: verifyError, - } - if c.query != nil { - record.Query = c.query - } - if c.jsonBody != nil { - record.JsonBody = c.jsonBody - } - if c.formBody != nil { - record.FormBody = c.formBody - } - if c.fileContent != nil { - record.HasFile = true - record.FileField = c.fileField - } - if len(body) > 0 { - record.ResponseBody = body - } - expectResp := Map{"status": expectStatus} - if expectStatus != 0 { - if expectMsg != "" { - errorInfo := Map{"msg": expectMsg} - errorType := c.api.app.Config.GetMap("error").GetString(ObjToStr(expectStatus)) - if errorType != "" { - errorInfo["type"] = errorType - } - expectResp["result"] = errorInfo - expectResp["error"] = errorInfo - } - } else if hasExpectResult { - expectResp["result"] = expectResult - } - record.ExpectResult = expectResp - c.api.app.collector.Add(record) }) if resp == nil { diff --git a/testing_helper.go b/testing_helper.go index 936cbfc..9025dc8 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -31,20 +31,30 @@ type TestResponse struct { // CoverageReport 覆盖率报告 type CoverageReport struct { - Total int - Covered int - Missing []string - Details []MethodCoverage + 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 + Path string + HasTest bool + CaseCount int + Passed int + Failed int + Duration time.Duration + FailedCases []FailedCase +} + +// FailedCase 失败用例信息 +type FailedCase struct { + CaseName string + FailReason string } // TestRecord 单条测试记录 @@ -64,6 +74,7 @@ type TestRecord struct { Note string ExpectResult interface{} VerifyError string + FailReason string } // TestCollector 线程安全的测试记录收集器 @@ -106,6 +117,7 @@ func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context os.Stderr = origStderr devNull.Close() + app.WebConnectLog = nil app.Log.SetOutput(io.Discard) for _, lis := range listeners { @@ -272,6 +284,10 @@ func (that *TestApp) PrintCoverage() CoverageReport { mc.Passed++ } else { mc.Failed++ + mc.FailedCases = append(mc.FailedCases, FailedCase{ + CaseName: r.CaseName, + FailReason: r.FailReason, + }) } } } @@ -280,9 +296,53 @@ func (that *TestApp) PrintCoverage() CoverageReport { } } + // 汇总全部用例统计 + 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", @@ -292,27 +352,36 @@ func (that *TestApp) PrintCoverage() CoverageReport { fmt.Println("总接口: 0") } - if !isPartialRun { - var ranDetails []MethodCoverage - for _, d := range report.Details { - if d.HasTest && d.CaseCount > 0 { - ranDetails = append(ranDetails, d) + 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(ranDetails) > 0 { - fmt.Println("\n本次运行:") - for _, d := range ranDetails { - fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n", - d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration) - } - } - } else { - fmt.Printf("\n本次运行 %d 个接口:\n", ranCount) - 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) - } + } + + 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) } } diff --git a/testing_swagger.go b/testing_swagger.go index 2f2834e..a482e2c 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -24,6 +24,7 @@ type swaggerTestCase struct { Response map[string]interface{} `json:"response,omitempty"` Expect interface{} `json:"expect,omitempty"` VerifyError string `json:"verifyError,omitempty"` + FailReason string `json:"failReason,omitempty"` } type paramSpec struct { @@ -359,10 +360,11 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { var cases []swaggerTestCase for _, r := range recs { - tc := swaggerTestCase{ - Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, - Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, - } + tc := swaggerTestCase{ + Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed, + Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError, + FailReason: r.FailReason, + } if r.Query != nil { tc.Query = r.Query } @@ -714,7 +716,8 @@ function cases(e){ h+='