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