Go: 基于 apitest 做handler层单元测试
apitest
A simple and extensible behavioural testing library. Supports mocking external http calls and renders sequence diagrams on completion. Credit to testify which is this libraries’ only dependency
相关资源:
老的方案: postman/newman
原先使用 postman + newman 的方式做的 API 功能测试;
这种方式不完全是API层的单元测试, 预先配置好数据库/初始化好数据后, 直接通过API调用, 判定响应值
是介于单元测试 + 集成测试 之间的一种测试, 但是既不是完备的单元测试, 也不是完整的集成测试;
好处:
- 简单方便, 在本地开发调试API时, 就能将调试请求直接转为测试用例
缺点:
- 难以维护: 更新API/增加API时, 未实时同步更新测试用例; 变更上层协议时, 例如middleware, 所有case需要改
- 违背Isolate原则: 无法独立, 每个case运行时, 前面执行的数据会影响后面执行的数据; 每个case都需要考虑规避之前case的数据影响
- 缺乏完备性: 一个API一般只构造一个失败, 一个成功两个Case
新的方案
- Golang 使用 apitest 做handler层的
单元测试
- 当前只覆盖
核心逻辑
- 重新去寻找
集成测试
的方案
1. 入门示例
func TestApi(t *testing.T) {
apitest.New().
Handler(handler).
Get("/user/1234").
Expect(t).
Body(`{"id": "1234", "name": "Tate"}`).
Status(http.StatusCreated).
End()
}
2. 传统httptest vs apitest
使用httptest
func TestPong(t *testing.T) {
r := util.SetupRouter()
r.GET("/test", Pong)
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
body, err := ioutil.ReadAll(w.Body)
assert.NoError(t, err)
assert.True(t, strings.Contains(string(body), "pong"))
}
使用apitest
func TestPong(t *testing.T) {
r := util.SetupRouter()
r.GET("/ping", Pong)
apitest.New().
Handler(r).
Get("/ping").
Expect(t).
Body(`{"message":"pong"}`).
Status(http.StatusOK).
End()
}
3. why not JSONpath?
我们决定禁止
在测试中使用JSONpath
, 虽然这是apitest提供的一个强大的特性
官方示例: {"a": 12345, "b": [{"key": "c", "value": "result"}]}
func TestApi(t *testing.T) {
apitest.New().
Handler(handler).
Get("/hello").
Expect(t).
Assert(jsonpath.Contains(`$.b[? @.key=="c"].value`, "result")).
End()
}
问题点: 可维护性并不好(多一层学习和理解成本, JSONPath本身是一套很强大的语法, 但也就意味着其复杂度很高); 而单元测试最重要的就是后续的可维护性
4. JSONAssert: 封装 json 解析成map[string]interface
注意力集中在json body, 而不是解析
type JSONAssertFunc func(map[string]interface{}) error
func NewJSONAssertFunc(t *testing.T, assertFunc JSONAssertFunc) func(res *http.Response, req *http.Request) error {
return func(res *http.Response, req *http.Request) error {
body, err := ioutil.ReadAll(res.Body)
assert.NoError(t, err, "read body from response fail")
defer res.Body.Close()
var data map[string]interface{}
//var data Response
err = json.Unmarshal(body, &data)
assert.NoError(t, err, "unmarshal string to json fail")
return assertFunc(data)
}
}
使用
func TestVersion(t *testing.T) {
r := util.SetupRouter()
r.GET("/version", Version)
apitest.New().
Handler(r).
Get("/version").
Expect(t).
Assert(util.NewJSONAssertFunc(t, func(m map[string]interface{}) error {
assert.Contains(t, m, "version")
assert.Contains(t, m, "commit")
assert.Contains(t, m, "buildTime")
assert.Contains(t, m, "goVersion")
assert.Contains(t, m, "env")
return nil
})).
Status(http.StatusOK).
End()
}
5. ResponseAssert: 封装成后台返回协议(Response)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
type ResponseAssertFunc func(Response) error
func NewResponseAssertFunc(
t *testing.T,
responseFunc ResponseAssertFunc,
) func(res *http.Response, req *http.Request) error {
return func(res *http.Response, req *http.Request) error {
body, err := ioutil.ReadAll(res.Body)
assert.NoError(t, err, "read body from response fail")
defer res.Body.Close()
var data Response
err = json.Unmarshal(body, &data)
assert.NoError(t, err, "unmarshal string to response fail")
return responseFunc(data)
}
}
使用
func TestCreateSystemBadRequestInvalidJson(t *testing.T) {
r := util.SetupRouter()
url := "/api/v1/systems"
r.POST(url, CreateSystem)
// validate fail
apitest.New().
Handler(r).
Post(url).
JSON(map[string]interface{}{
"hello": "123",
}).
Expect(t).
Assert(util.NewResponseAssertFunc(t, func(resp util.Response) error {
assert.Equal(t, resp.Code, util.BadRequestError)
assert.Equal(t, resp.Message, "bad request:ID is required")
return nil
})).
Status(http.StatusOK).
End()
}
6. 能不能整合 ginkgo?
apitst的Expect方法接收的是 *testing.T
, 对应interface是testing.TB
// Expect marks the request spec as complete and following code will define the expected response
func (r *Request) Expect(t *testing.T) *Response {
r.apiTest.t = t
return r.apiTest.response
}
并不兼容于 ginkgo 的GinkgoT()
7. 再抽象一层? No
不要再试图抽象成一个函数, 为了足够的灵活性, 势必导致函数参数数量增多, 导致使用困难;
最终完备的函数封装跟链式调用已经没有区别了
问题复杂化了. 再次封装的意义不大
func assertRequestResponse(
t *testing.T,
r http.Handler,
url string,
appCode, appSecret string,
jsonData map[string]interface{},
assertFunc func(res *http.Response, req *http.Request) error) {
// 问题:
// 1. post/get/put/delete
// 2. headers
// 3. query参数等等
apitest.New().
Handler(r).
Post(url).
Header("X-App-Code", appCode).
Header("X-App-Secret", appSecret).
JSON(
jsonData,
).
Expect(t).
Assert(
assertFunc,
).
Status(http.StatusOK).
End()
}