接口测试

单元测试关注单个函数的正确性,接口测试则关注整个请求-响应流程。对于 Web 服务来说,接口测试能发现路由、中间件、参数绑定等环节的问题,是保证 API 质量的重要手段。

httptest 包介绍

Go 标准库的 net/http/httptest 包提供了测试 HTTP 服务器的工具。它可以在不启动真实服务器的情况下,模拟 HTTP 请求并获取响应。

主要类型:

  • httptest.NewRequest:创建测试请求
  • httptest.NewRecorder:记录响应内容

基本接口测试

先看一个简单的例子:

package main

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

func TestHelloHandler(t *testing.T) {
    // 设置测试模式
    gin.SetMode(gin.TestMode)
    
    // 创建路由
    router := gin.New()
    router.GET("/hello", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "Hello World",
        })
    })
    
    // 创建请求
    req := httptest.NewRequest("GET", "/hello", nil)
    
    // 创建响应记录器
    w := httptest.NewRecorder()
    
    // 处理请求
    router.ServeHTTP(w, req)
    
    // 验证响应
    if w.Code != http.StatusOK {
        t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code)
    }
    
    expected := `{"message":"Hello World"}`
    if w.Body.String() != expected {
        t.Errorf("Expected body %s, got %s", expected, w.Body.String())
    }
}

这个测试做了几件事:

  1. 设置 Gin 为测试模式,减少日志输出
  2. 创建路由和处理器
  3. httptest.NewRequest 创建模拟请求
  4. httptest.NewRecorder 记录响应
  5. 调用 router.ServeHTTP 处理请求
  6. 验证状态码和响应体

测试 POST 请求

POST 请求通常带有请求体,需要设置 Content-Type:

func TestCreateUser(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.POST("/users", func(c *gin.Context) {
        var user struct {
            Name  string `json:"name" binding:"required"`
            Email string `json:"email" binding:"required,email"`
        }
        
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        
        c.JSON(http.StatusCreated, gin.H{
            "id":    1,
            "name":  user.Name,
            "email": user.Email,
        })
    })
    
    // 创建请求体
    body := strings.NewReader(`{"name":"Alice","email":"alice@example.com"}`)
    req := httptest.NewRequest("POST", "/users", body)
    req.Header.Set("Content-Type", "application/json")
    
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    
    // 验证
    if w.Code != http.StatusCreated {
        t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code)
    }
    
    // 解析响应体
    var response map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &response)
    
    if response["name"] != "Alice" {
        t.Errorf("Expected name Alice, got %v", response["name"])
    }
}

测试查询参数

func TestQueryParams(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.GET("/search", func(c *gin.Context) {
        query := c.Query("q")
        page := c.DefaultQuery("page", "1")
        
        c.JSON(http.StatusOK, gin.H{
            "query": query,
            "page":  page,
        })
    })
    
    req := httptest.NewRequest("GET", "/search?q=golang&page=2", nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    
    var response map[string]string
    json.Unmarshal(w.Body.Bytes(), &response)
    
    if response["query"] != "golang" {
        t.Errorf("Expected query 'golang', got %s", response["query"])
    }
    if response["page"] != "2" {
        t.Errorf("Expected page '2', got %s", response["page"])
    }
}

测试路径参数

func TestPathParam(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.GET("/users/:id", func(c *gin.Context) {
        id := c.Param("id")
        c.JSON(http.StatusOK, gin.H{"user_id": id})
    })
    
    req := httptest.NewRequest("GET", "/users/123", nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    
    var response map[string]string
    json.Unmarshal(w.Body.Bytes(), &response)
    
    if response["user_id"] != "123" {
        t.Errorf("Expected user_id '123', got %s", response["user_id"])
    }
}

测试中间件

中间件也是需要测试的重要部分:

func TestAuthMiddleware(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    // 认证中间件
    authMiddleware := func(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token != "valid-token" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
            c.Abort()
            return
        }
        c.Next()
    }
    
    router := gin.New()
    router.Use(authMiddleware)
    router.GET("/protected", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "success"})
    })
    
    // 测试无 token 的情况
    t.Run("NoToken", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/protected", nil)
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        if w.Code != http.StatusUnauthorized {
            t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
        }
    })
    
    // 测试有效 token
    t.Run("ValidToken", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/protected", nil)
        req.Header.Set("Authorization", "valid-token")
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        if w.Code != http.StatusOK {
            t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
        }
    })
}

测试 Cookie

func TestCookie(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    
    // 设置 Cookie
    router.GET("/set-cookie", func(c *gin.Context) {
        c.SetCookie("session", "abc123", 3600, "/", "localhost", false, true)
        c.JSON(http.StatusOK, gin.H{"message": "cookie set"})
    })
    
    // 读取 Cookie
    router.GET("/get-cookie", func(c *gin.Context) {
        session, err := c.Cookie("session")
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": "no cookie"})
            return
        }
        c.JSON(http.StatusOK, gin.H{"session": session})
    })
    
    t.Run("SetCookie", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/set-cookie", nil)
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        // 获取设置的 Cookie
        cookies := w.Result().Cookies()
        if len(cookies) == 0 {
            t.Error("Expected cookie to be set")
        }
        if cookies[0].Name != "session" || cookies[0].Value != "abc123" {
            t.Errorf("Unexpected cookie: %s=%s", cookies[0].Name, cookies[0].Value)
        }
    })
    
    t.Run("GetCookie", func(t *testing.T) {
        req := httptest.NewRequest("GET", "/get-cookie", nil)
        req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"})
        w := httptest.NewRecorder()
        router.ServeHTTP(w, req)
        
        var response map[string]string
        json.Unmarshal(w.Body.Bytes(), &response)
        
        if response["session"] != "abc123" {
            t.Errorf("Expected session 'abc123', got %s", response["session"])
        }
    })
}

表格驱动测试

对于多种输入情况,表格驱动测试更清晰:

func TestValidation(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.POST("/users", func(c *gin.Context) {
        var user struct {
            Name  string `json:"name" binding:"required"`
            Email string `json:"email" binding:"required,email"`
            Age   int    `json:"age" binding:"gte=0,lte=150"`
        }
        
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        
        c.JSON(http.StatusOK, gin.H{"success": true})
    })
    
    tests := []struct {
        name       string
        body       string
        wantStatus int
    }{
        {
            name:       "Valid user",
            body:       `{"name":"Alice","email":"alice@example.com","age":25}`,
            wantStatus: http.StatusOK,
        },
        {
            name:       "Missing name",
            body:       `{"email":"alice@example.com","age":25}`,
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "Invalid email",
            body:       `{"name":"Alice","email":"invalid","age":25}`,
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "Age out of range",
            body:       `{"name":"Alice","email":"alice@example.com","age":200}`,
            wantStatus: http.StatusBadRequest,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("POST", "/users", strings.NewReader(tt.body))
            req.Header.Set("Content-Type", "application/json")
            w := httptest.NewRecorder()
            router.ServeHTTP(w, req)
            
            if w.Code != tt.wantStatus {
                t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
            }
        })
    }
}

测试响应头

func TestResponseHeaders(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.GET("/api", func(c *gin.Context) {
        c.Header("X-Custom-Header", "custom-value")
        c.Header("Content-Type", "application/json")
        c.JSON(http.StatusOK, gin.H{"message": "ok"})
    })
    
    req := httptest.NewRequest("GET", "/api", nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    
    if w.Header().Get("X-Custom-Header") != "custom-value" {
        t.Errorf("Expected custom header, got %s", w.Header().Get("X-Custom-Header"))
    }
    
    if w.Header().Get("Content-Type") != "application/json" {
        t.Errorf("Expected application/json, got %s", w.Header().Get("Content-Type"))
    }
}

测试文件上传

func TestFileUpload(t *testing.T) {
    gin.SetMode(gin.TestMode)
    
    router := gin.New()
    router.POST("/upload", func(c *gin.Context) {
        file, err := c.FormFile("file")
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }
        
        c.JSON(http.StatusOK, gin.H{
            "filename": file.Filename,
            "size":     file.Size,
        })
    })
    
    // 创建模拟文件
    body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)
    
    part, _ := writer.CreateFormFile("file", "test.txt")
    part.Write([]byte("test content"))
    writer.Close()
    
    req := httptest.NewRequest("POST", "/upload", body)
    req.Header.Set("Content-Type", writer.FormDataContentType())
    
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    
    var response map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &response)
    
    if response["filename"] != "test.txt" {
        t.Errorf("Expected filename 'test.txt', got %v", response["filename"])
    }
}

小结

接口测试是保证 API 质量的重要手段:

  • 使用 httptest.NewRequest 创建模拟请求
  • 使用 httptest.NewRecorder 记录响应
  • 测试各种 HTTP 方法(GET、POST、PUT、DELETE)
  • 测试参数绑定、中间件、Cookie 等
  • 使用表格驱动测试覆盖多种情况

接口测试比单元测试更接近真实场景,能发现路由配置、中间件顺序等问题。建议为每个 API 端点编写测试,确保功能正确且后续修改不会破坏现有功能。