功能说明
依赖外部接口的测试通常不稳定:网络抖动、限流、数据变化都会让用例失败。httptest 可以在当前进程内启动一个真实监听端口的服务器,客户端代码无需任何修改就能连上去,返回内容完全由测试决定。这种方式比接口 mock 更接近真实调用链,能覆盖序列化、状态码和连接处理。
桩服务以路径为键保存响应,未命中路径返回 404。请求计数使用原子操作,多个协程并发调用也不会丢计数,可在测试结束时断言调用次数。
关键代码
NewStubServer 返回 *httptest.Server,内部用 http.HandlerFunc 处理每个请求:先原子累加计数,再查表;命中时设置 JSON 内容类型、写入预设状态码与响应体,未命中交给 http.NotFound。main 依次请求用户、健康检查和不存在路径,打印状态码与响应体,最后读取总请求数。服务器通过 defer 关闭,释放监听端口。
使用注意
桩服务器会真实占用本地端口,测试并行时要避免共享同一实例。响应体中的 JSON 建议用结构体序列化生成,手写字符串容易出现转义错误。需要模拟超时或连接中断时,应使用自定义 RoundTripper 或直接关闭连接,而不是在处理器里长时间阻塞。测试结束务必关闭服务器,否则可能造成协程泄漏。真实项目中可把 *testing.T 传入并在 Cleanup 中关闭。
验证方式
三条请求分别返回 200 加用户 JSON、200 加健康检查 JSON、404 加默认未找到文本。总请求数应为三,说明计数覆盖了成功与失败路径。可以再加入并发请求,确认计数仍然准确,并观察每次响应都带有 application/json 内容类型。
代码示例
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
)
type StubResponse struct {
Status int
Body string
}
func NewStubServer(responses map[string]StubResponse, hits *int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt64(hits, 1)
}
response, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.Status)
_, _ = io.WriteString(w, response.Body)
}))
}
func main() {
var hits int64
server := NewStubServer(map[string]StubResponse{
"/users/1": {Status: http.StatusOK, Body: `{"id":1,"name":"ada"}`},
"/health": {Status: http.StatusOK, Body: `{"status":"ok"}`},
}, &hits)
defer server.Close()
for _, requestPath := range []string{"/users/1", "/health", "/missing"} {
response, err := http.Get(server.URL + requestPath)
if err != nil {
panic(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
panic(err)
}
fmt.Printf("%s -> %d %s\n", requestPath, response.StatusCode, string(body))
}
fmt.Println("total requests:", atomic.LoadInt64(&hits))
}