单元测试关注单个函数的正确性,接口测试则关注整个请求-响应流程。对于 Web 服务来说,接口测试能发现路由、中间件、参数绑定等环节的问题,是保证 API 质量的重要手段。
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())
}
}
这个测试做了几件事:
httptest.NewRequest 创建模拟请求httptest.NewRecorder 记录响应router.ServeHTTP 处理请求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)
}
})
}
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 记录响应接口测试比单元测试更接近真实场景,能发现路由配置、中间件顺序等问题。建议为每个 API 端点编写测试,确保功能正确且后续修改不会破坏现有功能。