← 返回博客
go2026-09-12 21:17:003 分钟 · 751 1

Gin 单元测试:用 httptest + testify 守住 handler

用 httptest 起一个内存 HTTP 请求打 Gin handler,testify 做断言,配合接口 mock service 把数据库挡在测试外,覆盖参数校验、鉴权中间件和正常流程。

#gin#单元测试#testify

前面 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 后端骨架。缓存、鉴权、分层、部署都齐了,按业务往上长就行。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/186