feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁

补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 18:11:08 +08:00
parent 9fe4ab2758
commit 16496a8dc0
14 changed files with 4178 additions and 418 deletions
+79 -12
View File
@@ -1,7 +1,10 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
. "code.hoteas.com/golang/hotime"
@@ -18,27 +21,91 @@ var ProjectTest = ProjTest{
var testApp *TestApp
func TestMain(m *testing.M) {
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
_ = os.Chdir("..")
testApp = NewTestApp("config/config.json",
TestProj{
testApp = Test("config/config.json").
WorkDir("..").
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjectTest,
},
},
)
}).
Flows(DemoFlows)
// 在测试前初始化各数据库的测试表和种子数据
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
SetupDatabase(&testApp.Db)
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStopzzz_should_skip 应 Skip
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
func TestInterruptStop(t *testing.T) {
var app *TestApp
secondRan := false
app = Test("config/config.json").
Swagger("Interrupt Demo", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjTest{
"expect_demo": CtrTest{
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
ExpectDemoTest["string_result"].Func(a)
app.RequestStop()
}},
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
secondRan = true
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
}},
},
},
},
})
SetupDatabase(&app.Db)
app.RunTests(t)
if secondRan {
t.Fatal("停测后仍执行了后续接口")
}
_ = app.Db.RollbackTestTx()
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
t.Fatalf("GenerateSwagger: %v", err)
}
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
data, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
}
var spec struct {
Endpoints []struct {
Path string `json:"path"`
Cases json.RawMessage `json:"cases"`
} `json:"endpoints"`
}
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("解析 api-spec 失败: %v", err)
}
found := false
for _, ep := range spec.Endpoints {
if ep.Path != "/app/expect_demo/string_result" {
continue
}
found = true
if !strings.Contains(string(ep.Cases), `"name"`) {
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
}
}
if !found {
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
}
}
+1 -20
View File
@@ -231,25 +231,6 @@ var ExpectDemoTest = CtrTest{
}).
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
// ======== 演示故意失败的 Verify ========
a.Form(Map{"name": "verify_fail"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
}
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
if row == nil {
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
}
if row.GetCeilInt64("state") != 99 {
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0,故意校验 99 演示失败效果)", row.GetCeilInt64("state"))
}
return nil
}).
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
}},
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
var DemoFlows = FlowTest{
"expect_chain": {
Group: "expect_demo",
Desc: "expect_demo 多接口联调",
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
Func: func(f *Flow) {
f.Step("字符串结果", "/app/expect_demo/string_result").
FromCase("result是字符串").
Note("校验字符串 result:期望返回「操作成功」。").
Verify(func(a *Api) error {
if a.Resp().GetBody().GetString("result") != "操作成功" {
return fmt.Errorf("result 期望 操作成功")
}
return nil
}).
Get("步骤1-字符串", 0, "样本")
f.Step("Map结果", "/app/expect_demo/map_result").
FromCase("result是Map-校验字段名+类型+值").
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1")
}
return nil
}).
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
},
},
}
+3 -2
View File
@@ -145,9 +145,10 @@ func testBasicCRUD(that *Context) Map {
now := nowFunc(that)
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
uniq := time.Now().UnixNano()
adminId := that.Db.Insert("admin", Map{
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
"phone": fmt.Sprintf("139%08d", uniq%100000000),
"state": 1,
"password": "test123456",
"role_id": 1,