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

610 lines
15 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"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
)
// === 注册类型 ===
// TestProj 测试项目集合(项目名 → 定义)
type TestProj map[string]TestProjDef
// TestProjDef 单个项目的路由 + 测试定义
type TestProjDef struct {
Proj Proj
Tests ProjTest
}
// ProjTest 项目级测试(控制器名 → 控制器测试)
type ProjTest map[string]CtrTest
// CtrTest 控制器级测试(方法名 → 用例定义)
type CtrTest map[string]ApiTestDef
// ApiTestDef 单个接口的测试定义
type ApiTestDef struct {
Desc string
Func func(a *Api)
}
// === Api:测试起点 ===
// Api 测试 API 入口,由 RunTests 自动创建并注入
type Api struct {
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse
}
// WithSession 返回一个携带 session 的新 Api 实例
func (a *Api) WithSession(s Map) *Api {
return &Api{
app: a.app,
path: a.path,
t: a.t,
session: s,
}
}
// JSON 设置 JSON body,返回 ApiCase 构建器
func (a *Api) JSON(body interface{}) *ApiCase {
c := a.newCase()
c.jsonBody = body
return c
}
// Query 设置 URL 查询参数,返回 ApiCase 构建器
func (a *Api) Query(params Map) *ApiCase {
c := a.newCase()
c.query = params
return c
}
// Form 设置表单 body,返回 ApiCase 构建器
func (a *Api) Form(body Map) *ApiCase {
c := a.newCase()
c.formBody = body
return c
}
// File 设置上传文件,返回 ApiCase 构建器
func (a *Api) File(field, name string, content []byte) *ApiCase {
c := a.newCase()
c.fileField = field
c.fileName = name
c.fileContent = content
return c
}
// Get 直接发送 GET 请求
// expect 可选参数: string 为期望 msgMap 为期望 result 结构(按样本值类型校验)
func (a *Api) Get(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Get(desc, status, expect...)
}
// Post 直接发送 POST 请求
func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Post(desc, status, expect...)
}
// Put 直接发送 PUT 请求
func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Put(desc, status, expect...)
}
// Delete 直接发送 DELETE 请求
func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Delete(desc, status, expect...)
}
// Verify 设置请求后的校验函数,返回 ApiCase 构建器
// 在回调中可用 a.Resp() 做响应值断言 + a.DB() 做数据库校验
func (a *Api) Verify(fn func(a *Api) error) *ApiCase {
c := a.newCase()
c.verifyFn = fn
return c
}
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
func (a *Api) Note(note string) *ApiCase {
c := a.newCase()
c.note = note
return c
}
// DB 获取数据库实例(在测试事务内)
func (a *Api) DB() *HoTimeDB {
return &a.app.Db
}
// Resp 获取最近一次请求的响应,在 Verify 回调中用于响应值断言
func (a *Api) Resp() *ApiResponse {
return a.lastResp
}
func (a *Api) newCase() *ApiCase {
return &ApiCase{
api: a,
session: a.session,
}
}
// === ApiCase:链式构建器 ===
// ApiCase 测试用例构建器,支持链式调用叠加请求数据
type ApiCase struct {
api *Api
session Map
query Map
jsonBody interface{}
formBody Map
fileField string
fileName string
fileContent []byte
note string
verifyFn func(a *Api) error
}
// JSON 叠加 JSON body
func (c *ApiCase) JSON(body interface{}) *ApiCase {
c.jsonBody = body
return c
}
// Query 叠加 URL 查询参数
func (c *ApiCase) Query(params Map) *ApiCase {
if c.query == nil {
c.query = params
} else {
for k, v := range params {
c.query[k] = v
}
}
return c
}
// Form 叠加表单 body
func (c *ApiCase) Form(body Map) *ApiCase {
if c.formBody == nil {
c.formBody = body
} else {
for k, v := range body {
c.formBody[k] = v
}
}
return c
}
// File 设置上传文件
func (c *ApiCase) File(field, name string, content []byte) *ApiCase {
c.fileField = field
c.fileName = name
c.fileContent = content
return c
}
// WithSession 覆盖本次请求的 session
func (c *ApiCase) WithSession(s Map) *ApiCase {
c.session = s
return c
}
// Note 为本条用例添加可选备注,备注将显示在 swagger 调试控制台
func (c *ApiCase) Note(note string) *ApiCase {
c.note = note
return c
}
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
c.verifyFn = fn
return c
}
// Get 终端操作:GET 请求 + 断言
func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("GET", desc, status, expect...)
}
// Post 终端操作:POST 请求 + 断言
func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("POST", desc, status, expect...)
}
// Put 终端操作:PUT 请求 + 断言
func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("PUT", desc, status, expect...)
}
// Delete 终端操作:DELETE 请求 + 断言
func (c *ApiCase) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("DELETE", desc, status, expect...)
}
func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...interface{}) *ApiResponse {
// status!=0 时: 第一个 string → msg 校验,其余 → result 结构校验
// status==0 时: 所有参数 → result 类型/结构校验(因为成功响应没有 msg)
var expectMsg string
var expectResult interface{}
var hasExpectResult bool
for _, arg := range expectArgs {
if s, ok := arg.(string); ok && expectStatus != 0 && expectMsg == "" {
expectMsg = s
continue
}
if !hasExpectResult {
expectResult = arg
hasExpectResult = true
}
}
if expectStatus != 0 && expectMsg == "" {
expectMsg = desc
}
t := c.api.t
var resp *ApiResponse
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)
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 {
body = Map(m)
}
}
}
resp = &ApiResponse{
StatusCode: w.Code,
Body: body,
RawBody: w.Body.Bytes(),
t: t,
}
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
}
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
}
}
if passed && hasExpectResult {
if !validateShape(body["result"], expectResult, "result", t) {
failReason = "响应结构校验失败"
passed = false
}
}
c.api.lastResp = resp
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()
}
}
if !passed {
if sqlLog := c.api.app.Db.FlushTestLog(); sqlLog != "" {
t.Logf("SQL 日志:\n%s", sqlLog)
}
} else {
c.api.app.Db.FlushTestLog()
}
})
if resp == nil {
resp = &ApiResponse{t: t}
}
return resp
}
func (c *ApiCase) buildRequest(method string) *http.Request {
path := c.api.path
if c.query != nil {
q := url.Values{}
for k, v := range c.query {
q.Set(k, ObjToStr(v))
}
path += "?" + q.Encode()
}
var body io.Reader
var contentType string
switch {
case c.fileContent != nil:
buf := &bytes.Buffer{}
writer := multipart.NewWriter(buf)
part, _ := writer.CreateFormFile(c.fileField, c.fileName)
_, _ = part.Write(c.fileContent)
if c.formBody != nil {
for k, v := range c.formBody {
_ = writer.WriteField(k, ObjToStr(v))
}
}
_ = writer.Close()
body = buf
contentType = writer.FormDataContentType()
case c.jsonBody != nil:
jsonBytes, _ := json.Marshal(c.jsonBody)
body = bytes.NewReader(jsonBytes)
contentType = "application/json"
case c.formBody != nil:
form := url.Values{}
for k, v := range c.formBody {
form.Set(k, ObjToStr(v))
}
body = strings.NewReader(form.Encode())
contentType = "application/x-www-form-urlencoded"
}
req := httptest.NewRequest(method, path, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if c.session != nil {
sessionId := Md5("test_session_" + ObjToStr(time.Now().UnixNano()))
c.api.app.HoTimeCache.Session(HEAD_SESSION_ADD+sessionId, c.session)
req.Header.Set("Authorization", sessionId)
}
return req
}
// === ApiResponse ===
// ApiResponse 测试响应对象
type ApiResponse struct {
StatusCode int
Body Map
RawBody []byte
t *testing.T
}
// GetStatus 获取业务状态码(JSON body 中的 status 字段)
func (r *ApiResponse) GetStatus() int {
return r.Body.GetInt("status")
}
// GetResult 获取响应结果(JSON body 中的 result 字段)
func (r *ApiResponse) GetResult() interface{} {
return r.Body.Get("result")
}
// GetMsg 获取响应消息
// Display 错误格式为 {"status":N, "result":{"msg":"xxx"}},成功时无 msg
func (r *ApiResponse) GetMsg() string {
if msg := r.Body.GetString("msg"); msg != "" {
return msg
}
if result := r.Body.GetMap("result"); result != nil {
return result.GetString("msg")
}
return ""
}
// GetBody 获取完整的响应 bodyJSON 解析后的 Map,可链式 Get*)
func (r *ApiResponse) GetBody() Map {
return r.Body
}
// GetRawBody 获取原始响应字节(用于非 JSON 响应如文件下载、二进制流校验)
func (r *ApiResponse) GetRawBody() []byte {
return r.RawBody
}
// Fail 手动标记测试失败(用于自定义断言)
func (r *ApiResponse) Fail(msg string) {
if r.t != nil {
r.t.Errorf("自定义断言失败: %s\n响应: %s", msg, string(r.RawBody))
} else {
fmt.Printf("自定义断言失败: %s\n", msg)
}
}
// ExpectResult 后置校验 result 结构,expected 作为类型样本(只比类型,不比值)
// expected 可以是 Map(校验字段)、Slice(校验数组)、或 string/int64/float64 等原始类型
//
// res.ExpectResult(Map{"id": int64(1), "name": "样本"})
// res.ExpectResult("操作成功") // 校验 result 是 string 类型
// res.ExpectResult(Slice{Map{"id": int64(1)}}) // 校验 result 是数组
func (r *ApiResponse) ExpectResult(expected interface{}) *ApiResponse {
if !validateShape(r.Body["result"], expected, "result", r.t) && r.t == nil {
fmt.Printf("ExpectResult 校验失败\n")
}
return r
}
// === 结构校验 ===
// validateShape 递归校验 actual 是否符合 expected 的结构(字段名+类型大类)
func validateShape(actual, expected interface{}, path string, t *testing.T) bool {
if expected == nil {
return true
}
if actual == nil {
if t != nil {
t.Errorf("字段 %s 缺失", path)
}
return false
}
switch exp := expected.(type) {
case Map:
return validateMapShape(actual, exp, path, t)
case map[string]interface{}:
return validateMapShape(actual, Map(exp), path, t)
case Slice:
return validateSliceShape(actual, exp, path, t)
case []interface{}:
return validateSliceShape(actual, Slice(exp), path, t)
default:
if !sameTypeCategory(actual, expected) {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 %s, 实际 %s(%v)",
path, typeCategory(expected), typeCategory(actual), actual)
}
return false
}
return true
}
}
func validateMapShape(actual interface{}, exp Map, path string, t *testing.T) bool {
actualMap := ObjToMap(actual)
if actualMap == nil {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 map, 实际 %s", path, typeCategory(actual))
}
return false
}
ok := true
for field, expectedVal := range exp {
fieldPath := path + "." + field
actualVal, exists := actualMap[field]
if !exists {
if t != nil {
t.Errorf("字段 %s 缺失", fieldPath)
}
ok = false
continue
}
if !validateShape(actualVal, expectedVal, fieldPath, t) {
ok = false
}
}
return ok
}
func validateSliceShape(actual interface{}, exp Slice, path string, t *testing.T) bool {
actualSlice := ObjToSlice(actual)
if actualSlice == nil {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 slice, 实际 %s", path, typeCategory(actual))
}
return false
}
if len(exp) > 0 && len(actualSlice) > 0 {
return validateShape(actualSlice[0], exp[0], path+"[0]", t)
}
return true
}
func sameTypeCategory(a, b interface{}) bool {
return typeCategory(a) == typeCategory(b)
}
func typeCategory(v interface{}) string {
switch v.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return "number"
case float32, float64:
return "float"
case string:
return "string"
case bool:
return "bool"
case Map, map[string]interface{}:
return "map"
case Slice, []interface{}:
return "slice"
default:
return fmt.Sprintf("%T", v)
}
}