Skip to content

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.NewRequesthttp.NewRequest 的区别:

  • http.NewRequest 不会自动填充 r.Host,需要手动设置。
  • httptest.NewRequest 会自动设置 r.Host = target 的主机部分,更适合直接调用 Handler。
  • 两者都接受 body io.Reader,传 nil 表示无请求体。

httptest.NewRecorder 的常用字段:

字段类型含义
Codeint响应状态码
HeaderMaphttp.Header响应头
Body*bytes.Buffer响应体
Flushedbool是否调用过 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 默认的彩色日志输出,让测试输出更干净。在测试入口(initTestMain)设置一次即可。

六、测试 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 测试,核心要点:

  1. httptest.NewRecorder:捕获 Handler 的响应,适合直接测 Handler,无需启动服务器。
  2. httptest.NewRequest:构造请求,自动填充 Host,比 http.NewRequest 更适合 Handler 测试。
  3. httptest.NewServer:启动真实本地服务器,测试 HTTP 客户端代码的利器。
  4. 测试 Gin:通过 router.ServeHTTP(rec, req) 直接调用,配合 gin.TestMode 让输出干净。
  5. 测试中间件:构造简单的下游 Handler,验证中间件对请求/响应的改写。
  6. 测试客户端:用 httptest.NewServer 返回 mock API,校验客户端的请求与错误处理。
  7. 端到端测试:把整个应用启动在本地服务器,模拟真实请求链路,覆盖集成层问题。
  8. 陷阱:关闭 Body、不硬编码端口、用 gin.TestMode、JSON 顺序无关比较、路径参数边界。

下一篇我们将进入性能测试领域——基准测试(Benchmark),学习如何用 go test -bench 测量代码性能、对比不同实现的差异,并优化内存分配。


HTTP 测试是 Go Web 开发中最实用的技能之一。掌握了 httptest,你会发现大多数 Web 应用测试都不需要真正的网络或外部服务——既快又稳定。把这份能力用好,能让你的测试套件跑得飞快、CI 也更可靠。