4. HTTP 接口测试:net/http/httptest 包、创建测试 Server、发送请求、断言响应

在 Go 项目中进行 HTTP 接口测试,核心目标是验证 handler 在真实 HTTP 协议下的行为:路由分发、请求解析、状态码、响应头、响应体以及中间件逻辑。标准库 net/http/httptest 提供了无需外部监听端口的轻量级测试 Server,让集成测试既快速又可靠。

4.1 核心组件:httptest.Server 与 httptest.ResponseRecorder

组件 用途 典型场景
httptest.Server 启动一个真实的 HTTP 测试服务器,分配随机端口 需要完整 HTTP 生命周期(连接、TLS、超时)的集成测试
httptest.ResponseRecorder 记录 handler 写入的响应状态码、Header 和 Body 单元测试 handler 逻辑,无需启动网络监听
httptest.NewRequest 构造一个 *http.Request,可设置路径、Header、Body 配合 ResponseRecorder 模拟客户端请求
选择建议: 压测闭环方案中,优先使用 httptest.Server 进行集成测试,因为它能覆盖网络栈、中间件链和路由器的完整行为;ResponseRecorder 更适合 handler 级别的快速验证。

4.2 创建测试 Server:两种模式

模式一:基于现有 handler 创建

func TestUserAPI_WithServer(t *testing.T) {
    // 假设已有路由 mux
    mux := http.NewServeMux()
    mux.HandleFunc("/api/v1/users", handleGetUsers)

    // 创建测试 Server
    ts := httptest.NewServer(mux)
    defer ts.Close()

    // 使用 ts.URL 发送请求
    resp, err := http.Get(ts.URL + "/api/v1/users")
    // ... 断言
}

模式二:自定义 handler 闭包

func TestHealthCheck(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    })

    ts := httptest.NewServer(handler)
    defer ts.Close()

    // 发送请求
    resp, _ := http.Get(ts.URL + "/health")
    // ... 断言
}
注意: 每个测试用例都应调用 defer ts.Close(),避免端口泄漏。测试 Server 会在随机端口上监听,不会与开发环境冲突。

4.3 发送请求:构造与执行

使用标准 net/http 客户端发送请求,支持 GET、POST、PUT、DELETE 等所有方法。推荐显式构造 *http.Request 以精确控制 Header 和 Body。

func TestCreateUser(t *testing.T) {
    ts := httptest.NewServer(userRouter())
    defer ts.Close()

    // 构造请求体
    body := `{"name":"Alice","email":"alice@example.com"}`
    req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/users",
        strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer test-token-123")

    // 发送请求
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        t.Fatalf("请求失败: %v", err)
    }
    defer resp.Body.Close()

    // 读取响应体
    respBody, _ := io.ReadAll(resp.Body)
    // ... 断言
}

4.4 断言响应:状态码、Header、Body

断言是测试的最终验证环节,必须覆盖三个维度:

  • 状态码: 使用 if got != want 或 testify assert.Equal
  • 响应头: 检查 Content-Type、Location、Set-Cookie 等关键 Header
  • 响应体: 对 JSON 响应进行反序列化后断言字段值
func TestGetUser_Assertions(t *testing.T) {
    ts := httptest.NewServer(userRouter())
    defer ts.Close()

    resp, _ := http.Get(ts.URL + "/api/v1/users/42")
    defer resp.Body.Close()

    // 1. 断言状态码
    if resp.StatusCode != http.StatusOK {
        t.Errorf("期望状态码 200,得到 %d", resp.StatusCode)
    }

    // 2. 断言响应头
    if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
        t.Errorf("期望 Content-Type application/json,得到 %s", ct)
    }

    // 3. 断言响应体
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        t.Fatalf("JSON 解析失败: %v", err)
    }
    if user.ID != 42 {
        t.Errorf("期望 ID 42,得到 %d", user.ID)
    }
    if user.Name == "" {
        t.Error("用户名称不应为空")
    }
}

4.5 集成测试流程:从 Server 到断言

HTTP 集成测试流程 1. 创建 Server 2. 构造请求 3. 发送请求 4. 断言状态码 5. 断言 Header 6. 断言 Body 每个测试用例独立创建 Server,确保隔离性;defer ts.Close() 保证资源释放

4.6 常见断言模式与避坑

场景 推荐做法 常见错误
JSON 响应断言 使用 json.Decoder 反序列化到结构体,逐字段比较 直接比较字符串,忽略字段顺序和空格差异
错误状态码 断言 resp.StatusCode 与 http.StatusXxx 常量相等 硬编码数字 400/500,可读性差
响应头验证 使用 resp.Header.Get() 获取单个值 忽略 Header 大小写不敏感特性
空 Body 处理 先检查 resp.Body 不为 nil,再读取 直接读取 nil Body 导致 panic
超时控制 创建带超时的 http.Client,如 &http.Client{Timeout: 5 * time.Second} 使用默认客户端,测试可能因死锁而挂起

4.7 完整示例:用户注册接口集成测试

func TestRegisterUser_Integration(t *testing.T) {
    // 1. 创建测试 Server(使用真实路由)
    router := setupRouter() // 包含中间件、数据库连接等
    ts := httptest.NewServer(router)
    defer ts.Close()

    // 2. 构造注册请求
    registerBody := `{"username":"testuser","password":"Str0ng!Pass"}`
    req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/auth/register",
        strings.NewReader(registerBody))
    req.Header.Set("Content-Type", "application/json")

    // 3. 发送请求
    client := &http.Client{Timeout: 3 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        t.Fatalf("注册请求失败: %v", err)
    }
    defer resp.Body.Close()

    // 4. 断言状态码
    if resp.StatusCode != http.StatusCreated {
        body, _ := io.ReadAll(resp.Body)
        t.Fatalf("期望 201 Created,得到 %d,响应体: %s", resp.StatusCode, string(body))
    }

    // 5. 断言响应头
    if loc := resp.Header.Get("Location"); loc == "" {
        t.Error("期望 Location 头指向新用户资源")
    }

    // 6. 断言响应体
    var created UserResponse
    if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
        t.Fatalf("JSON 解析失败: %v", err)
    }
    if created.Username != "testuser" {
        t.Errorf("期望用户名 testuser,得到 %s", created.Username)
    }
    if created.ID == 0 {
        t.Error("用户 ID 不应为 0")
    }
}
压测闭环要点: 集成测试中的每个断言都对应一个压测指标。例如,状态码断言对应压测中的错误率监控;响应体字段断言对应数据完整性校验。将集成测试用例作为压测场景的“黄金样本”,确保压测流量覆盖所有关键接口路径。