测试工具函数

写多了测试代码,你会发现很多重复的模式:创建请求、解析响应、设置请求头等等。把这些操作封装成工具函数,能让测试代码更简洁、更易维护。

基础请求辅助函数

封装 GET 请求

package testutils

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    
    "github.com/gin-gonic/gin"
)

// 设置测试模式
func init() {
    gin.SetMode(gin.TestMode)
}

// PerformGET 发送 GET 请求
func PerformGET(router *gin.Engine, path string) *httptest.ResponseRecorder {
    req := httptest.NewRequest("GET", path, nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

// PerformGETWithHeaders 发送带请求头的 GET 请求
func PerformGETWithHeaders(router *gin.Engine, path string, headers map[string]string) *httptest.ResponseRecorder {
    req := httptest.NewRequest("GET", path, nil)
    for key, value := range headers {
        req.Header.Set(key, value)
    }
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

封装 POST 请求

// PerformPOST 发送 JSON POST 请求
func PerformPOST(router *gin.Engine, path string, body interface{}) *httptest.ResponseRecorder {
    jsonBody, _ := json.Marshal(body)
    req := httptest.NewRequest("POST", path, bytes.NewBuffer(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

// PerformPOSTRaw 发送原始 body 的 POST 请求
func PerformPOSTRaw(router *gin.Engine, path string, body string) *httptest.ResponseRecorder {
    req := httptest.NewRequest("POST", path, bytes.NewBufferString(body))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

封装其他 HTTP 方法

// PerformPUT 发送 PUT 请求
func PerformPUT(router *gin.Engine, path string, body interface{}) *httptest.ResponseRecorder {
    jsonBody, _ := json.Marshal(body)
    req := httptest.NewRequest("PUT", path, bytes.NewBuffer(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

// PerformDELETE 发送 DELETE 请求
func PerformDELETE(router *gin.Engine, path string) *httptest.ResponseRecorder {
    req := httptest.NewRequest("DELETE", path, nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

响应解析辅助函数

解析 JSON 响应

// ParseResponse 解析 JSON 响应到目标结构
func ParseResponse(w *httptest.ResponseRecorder, target interface{}) error {
    return json.Unmarshal(w.Body.Bytes(), target)
}

// ParseResponseMap 解析 JSON 响应为 map
func ParseResponseMap(w *httptest.ResponseRecorder) map[string]interface{} {
    var result map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &result)
    return result
}

// GetResponseBody 获取响应体字符串
func GetResponseBody(w *httptest.ResponseRecorder) string {
    return w.Body.String()
}

断言辅助函数

import "testing"

// AssertStatus 断言状态码
func AssertStatus(t *testing.T, w *httptest.ResponseRecorder, expected int) {
    t.Helper()
    if w.Code != expected {
        t.Errorf("Expected status %d, got %d. Body: %s", expected, w.Code, w.Body.String())
    }
}

// AssertJSON 断言 JSON 响应
func AssertJSON(t *testing.T, w *httptest.ResponseRecorder, expected string) {
    t.Helper()
    if w.Body.String() != expected {
        t.Errorf("Expected body %s, got %s", expected, w.Body.String())
    }
}

// AssertContains 断言响应包含特定字符串
func AssertContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
    t.Helper()
    if !bytes.Contains(w.Body.Bytes(), []byte(substr)) {
        t.Errorf("Response body does not contain %s. Body: %s", substr, w.Body.String())
    }
}

// AssertHeader 断言响应头
func AssertHeader(t *testing.T, w *httptest.ResponseRecorder, key, expected string) {
    t.Helper()
    actual := w.Header().Get(key)
    if actual != expected {
        t.Errorf("Expected header %s=%s, got %s", key, expected, actual)
    }
}

完整测试工具包

把上面的函数组织成一个完整的测试工具包:

package testutils

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "github.com/gin-gonic/gin"
)

func init() {
    gin.SetMode(gin.TestMode)
}

type RequestConfig struct {
    Method  string
    Path    string
    Body    interface{}
    Headers map[string]string
    Cookies []*http.Cookie
}

// Request 发送自定义请求
func Request(router *gin.Engine, config RequestConfig) *httptest.ResponseRecorder {
    var bodyReader *bytes.Buffer
    if config.Body != nil {
        switch v := config.Body.(type) {
        case string:
            bodyReader = bytes.NewBufferString(v)
        case []byte:
            bodyReader = bytes.NewBuffer(v)
        default:
            jsonBody, _ := json.Marshal(v)
            bodyReader = bytes.NewBuffer(jsonBody)
        }
    } else {
        bodyReader = bytes.NewBuffer(nil)
    }
    
    req := httptest.NewRequest(config.Method, config.Path, bodyReader)
    
    // 设置请求头
    if config.Body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    for key, value := range config.Headers {
        req.Header.Set(key, value)
    }
    
    // 设置 Cookie
    for _, cookie := range config.Cookies {
        req.AddCookie(cookie)
    }
    
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    return w
}

// 快捷方法
func GET(router *gin.Engine, path string, headers ...map[string]string) *httptest.ResponseRecorder {
    h := make(map[string]string)
    if len(headers) > 0 {
        h = headers[0]
    }
    return Request(router, RequestConfig{
        Method:  "GET",
        Path:    path,
        Headers: h,
    })
}

func POST(router *gin.Engine, path string, body interface{}, headers ...map[string]string) *httptest.ResponseRecorder {
    h := make(map[string]string)
    if len(headers) > 0 {
        h = headers[0]
    }
    return Request(router, RequestConfig{
        Method:  "POST",
        Path:    path,
        Body:    body,
        Headers: h,
    })
}

func PUT(router *gin.Engine, path string, body interface{}, headers ...map[string]string) *httptest.ResponseRecorder {
    h := make(map[string]string)
    if len(headers) > 0 {
        h = headers[0]
    }
    return Request(router, RequestConfig{
        Method:  "PUT",
        Path:    path,
        Body:    body,
        Headers: h,
    })
}

func DELETE(router *gin.Engine, path string, headers ...map[string]string) *httptest.ResponseRecorder {
    h := make(map[string]string)
    if len(headers) > 0 {
        h = headers[0]
    }
    return Request(router, RequestConfig{
        Method:  "DELETE",
        Path:    path,
        Headers: h,
    })
}

使用示例

有了工具函数后,测试代码变得简洁:

package main

import (
    "net/http"
    "testing"
    
    "github.com/gin-gonic/gin"
    "your-project/testutils"
)

func TestUserAPI(t *testing.T) {
    router := setupRouter()
    
    t.Run("Create user", func(t *testing.T) {
        body := map[string]string{
            "name":  "Alice",
            "email": "alice@example.com",
        }
        w := testutils.POST(router, "/users", body)
        
        testutils.AssertStatus(t, w, http.StatusCreated)
        
        var response map[string]interface{}
        testutils.ParseResponse(w, &response)
        
        if response["name"] != "Alice" {
            t.Errorf("Expected name Alice, got %v", response["name"])
        }
    })
    
    t.Run("Get user", func(t *testing.T) {
        w := testutils.GET(router, "/users/1")
        testutils.AssertStatus(t, w, http.StatusOK)
    })
    
    t.Run("Update user", func(t *testing.T) {
        body := map[string]string{
            "name": "Bob",
        }
        w := testutils.PUT(router, "/users/1", body)
        testutils.AssertStatus(t, w, http.StatusOK)
    })
    
    t.Run("Delete user", func(t *testing.T) {
        w := testutils.DELETE(router, "/users/1")
        testutils.AssertStatus(t, w, http.StatusNoContent)
    })
}

func setupRouter() *gin.Engine {
    router := gin.New()
    
    router.POST("/users", func(c *gin.Context) {
        c.JSON(http.StatusCreated, gin.H{"id": 1, "name": "Alice"})
    })
    
    router.GET("/users/:id", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"id": 1, "name": "Alice"})
    })
    
    router.PUT("/users/:id", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"id": 1, "name": "Bob"})
    })
    
    router.DELETE("/users/:id", func(c *gin.Context) {
        c.Status(http.StatusNoContent)
    })
    
    return router
}

认证辅助函数

如果 API 需要认证,可以封装认证相关的辅助函数:

// WithAuth 添加认证头
func WithAuth(token string) map[string]string {
    return map[string]string{
        "Authorization": "Bearer " + token,
    }
}

// GetAuthToken 获取测试用的认证 token
func GetAuthToken(router *gin.Engine, username, password string) string {
    body := map[string]string{
        "username": username,
        "password": password,
    }
    w := POST(router, "/login", body)
    
    var response map[string]interface{}
    ParseResponse(w, &response)
    
    return response["token"].(string)
}

// AuthenticatedRequest 发送认证请求
func AuthenticatedRequest(router *gin.Engine, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
    return Request(router, RequestConfig{
        Method:  method,
        Path:    path,
        Body:    body,
        Headers: WithAuth(token),
    })
}

使用示例:

func TestProtectedAPI(t *testing.T) {
    router := setupRouter()
    
    // 获取 token
    token := testutils.GetAuthToken(router, "testuser", "testpass")
    
    // 使用认证请求
    w := testutils.AuthenticatedRequest(router, "GET", "/protected", nil, token)
    testutils.AssertStatus(t, w, http.StatusOK)
}

数据库测试辅助函数

如果测试需要数据库,可以封装数据库相关的辅助函数:

package testutils

import (
    "database/sql"
    "os"
    "testing"
    
    _ "github.com/lib/pq"
)

var testDB *sql.DB

// SetupDB 初始化测试数据库
func SetupDB(t *testing.T) *sql.DB {
    t.Helper()
    
    dsn := os.Getenv("TEST_DATABASE_URL")
    if dsn == "" {
        dsn = "postgres://test:test@localhost:5432/test_db?sslmode=disable"
    }
    
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatalf("Failed to connect database: %v", err)
    }
    
    // 清理表
    db.Exec("TRUNCATE users CASCADE")
    
    return db
}

// TeardownDB 清理测试数据库
func TeardownDB(t *testing.T, db *sql.DB) {
    t.Helper()
    db.Exec("TRUNCATE users CASCADE")
    db.Close()
}

// InsertTestUser 插入测试用户
func InsertTestUser(t *testing.T, db *sql.DB, name, email string) int {
    t.Helper()
    
    var id int
    err := db.QueryRow(
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
        name, email,
    ).Scan(&id)
    
    if err != nil {
        t.Fatalf("Failed to insert test user: %v", err)
    }
    
    return id
}

小结

测试工具函数能显著提高测试效率:

  • 封装常用的请求方法(GET、POST、PUT、DELETE)
  • 封装响应解析和断言
  • 封装认证和数据准备逻辑
  • 使用 t.Helper() 标记辅助函数,使错误报告更准确

好的测试工具函数让测试代码更易读、更易维护,也更容易让团队成员写出一致的测试代码。建议在项目中建立自己的测试工具库,随着项目发展不断完善。