Appearance
HTTP 测试与 httptest
Web 应用是 Go 最常见的应用场景之一。测试 HTTP 代码时,最理想的做法是不真正监听端口、不依赖外部服务,而是在进程内完成请求构造、Handler 调用、响应断言。标准库 net/http/httptest 包就是为这个目标设计的。本篇将系统讲解 httptest 的核心 API,并演示如何测试 HTTP Handler、中间件、HTTP 客户端代码,最后用一个完整的 Gin 应用案例把所有技能串起来。
一、httptest 包简介
net/http/httptest 是 Go 标准库的一部分,提供以下能力:
| API | 用途 |
|---|---|
httptest.NewRecorder() | 创建 ResponseRecorder,捕获 Handler 写入的响应 |
httptest.NewRequest(method, target, body) | 构造一个 *http.Request(Go 1.7+) |
httptest.NewServer(handler) | 启动一个真实的 HTTP 服务器(监听真实端口) |
httptest.NewTLSServer(handler) | 启动一个 TLS 服务器 |
httptest.NewServer(...) + 自定义 Transport | 测试客户端代码(HTTP 调用方) |
它的核心思想是:让测试不需要真正启服务就能验证 Handler 行为,必要时也能启一个本地服务器,让客户端代码访问。
二、httptest.NewRecorder:测试 HTTP Handler
ResponseRecorder 实现了 http.ResponseWriter 接口,但它不把响应发到网络上,而是把状态码、Header、Body 都记下来。这样测试时就可以读取并断言。
来看一个最简单的例子:
go
package httphandlers
import (
"encoding/json"
"net/http"
)
// HelloHandler 返回一段 JSON。
func HelloHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "hello world",
})
}
// EchoHandler 把查询参数 name 回显。
func EchoHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "stranger"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("Hello, " + name + "!"))
}测试:
go
package httphandlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHelloHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
rec := httptest.NewRecorder()
HelloHandler(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var body map[string]string
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
assert.Equal(t, "hello world", body["message"])
}
func TestEchoHandler(t *testing.T) {
cases := []struct {
name string
query string
want string
}{
{"with name", "name=Alice", "Hello, Alice!"},
{"without name", "", "Hello, stranger!"},
{"empty name", "name=", "Hello, stranger!"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
target := "/echo"
if c.query != "" {
target += "?" + c.query
}
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
EchoHandler(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type"))
assert.Equal(t, c.want, rec.Body.String())
})
}
}httptest.NewRequest 与 http.NewRequest 的区别:
http.NewRequest不会自动填充r.Host,需要手动设置。httptest.NewRequest会自动设置r.Host = target的主机部分,更适合直接调用 Handler。- 两者都接受
body io.Reader,传nil表示无请求体。
httptest.NewRecorder 的常用字段:
| 字段 | 类型 | 含义 |
|---|---|---|
Code | int | 响应状态码 |
HeaderMap | http.Header | 响应头 |
Body | *bytes.Buffer | 响应体 |
Flushed | bool | 是否调用过 Flush |
三、httptest.NewServer:模拟 HTTP 服务器
NewRecorder 适合直接测 Handler,但有些代码(如 HTTP 客户端、调用第三方 API 的代码)不直接调用 Handler,而是发 HTTP 请求。这种情况下,需要 httptest.NewServer 启动一个真实的本地服务器。
go
package fetcher
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
}
// FetchLatestRelease 调用 GitHub API 获取最新 Release 信息。
type Fetcher struct {
client *http.Client
url string
}
func NewFetcher(client *http.Client, url string) *Fetcher {
return &Fetcher{client: client, url: url}
}
func (f *Fetcher) FetchLatestRelease(ctx context.Context) (Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.url, nil)
if err != nil {
return Release{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := f.client.Do(req)
if err != nil {
return Release{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Release{}, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var r Release
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return Release{}, err
}
return r, nil
}测试:
go
package fetcher_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFetcher_FetchLatestRelease(t *testing.T) {
// 启动一个 mock server,返回预定义的 JSON
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 校验请求头
require.Equal(t, "application/vnd.github+json", r.Header.Get("Accept"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"tag_name": "v1.2.3",
"name": "Release v1.2.3",
})
}))
defer server.Close()
fetcher := NewFetcher(server.Client(), server.URL)
release, err := fetcher.FetchLatestRelease(context.Background())
require.NoError(t, err)
assert.Equal(t, "v1.2.3", release.TagName)
assert.Equal(t, "Release v1.2.3", release.Name)
}
func TestFetcher_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
fetcher := NewFetcher(server.Client(), server.URL)
_, err := fetcher.FetchLatestRelease(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status: 404")
}httptest.NewServer 会自动选择一个空闲端口,启动后通过 server.URL 获取类似 http://127.0.0.1:54321 的地址。server.Client() 返回一个预配置好的 *http.Client,可以直接访问该服务器。
测试结束记得
server.Close(),否则端口会泄漏。习惯用defer server.Close()放在NewServer紧后面。
四、httptest.NewRequest:构造请求
httptest.NewRequest 用来构造请求对象,比 http.NewRequest 更适合 Handler 测试,因为它会自动填充 Host 等 Header。常用方式:
go
// GET 请求
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
// POST + JSON body
body := strings.NewReader(`{"name":"Alice"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
// 设置 path 参数(仅作为字段保存,需 Handler 配合解析)
req.SetPathValue("id", "42") // Go 1.22+ 的 ServeMux 才会读取
// 设置查询参数
req.URL.RawQuery = "name=Alice&age=28"下面是一个 POST JSON 的完整例子:
go
package handlers
import (
"encoding/json"
"net/http"
)
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Name == "" || req.Email == "" {
http.Error(w, "name and email are required", http.StatusBadRequest)
return
}
resp := CreateUserResponse{
ID: 1,
Name: req.Name,
Email: req.Email,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(resp)
}测试:
go
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateUserHandler(t *testing.T) {
t.Run("success", func(t *testing.T) {
body := strings.NewReader(`{"name":"Alice","email":"alice@example.com"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
var resp CreateUserResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, "Alice", resp.Name)
assert.Equal(t, "alice@example.com", resp.Email)
})
t.Run("missing fields", func(t *testing.T) {
body := strings.NewReader(`{"name":"Alice"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "name and email are required")
})
t.Run("invalid json", func(t *testing.T) {
body := strings.NewReader(`not json`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "invalid json")
})
t.Run("wrong method", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
})
}五、测试 Gin 路由处理器
Gin 的 *gin.Context 是从 http.ResponseWriter 和 *http.Request 包装来的,所以可以用 httptest 直接测。
先准备一个 Gin 应用:
go
package ginapp
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
var users = map[int]User{
1: {ID: 1, Name: "Alice"},
2: {ID: 2, Name: "Bob"},
}
func NewRouter() *gin.Engine {
r := gin.New()
r.GET("/users/:id", GetUser)
r.POST("/users", CreateUser)
r.GET("/users", ListUsers)
return r
}
func GetUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
u, ok := users[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, u)
}
func CreateUser(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u.ID = len(users) + 1
users[u.ID] = u
c.JSON(http.StatusCreated, u)
}
func ListUsers(c *gin.Context) {
list := make([]User, 0, len(users))
for _, u := range users {
list = append(list, u)
}
c.JSON(http.StatusOK, list)
}测试:
go
package ginapp_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"example.com/ginapp"
)
func init() {
gin.SetMode(gin.TestMode) // 关闭日志输出,让测试输出更干净
}
func TestGetUser(t *testing.T) {
router := ginapp.NewRouter()
cases := []struct {
name string
id string
wantCode int
wantName string
wantErr string
}{
{"existing user", "1", http.StatusOK, "Alice", ""},
{"another user", "2", http.StatusOK, "Bob", ""},
{"non-existent", "999", http.StatusNotFound, "", "not found"},
{"invalid id", "abc", http.StatusBadRequest, "", "invalid id"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/"+c.id, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, c.wantCode, rec.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
if c.wantErr != "" {
assert.Contains(t, resp["error"], c.wantErr)
} else {
assert.Equal(t, c.wantName, resp["name"])
}
})
}
}
func TestCreateUser(t *testing.T) {
router := ginapp.NewRouter()
body := strings.NewReader(`{"name":"Charlie"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var resp ginapp.User
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "Charlie", resp.Name)
assert.NotZero(t, resp.ID)
}
func TestListUsers(t *testing.T) {
router := ginapp.NewRouter()
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp []ginapp.User
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.GreaterOrEqual(t, len(resp), 2)
}
func TestCreateUser_InvalidJSON(t *testing.T) {
router := ginapp.NewRouter()
body := strings.NewReader(`not json`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}gin.TestMode 会关闭 gin 默认的彩色日志输出,让测试输出更干净。在测试入口(init 或 TestMain)设置一次即可。
六、测试 HTTP 中间件
中间件本质是一个「接收 Handler 返回 Handler」的函数。测试它时,可以构造一个简单的下游 Handler,然后用 httptest 验证中间件是否按预期改写了请求或响应。
下面是一个日志中间件和一个鉴权中间件的测试示例:
go
package middleware
import (
"net/http"
"strings"
"time"
)
// AuthMiddleware 验证 Bearer Token。
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
http.Error(w, "missing authorization", http.StatusUnauthorized)
return
}
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "invalid scheme", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(auth, "Bearer ")
if token != "secret-token" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// LogMiddleware 记录请求信息。
type LogEntry struct {
Method string
Path string
Status int
Duration time.Duration
}
func LogMiddleware(entries *[]LogEntry, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
*entries = append(*entries, LogEntry{
Method: r.Method,
Path: r.URL.Path,
Status: rec.status,
Duration: time.Since(start),
})
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}测试:
go
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthMiddleware(t *testing.T) {
// 一个简单的下游 handler
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
cases := []struct {
name string
auth string
wantCode int
wantBody string
}{
{"no auth header", "", http.StatusUnauthorized, "missing authorization"},
{"wrong scheme", "Basic abc", http.StatusUnauthorized, "invalid scheme"},
{"invalid token", "Bearer wrong", http.StatusUnauthorized, "invalid token"},
{"valid token", "Bearer secret-token", http.StatusOK, "ok"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if c.auth != "" {
req.Header.Set("Authorization", c.auth)
}
rec := httptest.NewRecorder()
AuthMiddleware(okHandler).ServeHTTP(rec, req)
require.Equal(t, c.wantCode, rec.Code)
assert.Contains(t, rec.Body.String(), c.wantBody)
})
}
}
func TestLogMiddleware(t *testing.T) {
var entries []LogEntry
// 构造一个返回 404 的下游 handler
notFoundHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mw := LogMiddleware(&entries, notFoundHandler)
// 发两次请求
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/users", nil)
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code)
}
require.Len(t, entries, 2)
assert.Equal(t, http.MethodGet, entries[0].Method)
assert.Equal(t, "/api/users", entries[0].Path)
assert.Equal(t, http.StatusNotFound, entries[0].Status)
assert.True(t, entries[0].Duration >= 0)
}
func TestAuthMiddleware_PassesThrough(t *testing.T) {
// 验证鉴权通过后,下游能正确收到请求
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer secret-token")
rec := httptest.NewRecorder()
AuthMiddleware(next).ServeHTTP(rec, req)
require.True(t, called, "下游 handler 应该被调用")
assert.Equal(t, http.StatusOK, rec.Code)
}七、测试客户端代码
测试「调用 HTTP API」的客户端代码,最佳做法是用 httptest.NewServer 启动一个 mock 服务器,让客户端访问。
go
package apiclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type Client struct {
baseURL string
http *http.Client
}
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
}
func NewClient(baseURL string, client *http.Client) *Client {
if client == nil {
client = http.DefaultClient
}
return &Client{baseURL: baseURL, http: client}
}
func (c *Client) GetPost(ctx context.Context, id int) (Post, error) {
url := fmt.Sprintf("%s/posts/%d", c.baseURL, id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Post{}, err
}
resp, err := c.http.Do(req)
if err != nil {
return Post{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Post{}, fmt.Errorf("status %d", resp.StatusCode)
}
var p Post
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return Post{}, err
}
return p, nil
}测试:
go
package apiclient_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_GetPost(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 校验请求路径
require.Equal(t, "/posts/1", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Post{ID: 1, Title: "Hello"})
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
post, err := client.GetPost(context.Background(), 1)
require.NoError(t, err)
assert.Equal(t, 1, post.ID)
assert.Equal(t, "Hello", post.Title)
})
t.Run("not found", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
_, err := client.GetPost(context.Background(), 999)
require.Error(t, err)
assert.Contains(t, err.Error(), "status 404")
})
t.Run("server error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
_, err := client.GetPost(context.Background(), 1)
require.Error(t, err)
assert.Contains(t, err.Error(), "status 500")
})
t.Run("invalid response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "not json")
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
_, err := client.GetPost(context.Background(), 1)
require.Error(t, err)
})
t.Run("connection refused", func(t *testing.T) {
// 启动后立即关闭,模拟连接失败
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
addr := server.URL
server.Close()
client := NewClient(addr, server.Client())
_, err := client.GetPost(context.Background(), 1)
require.Error(t, err)
})
}八、端到端 API 测试
端到端测试是把整个应用作为「黑盒」对待:启动应用、发 HTTP 请求、断言响应。httptest.NewServer 适合这种场景,但更常用的是直接用 http.ListenAndServe 配合一个随机端口。
下面是一个端到端测试的简化示例,把 Gin 应用整体跑起来:
go
package e2e_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"example.com/ginapp"
)
// startApp 启动应用,返回 server 与 cleanup。
func startApp(t *testing.T) (*httptest.Server, func()) {
t.Helper()
router := ginapp.NewRouter()
server := httptest.NewServer(router)
return server, func() { server.Close() }
}
// httpDo 封装一次 HTTP 调用,方便复用。
func httpDo(t *testing.T, method, url string, body string) *http.Response {
t.Helper()
var reader *strings.Reader
if body != "" {
reader = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
require.NoError(t, err)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
func TestE2E_UserLifecycle(t *testing.T) {
server, cleanup := startApp(t)
defer cleanup()
// 步骤 1:列出用户(初始至少 2 个)
resp := httpDo(t, http.MethodGet, server.URL+"/users", "")
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var list []ginapp.User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
initialCount := len(list)
require.GreaterOrEqual(t, initialCount, 2)
// 步骤 2:创建新用户
resp = httpDo(t, http.MethodPost, server.URL+"/users", `{"name":"Charlie"}`)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created ginapp.User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
require.Equal(t, "Charlie", created.Name)
newID := created.ID
// 步骤 3:查询刚创建的用户
resp = httpDo(t, http.MethodGet, server.URL+"/users/"+itoa(newID), "")
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var got ginapp.User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "Charlie", got.Name)
// 步骤 4:再次列出,数量应该 +1
resp = httpDo(t, http.MethodGet, server.URL+"/users", "")
defer resp.Body.Close()
require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
assert.Equal(t, initialCount+1, len(list))
}
// itoa 是 strconv.Itoa 的简化版,避免在测试里多 import。
func itoa(n int) string {
if n == 0 {
return "0"
}
var sign string
if n < 0 {
sign = "-"
n = -n
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return sign + string(digits)
}端到端测试覆盖了完整请求链路,能发现集成层才暴露的问题(如路由注册错误、中间件顺序错误、JSON 序列化问题)。但缺点是较慢、定位错误更难,建议与单元测试配合使用——单元测试覆盖细,端到端测试覆盖广。
九、完整示例:Gin 应用的 HTTP 测试
把前面的元素整合,下面给出一个完整的 Gin 应用测试套件,包含 Handler 测试、路由测试、中间件测试与端到端测试。
ginapp/app.go:
go
package ginapp
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
}
type Store struct {
items map[int]Item
nextID int
}
func NewStore() *Store {
return &Store{
items: map[int]Item{1: {ID: 1, Name: "Apple", Price: 100}},
nextID: 2,
}
}
func (s *Store) List() []Item {
list := make([]Item, 0, len(s.items))
for _, v := range s.items {
list = append(list, v)
}
return list
}
func (s *Store) Get(id int) (Item, bool) {
v, ok := s.items[id]
return v, ok
}
func (s *Store) Create(name string, price int) Item {
it := Item{ID: s.nextID, Name: name, Price: price}
s.items[s.nextID] = it
s.nextID++
return it
}
func (s *Store) Delete(id int) bool {
if _, ok := s.items[id]; !ok {
return false
}
delete(s.items, id)
return true
}
func NewRouter(store *Store) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api")
{
api.GET("/items", listItems(store))
api.GET("/items/:id", getItem(store))
api.POST("/items", createItem(store))
api.DELETE("/items/:id", deleteItem(store))
}
return r
}
func listItems(s *Store) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, s.List())
}
}
func getItem(s *Store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
item, ok := s.Get(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, item)
}
}
func createItem(s *Store) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Price int `json:"price" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := s.Create(req.Name, req.Price)
c.JSON(http.StatusCreated, item)
}
}
func deleteItem(s *Store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if !s.Delete(id) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Status(http.StatusNoContent)
}
}测试文件 ginapp/app_test.go:
go
package ginapp_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"example.com/ginapp"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestListItems(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
req := httptest.NewRequest(http.MethodGet, "/api/items", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var items []ginapp.Item
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &items))
assert.GreaterOrEqual(t, len(items), 1)
}
func TestGetItem(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
t.Run("existing", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/items/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var item ginapp.Item
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &item))
assert.Equal(t, 1, item.ID)
assert.Equal(t, "Apple", item.Name)
})
t.Run("non-existent", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/items/9999", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
t.Run("invalid id", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/items/abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
func TestCreateItem(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
cases := []struct {
name string
body string
wantCode int
}{
{"valid", `{"name":"Banana","price":50}`, http.StatusCreated},
{"missing name", `{"price":50}`, http.StatusBadRequest},
{"zero price", `{"name":"Foo","price":0}`, http.StatusBadRequest},
{"negative price", `{"name":"Foo","price":-1}`, http.StatusBadRequest},
{"invalid json", `not json`, http.StatusBadRequest},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/items", strings.NewReader(c.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, c.wantCode, rec.Code)
})
}
}
func TestDeleteItem(t *testing.T) {
t.Run("existing", func(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
req := httptest.NewRequest(http.MethodDelete, "/api/items/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
require.Empty(t, rec.Body.Bytes())
})
t.Run("non-existent", func(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
req := httptest.NewRequest(http.MethodDelete, "/api/items/9999", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
func TestCreateAndGetFlow(t *testing.T) {
store := ginapp.NewStore()
router := ginapp.NewRouter(store)
// 步骤 1:创建
body := bytes.NewReader([]byte(`{"name":"Cherry","price":120}`))
req := httptest.NewRequest(http.MethodPost, "/api/items", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var created ginapp.Item
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
require.Equal(t, "Cherry", created.Name)
// 步骤 2:查询创建的
req2 := httptest.NewRequest(http.MethodGet, "/api/items/"+itoa(created.ID), nil)
rec2 := httptest.NewRecorder()
router.ServeHTTP(rec2, req2)
require.Equal(t, http.StatusOK, rec2.Code)
var got ginapp.Item
require.NoError(t, json.Unmarshal(rec2.Body.Bytes(), &got))
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, "Cherry", got.Name)
}
// itoa 是测试中用到的辅助函数。
func itoa(n int) string {
if n == 0 {
return "0"
}
var digits []byte
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}执行测试:
bash
go test -v -cover预期输出:
text
=== RUN TestListItems
--- PASS: TestListItems (0.00s)
=== RUN TestGetItem
=== RUN TestGetItem/existing
=== RUN TestGetItem/non-existent
=== RUN TestGetItem/invalid_id
--- PASS: TestGetItem (0.00s)
=== RUN TestCreateItem
=== RUN TestCreateItem/valid
=== RUN TestCreateItem/missing_name
=== RUN TestCreateItem/invalid_json
--- PASS: TestCreateItem (0.00s)
PASS
coverage: 90.0% of statements
ok example.com/ginapp 0.012s十、常见陷阱与最佳实践
1. 不要在生产代码里调用 httptest
httptest 是测试包,生产代码不应依赖它。如果你的生产代码需要 *http.Client 作为依赖,测试时传入 server.Client() 即可。
2. 别忘了 defer resp.Body.Close()
如果不在测试里关闭响应 Body,可能导致连接复用失败,特别是循环调用时。每收到一个 resp 都 defer resp.Body.Close()。
3. 状态码断言要具体
不要写 assert.NotEmpty(t, rec.Body) 就放过——HTTP 状态码是契约的一部分,应该精确断言。
4. 路径参数要测试边界
/items/:id 的 id 是字符串,要测试:
- 有效的整数
- 非数字(如
abc) - 负数(如
-1) - 零(
0)
5. 别在测试里硬编码端口
go
// ❌ 错误:硬编码 8080,CI 上可能被占用
listener, _ := net.Listen("tcp", ":8080")
// ✅ 正确:用 httptest.NewServer 自动选端口
server := httptest.NewServer(handler)
defer server.Close()6. 测试用 gin.TestMode
Gin 在 DebugMode 下会输出大量彩色日志,干扰测试输出。在测试入口设置:
go
func init() {
gin.SetMode(gin.TestMode)
}7. JSON 比较要警惕顺序
JSON 对象的 key 在序列化时顺序不保证(除非用有序结构)。比较时用 JSONEq 或解码成 map 后比较,避免字符串比较:
go
// ❌ 字符串比较,顺序敏感
assert.Equal(t, `{"a":1,"b":2}`, rec.Body.String())
// ✅ 用 JSONEq,顺序无关
assert.JSONEq(t, `{"a":1,"b":2}`, rec.Body.String())十一、小结
本篇系统讲解了 Go 的 HTTP 测试,核心要点:
httptest.NewRecorder:捕获 Handler 的响应,适合直接测 Handler,无需启动服务器。httptest.NewRequest:构造请求,自动填充 Host,比http.NewRequest更适合 Handler 测试。httptest.NewServer:启动真实本地服务器,测试 HTTP 客户端代码的利器。- 测试 Gin:通过
router.ServeHTTP(rec, req)直接调用,配合gin.TestMode让输出干净。 - 测试中间件:构造简单的下游 Handler,验证中间件对请求/响应的改写。
- 测试客户端:用
httptest.NewServer返回 mock API,校验客户端的请求与错误处理。 - 端到端测试:把整个应用启动在本地服务器,模拟真实请求链路,覆盖集成层问题。
- 陷阱:关闭 Body、不硬编码端口、用
gin.TestMode、JSON 顺序无关比较、路径参数边界。
下一篇我们将进入性能测试领域——基准测试(Benchmark),学习如何用 go test -bench 测量代码性能、对比不同实现的差异,并优化内存分配。
HTTP 测试是 Go Web 开发中最实用的技能之一。掌握了 httptest,你会发现大多数 Web 应用测试都不需要真正的网络或外部服务——既快又稳定。把这份能力用好,能让你的测试套件跑得飞快、CI 也更可靠。