前面 9 篇把功能写完了,改起来怕不怕改坏?单元测试就是那道保险。Gin 的 handler 是纯函数式代码,用标准库 httptest 就能在内存里发请求、收响应,不监听端口、不起数据库,一个测试毫秒级跑完。
1. 最小可跑的 handler 测试
httptest.NewRecorder 收集响应,r.ServeHTTP 同步执行完整个链路:
func TestGetArticle(t *testing.T) { r := gin.New() r.GET("/articles/:id", func(c *gin.Context) { if c.Param("id") == "" { c.JSON(400, gin.H{"error": "id empty"}) return } c.JSON(200, gin.H{"id": c.Param("id")}) }) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/articles/1", nil) r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) assert.Contains(t, w.Body.String(), `"id":"1"`) }
w.Code 是状态码,w.Body.String() 是响应体。断言用 testify/assert。这一层测的是真实 HTTP 行为(路由、参数绑定、中间件),比单独测函数更贴近线上。
2. 用接口把 service 换成 mock
handler 依赖 service 查库,测试里不能真查库。先给 service 抽象成接口,handler 持接口:
type ArticleService interface { Get(id string) (*Article, error) } type ArticleHandler struct { svc ArticleService }
测试塞一个内存假实现,数据库完全不碰:
type fakeSvc struct{ data map[string]*Article } func (f *fakeSvc) Get(id string) (*Article, error) { if a, ok := f.data[id]; ok { return a, nil } return nil, ErrNotFound }
接口隔离让单测快、稳、不依赖环境。这也是第 5 篇分层的好处:service 抽象成接口,测试才好换。
3. 表驱动测试覆盖多分支
一个函数测正常、异常、边界三种情况,用切片把用例列出来:
func TestGetArticle_Table(t *testing.T) { svc := &fakeSvc{data: map[string]*Article{"1": {Title: "Gin"}}} h := &ArticleHandler{svc: svc} cases := []struct { name string id string want int }{ {"正常返回", "1", 200}, {"不存在", "999", 404}, {"空 id", "", 400}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := gin.New() r.GET("/articles/:id", h.Get) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/articles/"+tc.id, nil) r.ServeHTTP(w, req) assert.Equal(t, tc.want, w.Code) }) } }
ErrNotFound 在 handler 里转成 Fail(c, CodeNotFound, ...),所以假实现返回它,测试就能覆盖 404 分支。每加一个用例只加一行结构体,不用复制整段测试。
4. 测 JSON 请求体绑定
POST 接口测请求体解析和 binding 校验:
func TestCreateArticle(t *testing.T) { body := `{"title":"hello","content":"world"}` req, _ := http.NewRequest("POST", "/articles", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r := gin.New() r.POST("/articles", h.Create) r.ServeHTTP(w, req) assert.Equal(t, 201, w.Code) // 校验失败:title 必填 bad := strings.NewReader(`{"content":"world"}`) req2, _ := http.NewRequest("POST", "/articles", bad) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() r.ServeHTTP(w2, req2) assert.Equal(t, 400, w2.Code) }
第二个用例确认 binding:"required" 真的生效,空 title 被拦在 400,不会落到 service。
5. 测鉴权中间件
中间件从 header 取 token 写进 context,测试构造带和不带 token 两种请求:
func TestAuthRequired(t *testing.T) { r := gin.New() r.GET("/me", AuthRequired(), func(c *gin.Context) { uid, _ := c.Get("uid") c.JSON(200, gin.H{"uid": uid}) }) // 无 token -> 401 w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/me", nil)) assert.Equal(t, 401, w.Code) // 带合法 token -> 200,且 uid 正确 w2 := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/me", nil) req.Header.Set("Authorization", "Bearer "+makeToken(1)) r.ServeHTTP(w2, req) assert.Equal(t, 200, w2.Code) assert.Contains(t, w2.Body.String(), `"uid":1`) }
makeToken(1) 是测试里用同一密钥签发的一个合法 token,复用第 6 篇的签发函数。
6. require 和 assert 怎么选
testify 两个断言函数:
b, err := json.Marshal(a) require.NoError(t, err) // 解码失败,后面没法测,立即停 assert.Equal(t, 200, w.Code) // 状态码不符,继续跑完看全貌
require 失败立刻终止当前测试函数,适合前置条件(连不上库、解不出结构)。assert 失败记一笔但继续,适合收集多个断言结果。
7. 跑测试与覆盖率
go test ./... -v go test -cover ./handler/... go test -coverprofile=cover.out ./... && go tool cover -html=cover.out
-cover 看每个包覆盖率,-coverprofile 生成报告再用 go tool cover 在浏览器里看到底哪行没测到。把 cover.out 加进 .gitignore,别提交进去。
8. 一个值得养成的习惯
handler 构造函数注入接口,测试才能换 mock。每加一个接口顺手写三个用例:参数校验失败、鉴权失败、正常返回。改老代码前先 go test ./... -cover 跑一遍,改完再跑一遍,覆盖率掉了就补。CI 里加一步 go test -cover,低于阈值就卡住合并,回归在进主干前就被拦下。
十篇走完,你手里是一套从 go mod init 到测试覆盖的完整 Gin 后端骨架。缓存、鉴权、分层、部署都齐了,按业务往上长就行。