feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁
补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+309
-87
@@ -7,8 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,8 +22,171 @@ import (
|
||||
// TestApp 测试应用,封装 Application 提供测试能力
|
||||
type TestApp struct {
|
||||
*Application
|
||||
projs TestProj
|
||||
collector *TestCollector
|
||||
configPath string
|
||||
listeners []func(*Context) bool
|
||||
workDir string
|
||||
projs TestProj
|
||||
flows FlowTest
|
||||
collector *TestCollector
|
||||
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerVersion string
|
||||
swaggerDir string
|
||||
|
||||
stopNewTests atomic.Bool
|
||||
swaggerMu sync.Mutex
|
||||
inited bool
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir)
|
||||
func Test(configPath string, listeners ...func(*Context) bool) *TestApp {
|
||||
return &TestApp{
|
||||
configPath: configPath,
|
||||
listeners: listeners,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
|
||||
// WorkDir 设置工作目录(在 Init 前 chdir)
|
||||
func (that *TestApp) WorkDir(dir string) *TestApp {
|
||||
that.workDir = dir
|
||||
return that
|
||||
}
|
||||
|
||||
// Swagger 启用接口级增量 Swagger 写入。
|
||||
// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir)
|
||||
func (that *TestApp) Swagger(args ...string) *TestApp {
|
||||
that.swaggerEnabled = true
|
||||
that.swaggerTitle = "API"
|
||||
that.swaggerVersion = "1.0.0"
|
||||
if len(args) > 0 && args[0] != "" {
|
||||
that.swaggerTitle = args[0]
|
||||
}
|
||||
if len(args) > 1 && args[1] != "" {
|
||||
that.swaggerVersion = args[1]
|
||||
}
|
||||
if len(args) > 2 && args[2] != "" {
|
||||
that.swaggerDir = args[2]
|
||||
}
|
||||
return that
|
||||
}
|
||||
|
||||
// Proj 注册路由与单接口测试,并完成 Init
|
||||
func (that *TestApp) Proj(projects TestProj) *TestApp {
|
||||
that.projs = projects
|
||||
that.ensureInit()
|
||||
return that
|
||||
}
|
||||
|
||||
// Flows 注册多接口业务流程测试(可选)
|
||||
func (that *TestApp) Flows(flows FlowTest) *TestApp {
|
||||
that.flows = flows
|
||||
return that
|
||||
}
|
||||
|
||||
// Run 执行测试:注册优雅停测信号 → m.Run → 覆盖率 → Swagger 收尾,返回 exit code
|
||||
func (that *TestApp) Run(m *testing.M) int {
|
||||
that.ensureInit()
|
||||
that.installStopSignals()
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if that.Application != nil {
|
||||
_ = that.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
that.PrintCoverage()
|
||||
if that.swaggerEnabled {
|
||||
dir := that.swaggerOutputDir()
|
||||
if err := that.GenerateSwagger(that.swaggerTitle, that.swaggerVersion, dir); err != nil {
|
||||
fmt.Printf("Swagger 收尾失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (that *TestApp) swaggerOutputDir() string {
|
||||
if that.swaggerDir != "" {
|
||||
return that.swaggerDir
|
||||
}
|
||||
if that.Application != nil && that.Config != nil {
|
||||
if d := that.Config.GetString("tpt"); d != "" {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return "tpt"
|
||||
}
|
||||
|
||||
func (that *TestApp) installStopSignals() {
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
fmt.Println("\n收到停测信号,将在当前接口/流程结束后停止并回滚测试事务…")
|
||||
that.RequestStop()
|
||||
}()
|
||||
}
|
||||
|
||||
// RequestStop 模拟优雅停测(等同收到 SIGINT):不再开启新的接口/流程,当前跑完后回滚并收尾
|
||||
func (that *TestApp) RequestStop() {
|
||||
that.stopNewTests.Store(true)
|
||||
}
|
||||
|
||||
func (that *TestApp) shouldStop() bool {
|
||||
return that.stopNewTests.Load()
|
||||
}
|
||||
|
||||
func (that *TestApp) ensureInit() {
|
||||
that.initOnce.Do(func() {
|
||||
if that.workDir != "" {
|
||||
if err := os.Chdir(that.workDir); err != nil {
|
||||
fmt.Printf("WorkDir(%s) 失败: %v\n", that.workDir, err)
|
||||
}
|
||||
}
|
||||
if that.configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
app := Init(that.configPath)
|
||||
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
devNull.Close()
|
||||
|
||||
app.WebConnectLog = nil
|
||||
app.Log.SetOutput(io.Discard)
|
||||
|
||||
for _, lis := range that.listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
that.Application = app
|
||||
if that.collector == nil {
|
||||
that.collector = &TestCollector{}
|
||||
}
|
||||
|
||||
if that.projs != nil {
|
||||
router := Router{}
|
||||
for projName, projDef := range that.projs {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
}
|
||||
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
app.Log.SetOutput(os.Stderr)
|
||||
that.inited = true
|
||||
})
|
||||
}
|
||||
|
||||
// TestResponse httptest 的原始响应封装
|
||||
@@ -59,29 +226,37 @@ type FailedCase struct {
|
||||
|
||||
// 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
|
||||
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
|
||||
FlowName string
|
||||
FlowGroup string
|
||||
FlowDesc string
|
||||
FlowPrep string
|
||||
FlowStep string
|
||||
FlowStepIndex int
|
||||
BindCase string // FromCase 绑定的单接口验收用例名
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
type TestCollector struct {
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
Visited map[string]bool
|
||||
VisitedFlows map[string]bool
|
||||
}
|
||||
|
||||
func (c *TestCollector) Add(r TestRecord) {
|
||||
@@ -90,7 +265,7 @@ func (c *TestCollector) Add(r TestRecord) {
|
||||
c.Records = append(c.Records, r)
|
||||
}
|
||||
|
||||
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
|
||||
// MarkVisited 标记路径已被测试框架调用
|
||||
func (c *TestCollector) MarkVisited(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -100,47 +275,19 @@ func (c *TestCollector) MarkVisited(path string) {
|
||||
c.Visited[path] = true
|
||||
}
|
||||
|
||||
// NewTestApp 创建测试应用实例
|
||||
// configPath: 配置文件路径(如 "../config/config.json")
|
||||
// projects: 项目定义(路由 + 测试)
|
||||
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
|
||||
// MarkFlowVisited 标记业务流程已运行
|
||||
func (c *TestCollector) MarkFlowVisited(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.VisitedFlows == nil {
|
||||
c.VisitedFlows = map[string]bool{}
|
||||
}
|
||||
c.VisitedFlows[name] = true
|
||||
}
|
||||
|
||||
// NewTestApp 立即 Init 的便捷构造(内部委托 Test().Proj())
|
||||
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{},
|
||||
}
|
||||
return Test(configPath, listeners...).Proj(projects)
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
@@ -199,34 +346,48 @@ func (that *TestApp) TestRequest(method, path string, body *http.Request) TestRe
|
||||
}
|
||||
}
|
||||
|
||||
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
|
||||
// RunTests 运行所有注册的测试;方法级事务隔离;另运行 Flows
|
||||
func (that *TestApp) RunTests(t *testing.T) {
|
||||
for projName, projDef := range that.projs {
|
||||
projName := projName
|
||||
projDef := projDef
|
||||
that.ensureInit()
|
||||
|
||||
projNames := sortedKeys(that.projs)
|
||||
for _, projName := range projNames {
|
||||
projDef := that.projs[projName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
t.Run(projName, func(t *testing.T) {
|
||||
for ctrName, ctrTest := range projDef.Tests {
|
||||
ctrName := ctrName
|
||||
ctrTest := ctrTest
|
||||
ctrNames := sortedKeys(projDef.Tests)
|
||||
for _, ctrName := range ctrNames {
|
||||
ctrTest := projDef.Tests[ctrName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
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)
|
||||
methodNames := sortedKeys(ctrTest)
|
||||
for _, methodName := range methodNames {
|
||||
apiTest := ctrTest[methodName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余接口")
|
||||
return
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
that.collector.MarkVisited(path)
|
||||
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
api := &Api{
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
|
||||
api := &Api{
|
||||
app: that,
|
||||
path: path,
|
||||
t: t,
|
||||
@@ -235,12 +396,73 @@ func (that *TestApp) RunTests(t *testing.T) {
|
||||
api.t = t
|
||||
apiTest.Func(api)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushEndpointSwagger(projName, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(that.flows) == 0 {
|
||||
return
|
||||
}
|
||||
t.Run("flows", func(t *testing.T) {
|
||||
flowNames := sortedKeys(that.flows)
|
||||
for _, flowName := range flowNames {
|
||||
flowDef := that.flows[flowName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余流程")
|
||||
return
|
||||
}
|
||||
t.Run(flowName, func(t *testing.T) {
|
||||
that.collector.MarkFlowVisited(flowName)
|
||||
|
||||
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)
|
||||
|
||||
flow := &Flow{
|
||||
app: that,
|
||||
t: t,
|
||||
name: flowName,
|
||||
group: flowDef.Group,
|
||||
desc: flowDef.Desc,
|
||||
prep: flowDef.Prep,
|
||||
}
|
||||
desc := flowDef.Desc
|
||||
if desc == "" {
|
||||
desc = flowName
|
||||
}
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
flow.t = t
|
||||
flowDef.Func(flow)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushFlowSwagger(flowName)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortedKeys[M ~map[string]V, V any](m M) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// PrintCoverage 输出 API 测试覆盖率报告
|
||||
@@ -250,6 +472,9 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
that.collector.mu.Lock()
|
||||
for _, r := range that.collector.Records {
|
||||
if r.FlowName != "" {
|
||||
continue
|
||||
}
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
visited := that.collector.Visited
|
||||
@@ -296,7 +521,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总全部用例统计
|
||||
for _, d := range report.Details {
|
||||
report.TotalCases += d.CaseCount
|
||||
report.PassedCases += d.Passed
|
||||
@@ -306,7 +530,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
ranCount := len(visited)
|
||||
isPartialRun := ranCount > 0 && ranCount < report.Covered
|
||||
|
||||
// 分拣本次运行中通过/失败的接口
|
||||
var passedDetails, failedDetails []MethodCoverage
|
||||
for _, d := range report.Details {
|
||||
if !d.HasTest || d.CaseCount == 0 {
|
||||
@@ -322,7 +545,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算本次运行的用例统计(部分运行时只统计实际运行的接口)
|
||||
ranInterfaces := len(failedDetails) + len(passedDetails)
|
||||
var ranTotalCases, ranPassedCases, ranFailedCases int
|
||||
if isPartialRun {
|
||||
|
||||
Reference in New Issue
Block a user