enhance(tests): 改进测试用例记录与覆盖率报告
- 在 ApiCase 中添加失败原因记录,提升测试结果的可读性 - 更新 CoverageReport 结构,增加总用例、通过用例和失败用例计数 - 优化 PrintCoverage 方法,支持详细输出未通过接口及其失败原因 - 更新 Swagger 生成逻辑,包含失败原因字段,增强调试信息 - 改进 SQL 错误计数逻辑,确保在测试模式下准确记录 SQL 错误
This commit is contained in:
@@ -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 即可安全跳过。
|
||||||
@@ -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` |
|
||||||
@@ -41,9 +41,10 @@ type HoTimeDB struct {
|
|||||||
Dialect Dialect // 数据库方言适配器
|
Dialect Dialect // 数据库方言适配器
|
||||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
||||||
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
||||||
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出
|
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出
|
||||||
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
||||||
txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享)
|
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
|
||||||
|
txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error)
|
// GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error)
|
||||||
@@ -215,6 +216,16 @@ func (that *HoTimeDB) FlushTestLog() string {
|
|||||||
return s
|
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 都在此事务内执行
|
// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行
|
||||||
func (that *HoTimeDB) BeginTestTx() error {
|
func (that *HoTimeDB) BeginTestTx() error {
|
||||||
tx, err := that.DB.Begin()
|
tx, err := that.DB.Begin()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
@@ -168,6 +169,7 @@ func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) {
|
|||||||
that.testLogBufMu.Lock()
|
that.testLogBufMu.Lock()
|
||||||
if that.testLogBuf != nil {
|
if that.testLogBuf != nil {
|
||||||
that.testLogBuf.WriteString(msg + "\n")
|
that.testLogBuf.WriteString(msg + "\n")
|
||||||
|
atomic.AddInt32(&that.testSQLErrCount, 1)
|
||||||
} else if that.Log != nil {
|
} else if that.Log != nil {
|
||||||
that.Log.Error().Msg(msg)
|
that.Log.Error().Msg(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-10
@@ -774,28 +774,58 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**输出示例:**
|
**输出示例(全部通过):**
|
||||||
|
|
||||||
```
|
```
|
||||||
========== API 测试覆盖率报告 ==========
|
========== API 测试覆盖率报告 ==========
|
||||||
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
|
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
|
||||||
|
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%
|
||||||
|
|
||||||
已覆盖的接口:
|
---------- 通过的接口 ----------
|
||||||
+ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
|
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
|
||||||
+ /api/user/info 2 用例 (2 通过, 0 失败) 5ms
|
✓ /api/user/info 2 用例 (2 通过, 0 失败) 5ms
|
||||||
+ /api/user/token 2 用例 (2 通过, 0 失败) 4ms
|
✓ /api/user/token 2 用例 (2 通过, 0 失败) 4ms
|
||||||
+ /api/user/create 4 用例 (4 通过, 0 失败) 18ms
|
✓ /api/user/create 4 用例 (4 通过, 0 失败) 18ms
|
||||||
+ /api/user/forget 2 用例 (2 通过, 0 失败) 6ms
|
✓ /api/user/forget 2 用例 (2 通过, 0 失败) 6ms
|
||||||
+ /api/user/file 2 用例 (2 通过, 0 失败) 9ms
|
✓ /api/user/file 2 用例 (2 通过, 0 失败) 9ms
|
||||||
|
|
||||||
未覆盖的接口:
|
未覆盖的接口: (36 个)
|
||||||
- api/goods/list
|
- api/goods/list
|
||||||
- api/goods/create
|
- api/goods/create
|
||||||
- api/order/list
|
- 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 覆盖率字段说明
|
### api-spec.json 覆盖率字段说明
|
||||||
|
|
||||||
`GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段:
|
`GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段:
|
||||||
|
|||||||
+24
-55
@@ -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 {
|
func callerMarshalFunc(_ uintptr, file string, line int) string {
|
||||||
return findCaller(file, line)
|
return findCaller(file, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func findCaller(origFile string, origLine int) string {
|
func findCaller(origFile string, origLine int) string {
|
||||||
frameworkCount := 0
|
var lastInfraFile string
|
||||||
var lastFrameworkFile string
|
var lastInfraLine int
|
||||||
var lastFrameworkLine int
|
|
||||||
var applicationFile string
|
|
||||||
var applicationLine int
|
|
||||||
|
|
||||||
for i := 1; i < 20; i++ {
|
for i := 1; i < 20; i++ {
|
||||||
_, file, line, ok := runtime.Caller(i)
|
_, file, line, ok := runtime.Caller(i)
|
||||||
@@ -332,28 +327,17 @@ func findCaller(origFile string, origLine int) string {
|
|||||||
}
|
}
|
||||||
shortFile := shortenPath(file)
|
shortFile := shortenPath(file)
|
||||||
|
|
||||||
if isHoTimeFrameworkFile(shortFile) {
|
if isInfrastructureFile(shortFile) {
|
||||||
frameworkCount++
|
lastInfraFile = shortFile
|
||||||
lastFrameworkFile = shortFile
|
lastInfraLine = line
|
||||||
lastFrameworkLine = line
|
|
||||||
if strings.Contains(shortFile, "application.go") {
|
|
||||||
applicationFile = shortFile
|
|
||||||
applicationLine = line
|
|
||||||
}
|
|
||||||
if frameworkCount >= maxFrameworkDepth {
|
|
||||||
return fmt.Sprintf("%s:%d", shortFile, line)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s:%d", shortFile, line)
|
return fmt.Sprintf("%s:%d", shortFile, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if applicationFile != "" {
|
if lastInfraFile != "" {
|
||||||
return fmt.Sprintf("%s:%d", applicationFile, applicationLine)
|
return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
|
||||||
}
|
|
||||||
if lastFrameworkFile != "" {
|
|
||||||
return fmt.Sprintf("%s:%d", lastFrameworkFile, lastFrameworkLine)
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
|
return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
|
||||||
}
|
}
|
||||||
@@ -375,52 +359,37 @@ func shortenPath(file string) string {
|
|||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHoTimeFrameworkFile(file string) bool {
|
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
|
||||||
// zerolog 内部文件
|
// 这些文件在调用栈中会被跳过,以显示真正的业务调用者。
|
||||||
|
// 框架业务文件(application.go、context.go、code/makecode.go 等)不在此列,
|
||||||
|
// 会作为有意义的调用者返回。
|
||||||
|
func isInfrastructureFile(file string) bool {
|
||||||
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
|
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// logrus 遗留(vendor 中可能存在)
|
|
||||||
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
|
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(file, "runtime/") {
|
if strings.HasPrefix(file, "runtime/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(file, "log/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
lowerFile := strings.ToLower(file)
|
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
|
||||||
if strings.Contains(lowerFile, "hotime") {
|
for _, prefix := range infraPrefixes {
|
||||||
frameworkDirs := []string{"db/", "common/", "code/", "cache/", "log/", "dri/"}
|
if strings.HasPrefix(file, prefix) {
|
||||||
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") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frameworkCoreDirs := []string{"db/", "common/", "code/", "cache/"}
|
lowerFile := strings.ToLower(file)
|
||||||
for _, dir := range frameworkCoreDirs {
|
if strings.Contains(lowerFile, "hotime") {
|
||||||
if strings.HasPrefix(file, dir) {
|
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
|
||||||
frameworkFiles := []string{
|
for _, dir := range infraDirs {
|
||||||
"query.go", "crud.go", "where.go", "builder.go", "db.go",
|
if strings.Contains(file, dir) {
|
||||||
"dialect.go", "aggregate.go", "transaction.go", "identifier.go",
|
return true
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-48
@@ -262,14 +262,62 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
|||||||
|
|
||||||
t.Run(desc, func(t *testing.T) {
|
t.Run(desc, func(t *testing.T) {
|
||||||
start := time.Now()
|
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)
|
req := c.buildRequest(method)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c.api.app.Application.ServeHTTP(w, req)
|
c.api.app.Application.ServeHTTP(w, req)
|
||||||
|
|
||||||
duration := time.Since(start)
|
body = Map{}
|
||||||
|
|
||||||
body := Map{}
|
|
||||||
if len(w.Body.Bytes()) > 0 {
|
if len(w.Body.Bytes()) > 0 {
|
||||||
if obj, err := JsonToObj(w.Body.String()); err == nil {
|
if obj, err := JsonToObj(w.Body.String()); err == nil {
|
||||||
if m, ok := obj.(map[string]interface{}); ok {
|
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,
|
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")
|
actualStatus := body.GetInt("status")
|
||||||
if actualStatus != expectStatus {
|
if actualStatus != expectStatus {
|
||||||
|
failReason = fmt.Sprintf("状态码不匹配: 期望 %d, 实际 %d", expectStatus, actualStatus)
|
||||||
t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String())
|
t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String())
|
||||||
passed = false
|
passed = false
|
||||||
}
|
}
|
||||||
@@ -295,6 +350,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
|||||||
if passed && expectMsg != "" {
|
if passed && expectMsg != "" {
|
||||||
actualMsg := resp.GetMsg()
|
actualMsg := resp.GetMsg()
|
||||||
if actualMsg != expectMsg {
|
if actualMsg != expectMsg {
|
||||||
|
failReason = fmt.Sprintf("消息不匹配: 期望 %q, 实际 %q", expectMsg, actualMsg)
|
||||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
|
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
|
||||||
passed = false
|
passed = false
|
||||||
}
|
}
|
||||||
@@ -302,17 +358,18 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
|||||||
|
|
||||||
if passed && hasExpectResult {
|
if passed && hasExpectResult {
|
||||||
if !validateShape(body["result"], expectResult, "result", t) {
|
if !validateShape(body["result"], expectResult, "result", t) {
|
||||||
|
failReason = "响应结构校验失败"
|
||||||
passed = false
|
passed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.api.lastResp = resp
|
c.api.lastResp = resp
|
||||||
var verifyError string
|
|
||||||
if passed && c.verifyFn != nil {
|
if passed && c.verifyFn != nil {
|
||||||
if err := c.verifyFn(c.api); err != nil {
|
if err := c.verifyFn(c.api); err != nil {
|
||||||
t.Errorf("Verify 校验失败: %s", err.Error())
|
t.Errorf("Verify 校验失败: %s", err.Error())
|
||||||
passed = false
|
passed = false
|
||||||
verifyError = err.Error()
|
verifyError = err.Error()
|
||||||
|
failReason = "Verify: " + err.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,49 +380,6 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
|||||||
} else {
|
} else {
|
||||||
c.api.app.Db.FlushTestLog()
|
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 {
|
if resp == nil {
|
||||||
|
|||||||
+98
-29
@@ -31,20 +31,30 @@ type TestResponse struct {
|
|||||||
|
|
||||||
// CoverageReport 覆盖率报告
|
// CoverageReport 覆盖率报告
|
||||||
type CoverageReport struct {
|
type CoverageReport struct {
|
||||||
Total int
|
Total int
|
||||||
Covered int
|
Covered int
|
||||||
Missing []string
|
Missing []string
|
||||||
Details []MethodCoverage
|
Details []MethodCoverage
|
||||||
|
TotalCases int
|
||||||
|
PassedCases int
|
||||||
|
FailedCases int
|
||||||
}
|
}
|
||||||
|
|
||||||
// MethodCoverage 单个接口的覆盖详情
|
// MethodCoverage 单个接口的覆盖详情
|
||||||
type MethodCoverage struct {
|
type MethodCoverage struct {
|
||||||
Path string
|
Path string
|
||||||
HasTest bool
|
HasTest bool
|
||||||
CaseCount int
|
CaseCount int
|
||||||
Passed int
|
Passed int
|
||||||
Failed int
|
Failed int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
|
FailedCases []FailedCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailedCase 失败用例信息
|
||||||
|
type FailedCase struct {
|
||||||
|
CaseName string
|
||||||
|
FailReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecord 单条测试记录
|
// TestRecord 单条测试记录
|
||||||
@@ -64,6 +74,7 @@ type TestRecord struct {
|
|||||||
Note string
|
Note string
|
||||||
ExpectResult interface{}
|
ExpectResult interface{}
|
||||||
VerifyError string
|
VerifyError string
|
||||||
|
FailReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCollector 线程安全的测试记录收集器
|
// TestCollector 线程安全的测试记录收集器
|
||||||
@@ -106,6 +117,7 @@ func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context
|
|||||||
os.Stderr = origStderr
|
os.Stderr = origStderr
|
||||||
devNull.Close()
|
devNull.Close()
|
||||||
|
|
||||||
|
app.WebConnectLog = nil
|
||||||
app.Log.SetOutput(io.Discard)
|
app.Log.SetOutput(io.Discard)
|
||||||
|
|
||||||
for _, lis := range listeners {
|
for _, lis := range listeners {
|
||||||
@@ -272,6 +284,10 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
|||||||
mc.Passed++
|
mc.Passed++
|
||||||
} else {
|
} else {
|
||||||
mc.Failed++
|
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)
|
ranCount := len(visited)
|
||||||
isPartialRun := ranCount > 0 && ranCount < report.Covered
|
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 测试覆盖率报告 ==========")
|
fmt.Println("\n========== API 测试覆盖率报告 ==========")
|
||||||
if report.Total > 0 {
|
if report.Total > 0 {
|
||||||
fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n",
|
fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n",
|
||||||
@@ -292,27 +352,36 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
|||||||
fmt.Println("总接口: 0")
|
fmt.Println("总接口: 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPartialRun {
|
if isPartialRun && ranTotalCases > 0 {
|
||||||
var ranDetails []MethodCoverage
|
passRate := float64(ranPassedCases) / float64(ranTotalCases) * 100
|
||||||
for _, d := range report.Details {
|
fmt.Printf("本次运行: %d 个接口 | 总用例: %d | 通过: %d | 未通过: %d | 通过率: %.1f%%\n",
|
||||||
if d.HasTest && d.CaseCount > 0 {
|
ranInterfaces, ranTotalCases, ranPassedCases, ranFailedCases, passRate)
|
||||||
ranDetails = append(ranDetails, d)
|
} 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 {
|
if len(passedDetails) > 0 {
|
||||||
fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n",
|
fmt.Println("\n---------- 通过的接口 ----------")
|
||||||
d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration)
|
for _, d := range passedDetails {
|
||||||
}
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -24,6 +24,7 @@ type swaggerTestCase struct {
|
|||||||
Response map[string]interface{} `json:"response,omitempty"`
|
Response map[string]interface{} `json:"response,omitempty"`
|
||||||
Expect interface{} `json:"expect,omitempty"`
|
Expect interface{} `json:"expect,omitempty"`
|
||||||
VerifyError string `json:"verifyError,omitempty"`
|
VerifyError string `json:"verifyError,omitempty"`
|
||||||
|
FailReason string `json:"failReason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type paramSpec struct {
|
type paramSpec struct {
|
||||||
@@ -359,10 +360,11 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
|||||||
|
|
||||||
var cases []swaggerTestCase
|
var cases []swaggerTestCase
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
tc := swaggerTestCase{
|
tc := swaggerTestCase{
|
||||||
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
|
||||||
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
|
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
|
||||||
}
|
FailReason: r.FailReason,
|
||||||
|
}
|
||||||
if r.Query != nil {
|
if r.Query != nil {
|
||||||
tc.Query = r.Query
|
tc.Query = r.Query
|
||||||
}
|
}
|
||||||
@@ -714,7 +716,8 @@ function cases(e){
|
|||||||
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
|
||||||
|
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
const curlId='cc'+i;const respId='cr'+i;
|
const curlId='cc'+i;const respId='cr'+i;
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||||
@@ -880,7 +883,8 @@ function updateCasePanel(e,i){
|
|||||||
const c=e.cases[i];
|
const c=e.cases[i];
|
||||||
let h='<div style="padding:8px 10px">';
|
let h='<div style="padding:8px 10px">';
|
||||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||||
if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
|
||||||
|
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||||
const hasDetail=c.expect&&c.expect.result!==undefined;
|
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user