在 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 的话不用离开编辑器就能运行测试。同时对于较大的系统,也不用等所有路由都装载过后再来测试,大大减少启动的等待时间
将视图层分成 checkpoint
和 endpoint
两层分别表示输入的 json 和 返回的 json 对应的 struct 描述,对于复杂的 input 还能考虑 mock struct,
好处多多,请立即开始你的 TDD 之旅吧!
更多的测试实践可以参考我的示例项目
如有错误,不胜指教。