在Golang项目里,对HTTP接口做测试时,我们并不需要真的起一个对外暴露的服务,也不该依赖第三方接口的稳定性。标准库中的net/http/httptest包提供了轻量的内存级测试能力,让我们能够发起请求、接管响应,并精确验证每一次交互的细节。下面先介绍两种核心用法,再给出完整的验证示例。

使用httptest.Server模拟后端服务
httptest.Server会在本地启动一个真实的HTTP服务器,但只运行在内存中,不会占用实际端口资源。这种方式最适合测试那些内部使用http.Client去调用其他服务的代码,比如业务层封装的API客户端。我们可以自定义Handler,返回固定的状态码和JSON,从而覆盖正常、超时、错误等多种场景。
下面的例子展示了如何创建一个测试服务器,并使用http.Get向其发起请求,随后验证返回的状态码与响应体内容。注意服务器在测试结束时必须调用Close方法释放资源,否则会造成 goroutine 泄漏。
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestCallExternalAPI(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := map[string]string{"message": "hello"}
data, _ := json.Marshal(resp)
w.Write(data)
}))
defer srv.Close()
resp, err := http.Get(srv.URL + "/api")
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("期望状态码200,实际得到%d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != `{"message":"hello"}` {
t.Errorf("响应体不符合预期: %s", body)
}
}
使用httptest.ResponseRecorder验证Handler
当我们直接测试自己编写的http.Handler或gin、echo等框架的路由函数时,更推荐使用httptest.NewRecorder。它实现了http.ResponseWriter接口,能够记录下代码中调用WriteHeader、Header().Set以及Write写入的所有数据,而完全不需要走网络栈。这样测试速度极快,也更容易断言中间件是否正确设置了头部。
在下面的示例中,我们构造一个http.Request,将其传给被测Handler,然后从Recorder中取出StatusCode、Header以及Body进行验证。这种方式同样适用于需要读取请求体的POST接口,只要用strings.NewReader构造请求即可。
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func demoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("X-App-Name", "test")
w.WriteHeader(http.StatusCreated)
w.Write([]byte("created"))
}
func TestDemoHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/demo", strings.NewReader("payload"))
rec := httptest.NewRecorder()
demoHandler(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("期望状态码201,实际得到%d", rec.Code)
}
if rec.Header().Get("X-App-Name") != "test" {
t.Errorf("缺少自定义头部")
}
if rec.Body.String() != "created" {
t.Errorf("响应内容错误: %s", rec.Body.String())
}
}
结构化响应的验证技巧
实际业务中响应通常是JSON,直接比对字符串容易因为字段顺序或空格失败。更稳妥的做法是把响应体反序列化到结构体,再使用reflect.DeepEqual或专门的断言库进行比对。此外,对于时间、随机ID等不稳定字段,可以在结构体中忽略或使用独立断言。
下面的代码演示了如何将Recorder捕获的Body解析为结构体,并验证其中的字段值。这样即使JSON格式微调,只要字段含义不变,测试依然可以通过,提升了用例的健壮性。
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func userHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(User{ID: 1, Name: "张三"})
}
func TestUserHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/user", nil)
rec := httptest.NewRecorder()
userHandler(rec, req)
var u User
if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil {
t.Fatalf("解析JSON失败: %v", err)
}
if u.ID != 1 || u.Name != "张三" {
t.Errorf("用户数据不正确: %+v", u)
}
}
常见误区与建议
不少初学者会在单元测试里直接用http.ListenAndServe起一个真实服务,再让测试进程去请求它,这种做法不仅让测试变慢,还容易因端口冲突导致CI失败。应当优先使用httptest提供的两种内存方案,把网络边界控制在测试框架内部。
另一个容易忽略的点是,使用httptest.Server时,如果Handler中启动了 goroutine 去写响应,必须在测试里通过通道或sync.WaitGroup等待完成,否则可能出现Recorder还没写到数据测试就已经结束的情况。保持被测代码同步写完响应,或在测试中显式同步,可以避免偶发失败。