在 Gin 中实践TDD测试API

目录

如果这篇文章能够从此让你放下手中的快递员,从此开始做一个 TDD First 的 programmer ,那么本文的目的也就达到了

Why TDD is so important?

TDD 的重要性最直接的体现就是 No guessing 和 Robust,一切都在测试的意料之中,同时也确保你自己考虑清楚了所有的情况。而不是直接将你的代码或API接口 丢给测试人员,然后根据反馈再继续修改,这样一来一回实际上十分降低效率,那么当我们意识到这一点时,为什么我们不能自己先将大部分的BUG利用测试事先发现并改掉呢?

其次,当系统的规模不断变大,新的功能不断加入进来,这时系统的单元测试有利于我们进行系统的重构,通过代码覆盖率定位到死代码(Dead Code),通过查看调用次数将一些 已经非常健壮的代码独立成包或 module,实现增量编译等,在避免臃肿,加快构建速度等改善系统腐化的问题中,测试绝对是功不可没。

TDD can effect code quality

优先编写可测试的类或函数会极大地影响你的代码风格,TDD First 的程序员首先思考的应该是,这个函数的依赖(parameters)是什么?对象实例,基础类型,还是接口? 什么样的函数才是容易测试的?这里就不得不提及 Clean Code 的一句名言

Function should do one thing. They should do it well and do it only

当你愿意主动去写测试的时候,你就越希望这些函数是像这样的,无形之中告诉了你的目标函数的模样。有点抽象,但句句重点。简单来说,一个工程的构建通常都是自底向上的, 越是灵活的系统切分的粒度应当是越细的,就像搭积木一样,从与数据库直接打交道的数据层,到持久化的 service层 ,基于 RESTful 的 Controller 控制层,校验及权限控制,路由层,日志管理 监控等,越高层级的操作越是依赖于低一层级的操作,因此自顶向下对代码健壮性的要求也是越高的。从测试的角度上来看,自底向上看,越是这些细粒度的函数责任越重大,这个层级的测试也是最容易写的, 因为它们正是我们说的那种只做一件事的函数。

TDD decrease BUG

试想一下这两种控制器函数

func (ctrl *UserController) Register(user *entity.User) {
    // validate user
    // code ....
    // code ....
    // insert into DB
    // code ....
    // code ....
    // err handle
    // code ....
    // code ....
    // response
}
func (ctrl *UserController) Register(user *entity.User) {
    // ... validate.NewUser(user)
    // err handle ...
    // ... services.AddNewUser(user)
    // err handle ...
    // response ...
}

前一种函数并没有做到 Do one thing,整个函数体十分臃肿,出现 BUG 的可能也越多;第二种控制器函数非常简短,而且基本上在干着一件事,处理错误并将错误响应,倘若校验层和服务层的函数都是经过测试的, 那么在控制器出现问题的可能性又降低了一个数量级

TDD is actually faster

Talk is cheap, show me the code

举一个大多数后端开发人员都要写的 end-to-end 测试,以 gin 框架构建的 API 为例, POST json 然后验证返回的 json

准备工作:

func toReader(t interface{}) *bytes.Reader {
    b, _ := json.Marshal(t)
    return bytes.NewReader(b)
}

func toStruct(r *httptest.ResponseRecorder, t interface{}) error {
    resp := r.Result()
    body, _ := ioutil.ReadAll(resp.Body)
    return json.Unmarshal(body, t)
}

func makeJSONReq(method, path string, data interface{}) *http.Request {
    req, _ := http.NewRequest(method, path, toReader(data))
    req.Header.Add("Content-Type", "application/json")
    req.Header.Add("Accept", "application/json")
    return req
}

这些小的通用函数可以封装成测试套件或工具包

对其中一个API接口进行测试:

package api_test

import (
    "bytes"
    "encoding/json"
    "log"
    "net/http"
    "net/http/httptest"
    "os"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/yujiahaol68/rossy/app/model/checkpoint"

    "github.com/gin-gonic/gin"

    "github.com/yujiahaol68/rossy/app/database"
    "github.com/yujiahaol68/rossy/app/routers/api"
)

var router = gin.Default()

func setup() error {
    api.Router(router)
    return database.OpenForTest()
}

func TestMain(m *testing.M) {
    err := setup()
    if err != nil {
        log.Fatal(err)
        os.Exit(1)
    }
    defer database.Teardown()

    code := m.Run()
    os.Exit(code)
}

func Test_Source(t *testing.T) {
    t.Log("POST: /api/source")
    check := checkpoint.PostSource{"http://www.infoq.com/cn/feed", 2}

    w := httptest.NewRecorder()
    req := makeJSONReq("POST", "/api/source/", &check)
    router.ServeHTTP(w, req)

    var end endpoint.PostSource
    err := toStruct(w, &end)
    if err != nil {
        t.Fatal(err)
    }

    assert.Equal(t, http.StatusCreated, end.Code)
}

实际要写的测试用例非常短小,如果用 vscode 的话不用离开编辑器就能运行测试。同时对于较大的系统,也不用等所有路由都装载过后再来测试,大大减少启动的等待时间

将视图层分成 checkpointendpoint 两层分别表示输入的 json 和 返回的 json 对应的 struct 描述,对于复杂的 input 还能考虑 mock struct, 好处多多,请立即开始你的 TDD 之旅吧!

更多的测试实践可以参考我的示例项目

如有错误,不胜指教。