3. HTTP 服务基础与测试桩:使用 net/http 构建简单 REST API,编写第一个单元测试,理解 httptest 包

3.1 从零构建一个 REST API:用户资源服务

在 Go 生态中,net/http 标准库提供了构建 HTTP 服务所需的一切基础能力。本节我们将创建一个简单的用户管理 REST API,包含 GET(列表/详情)和 POST(创建)两个端点,为后续的契约测试与性能压测提供稳定的被测对象。

3.1.1 项目结构

user-service/
├── go.mod
├── main.go
├── handler.go
├── store.go
└── store_test.go

3.1.2 内存存储层(store.go)

package main

import (
    "fmt"
    "sync"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type Store struct {
    mu     sync.RWMutex
    users  map[int]User
    nextID int
}

func NewStore() *Store {
    return &Store{
        users:  make(map[int]User),
        nextID: 1,
    }
}

func (s *Store) Create(name string) User {
    s.mu.Lock()
    defer s.mu.Unlock()
    u := User{ID: s.nextID, Name: name}
    s.users[u.ID] = u
    s.nextID++
    return u
}

func (s *Store) Get(id int) (User, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    u, ok := s.users[id]
    if !ok {
        return User{}, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

func (s *Store) List() []User {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]User, 0, len(s.users))
    for _, u := range s.users {
        result = append(result, u)
    }
    return result
}

3.1.3 HTTP 处理器层(handler.go)

package main

import (
    "encoding/json"
    "net/http"
    "strconv"
    "strings"
)

type Handler struct {
    store *Store
}

func NewHandler(store *Store) *Handler {
    return &Handler{store: store}
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 路由分发:/users 或 /users/{id}
    path := strings.TrimPrefix(r.URL.Path, "/users")
    path = strings.TrimPrefix(path, "/")

    switch r.Method {
    case http.MethodGet:
        if path == "" {
            h.listUsers(w, r)
        } else {
            id, err := strconv.Atoi(path)
            if err != nil {
                http.Error(w, "invalid id", http.StatusBadRequest)
                return
            }
            h.getUser(w, r, id)
        }
    case http.MethodPost:
        if path == "" {
            h.createUser(w, r)
        } else {
            http.Error(w, "not found", http.StatusNotFound)
        }
    default:
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    }
}

func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) {
    users := h.store.List()
    writeJSON(w, http.StatusOK, users)
}

func (h *Handler) getUser(w http.ResponseWriter, r *http.Request, id int) {
    user, err := h.store.Get(id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    writeJSON(w, http.StatusOK, user)
}

func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name string `json:"name"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    user := h.store.Create(req.Name)
    writeJSON(w, http.StatusCreated, user)
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

3.1.4 服务入口(main.go)

package main

import "net/http"

func main() {
    store := NewStore()
    handler := NewHandler(store)
    http.ListenAndServe(":8080", handler)
}
设计要点:本示例采用 http.Handler 接口统一分发路由,而非使用第三方路由器。这种设计在测试时更易注入 mock 或 stub,且与 httptest 包的协作最为自然。

3.2 理解 httptest 包:测试 HTTP 服务的瑞士军刀

net/http/httptest 是 Go 标准库中专门为 HTTP 测试设计的包,它提供了两大核心能力:

  • httptest.NewServer:启动一个真实的 HTTP 测试服务器,绑定到随机端口,返回 *httptest.Server,可直接用 http.Get 等客户端调用。
  • httptest.NewRecorder:模拟 http.ResponseWriter,记录处理器写入的状态码、响应头和响应体,无需启动网络端口。
组件 适用场景 网络开销 推荐用途
httptest.NewServer 集成测试、端到端测试 有(真实 TCP 端口) 验证完整 HTTP 生命周期,包括中间件、超时、连接池
httptest.NewRecorder 单元测试、处理器逻辑测试 无(纯内存操作) 快速验证状态码、响应体、Header,适合 TDD

3.3 编写第一个单元测试:使用 httptest.NewRecorder

我们为 Handler 编写单元测试,覆盖 创建用户 和 获取用户列表 两个核心场景。测试文件 handler_test.go 如下:

package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestCreateUser(t *testing.T) {
    // 1. 准备依赖
    store := NewStore()
    handler := NewHandler(store)

    // 2. 构造请求体
    body := `{"name":"Alice"}`
    req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
    rec := httptest.NewRecorder()

    // 3. 执行处理器
    handler.ServeHTTP(rec, req)

    // 4. 验证响应
    if rec.Code != http.StatusCreated {
        t.Fatalf("expected status 201, got %d", rec.Code)
    }

    var user User
    if err := json.NewDecoder(rec.Body).Decode(&user); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("expected name 'Alice', got '%s'", user.Name)
    }
    if user.ID != 1 {
        t.Errorf("expected ID 1, got %d", user.ID)
    }
}

func TestListUsers(t *testing.T) {
    store := NewStore()
    handler := NewHandler(store)

    // 先插入两条数据
    store.Create("Bob")
    store.Create("Carol")

    req := httptest.NewRequest(http.MethodGet, "/users", nil)
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("expected status 200, got %d", rec.Code)
    }

    var users []User
    if err := json.NewDecoder(rec.Body).Decode(&users); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }
    if len(users) != 2 {
        t.Errorf("expected 2 users, got %d", len(users))
    }
}
测试模式解析:上述测试遵循 Arrange-Act-Assert(AAA)模式。Arrange 阶段创建 Store 和 Handler;Act 阶段通过 httptest.NewRecorder 模拟请求执行;Assert 阶段验证状态码和响应体。整个过程不依赖外部网络,毫秒级完成。

3.4 使用 httptest.NewServer 进行集成测试

当需要验证完整的 HTTP 协议行为(如 Content-Type 头、连接关闭、超时)时,httptest.NewServer 是更好的选择。以下示例测试完整的创建 + 查询流程:

func TestCreateAndGetUserIntegration(t *testing.T) {
    store := NewStore()
    handler := NewHandler(store)

    // 启动测试服务器
    server := httptest.NewServer(handler)
    defer server.Close()

    // 1. 创建用户
    createResp, err := http.Post(server.URL+"/users", "application/json",
        strings.NewReader(`{"name":"Dave"}`))
    if err != nil {
        t.Fatal(err)
    }
    if createResp.StatusCode != http.StatusCreated {
        t.Fatalf("create: expected 201, got %d", createResp.StatusCode)
    }

    var created User
    json.NewDecoder(createResp.Body).Decode(&created)
    createResp.Body.Close()

    // 2. 通过 ID 查询
    getResp, err := http.Get(server.URL + "/users/" + strconv.Itoa(created.ID))
    if err != nil {
        t.Fatal(err)
    }
    if getResp.StatusCode != http.StatusOK {
        t.Fatalf("get: expected 200, got %d", getResp.StatusCode)
    }

    var fetched User
    json.NewDecoder(getResp.Body).Decode(&fetched)
    getResp.Body.Close()

    if fetched.Name != "Dave" {
        t.Errorf("expected name 'Dave', got '%s'", fetched.Name)
    }
}
注意:集成测试中务必调用 defer server.Close() 释放资源。每个测试用例应使用独立的 httptest.NewServer 实例,避免状态污染。对于高并发测试场景,建议使用 httptest.NewUnstartedServer 手动控制启动时机。

3.5 测试桩(Test Stub)与 httptest 的结合

在真实项目中,Handler 可能依赖外部服务(如数据库、第三方 API)。此时我们需要 测试桩(Stub) 来替代真实依赖。以下是一个典型的 Stub 模式:

// 定义接口,便于测试替换
type UserStore interface {
    Create(name string) User
    Get(id int) (User, error)
    List() []User
}

// 生产实现
type RealStore struct { /* ... */ }

// 测试桩实现
type StubStore struct {
    users []User
}

func (s *StubStore) Create(name string) User {
    u := User{ID: len(s.users) + 1, Name: name}
    s.users = append(s.users, u)
    return u
}

func (s *StubStore) Get(id int) (User, error) {
    for _, u := range s.users {
        if u.ID == id {
            return u, nil
        }
    }
    return User{}, fmt.Errorf("not found")
}

func (s *StubStore) List() []User {
    return s.users
}

// 测试中使用 Stub
func TestHandlerWithStub(t *testing.T) {
    stub := &StubStore{
        users: []User{{ID: 1, Name: "StubUser"}},
    }
    handler := NewHandler(stub) // Handler 接受 UserStore 接口

    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)

    // 验证返回 Stub 中的数据
    var user User
    json.NewDecoder(rec.Body).Decode(&user)
    if user.Name != "StubUser" {
        t.Errorf("expected StubUser, got %s", user.Name)
    }
}

3.6 本章知识结构图

下图展示了 net/http 服务、httptest 测试工具 与 测试桩 三者之间的关系,以及它们在单元测试和集成测试中的分工:

HTTP 服务测试体系:net/http + httptest + 测试桩 生产代码 main.go handler.go store.go (真实依赖:数据库/外部API) httptest 包 NewRecorder NewServer NewRequest (纯内存 / 真实TCP) 测试桩(Stub) StubStore MockUserService FakeDatabase (可控行为、无副作用) 注入 替代 测试用例(handler_test.go) Arrange(准备Stub + Recorder)→ Act(调用ServeHTTP)→ Assert(验证状态码/响应体)

3.7 测试策略对比与选择指南

维度 httptest.NewRecorder httptest.NewServer 测试桩(Stub)
测试速度 极快(微秒级) 较快(毫秒级) 极快(微秒级)
网络依赖 无 有(随机端口) 无
覆盖范围 处理器逻辑 完整 HTTP 协议 业务逻辑 + 接口契约
外部依赖 需手动 Mock 需手动 Mock 天然隔离
推荐比例 70% 单元测试 20% 集成测试 10% 契约测试
实战建议:在压测闭环工程中,httptest.NewRecorder 用于快速验证处理器逻辑的正确性;httptest.NewServer 用于模拟真实网络环境下的性能基线测试;测试桩则用于隔离外部依赖,确保契约测试的稳定性和可重复性。三者结合,构成完整的测试金字塔底座。