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()

}

golang

1166 Words

2021-01-22 14:35 +0800