0

一文详解gin框架中使用单元测试

 1 year ago
source link: https://www.yangyanxing.com/article/test-in-gin.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

作为一名合格的开发人员,写单元测试可以很好的梳理代码逻辑,让问题尽早的暴露出来,甚至有些公司或者团队会硬性要求开发人员写单元测试用例,并且还要有多少多少的覆盖度, 本文先以简单的函数单元测试为例,一点点的引入http的接口测试,最后再讨论一下gin 框架的单元测试,包括GET与POST方法 以下是本文的内容结构。

yuque_mind

普通函数的测试

当我们在main.go 文件中写了两个普通的函数,如下

package main

func Add(a, b int) int {
	return a + b
}

func getMin(x, y int) int {
	// 比较两个数,返回小的那个
	if x <= y {
		return x
	}
	return y
}

我们可以在main.go 中再写一些测试用例,调用上面的函数,但是更加优雅和高效的方式是利用golang 中的testing 包进行单元测试,我们可以在main.go 文件同目录下创建一个main_test.go 文件, 这个文件必须以_test.go 结尾, 我们通过表组测试我们可以一次性的进行多组输入测试。

func TestGetMin(t *testing.T) {
	// 构造出待测试的输入和输出
	var cases = []struct {
		A      int
		B      int
		expect int
	}{
		{1, 2, 1},
		{0, 0, 0},
		{2, 1, 1},
	}
	for _, testcase := range cases {
		if result := getMin(testcase.A, testcase.B); result != testcase.expect {
			t.Fatalf("getMin input %v, %v, expect %v, actual:%v",
				testcase.A, testcase.B, testcase.expect, result)
		} else {
			t.Logf("getMin input %v, %v, expect %v pass",
				testcase.A, testcase.B, testcase.expect)
		}
	}
}

可以使用IDE工具直接运行用例,也可以通过命令行来运行测试用例 go test -v -run TestGetMin

  1. -v 指令会显示过程中打印出来的log
  2. -run 指令是指定运行某个用例

上面的测试用例打印输出

=== RUN   TestGetMin
    main_test.go:110: getMin input 1, 2, expect 1 pass
    main_test.go:110: getMin input 0, 0, expect 0 pass
    main_test.go:110: getMin input 2, 1, expect 1 pass
--- PASS: TestGetMin (0.00s)
PASS
ok      golock  0.533s

可以看到只有所有的测试用例都通过了最后才是pass 状态。

但是对于这种非常简单的测试,其实意义不是很大,我们更多要测试的是对于复杂的逻辑结合复杂的业务来进行测试,以下让我们看看http web 服务是如何进行单元测试的? go 中net/http 标准库就为我们提供了非常方便的搭建web服务的方法,也有很多的框架如gin, beego等框架也会更加方便的提供web 服务, 我们先以原始的net/http搭建的服务来讲解。

原始的http 服务中的单元测试

先使用 http 库来写个简单的服务

package main

import (
	"fmt"
	"net/http"
	"strconv"
)

func index(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	a := q.Get("a")
	b := q.Get("b")
	ia, err1 := strconv.Atoi(a)
	ib, err2 := strconv.Atoi(b)
	if err1 != nil || err2 != nil {
		fmt.Fprintln(w, "请求参数有误")
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("bad request"))
		return
	}
	result := ia + ib
	fmt.Fprintln(w, result)
}

func main() {

	http.HandleFunc("/test", index)
	http.ListenAndServe("127.0.0.1:8080", nil)
}

这个服务非常简单,提供一个接口,/test ,获取两个参数,a 和 b, 返回a和b的求和结果, 运行这个服务会跑在本机的8080 端口,这时如果我们用curl 来访问curl http://127.0.0.1:8080/test?a=1&b=2 时,可以正常的返回3,接下来让我们用testing库来写单元测试

package main

import (
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
)

func TestHttp(t *testing.T) {
	// 定义一个请求
	req, err := http.NewRequest(http.MethodGet, "/test?a=1&b=2", nil)
	if err != nil {
		t.Fatalf("构建请求失败, err: %v", err)
	}
	// 构造一个记录
	rec := httptest.NewRecorder()
	// 调用web服务的方法
	index(rec, req)
	// 解析结果
	result := rec.Result()
	if result.StatusCode != 200 {
		t.Fatalf("请求状态码不符合预期")
	}
	body, err := ioutil.ReadAll(result.Body)
	if err != nil {
		t.Fatalf("读取返回内容失败, err:%v", err)
	}
	defer result.Body.Close()
	iresult, err := strconv.Atoi(strings.TrimSpace(string(body)))
	if err != nil {
		t.Fatalf("转换结果失败,err: %v", err)
	}
	if iresult != 3 {
		t.Fatalf("结果不符合预期, 预期为:%v, 实际为:%v", 3, iresult)
	}
	t.Log("用例测试通过")

}

有了之前简单函数的经验,我们来观察一下func index(w http.ResponseWriter, r *http.Request) 的函数输入

  • w, 是 http.ResponseWriter 的接口
  • r , 是http.Request 的结构体指针

这时我们要调用这个函数,就先需要构造这两个参数。 先看下 http.Request 指针的构造, http库提供了一个http.NewRequest(method, url string, body io.Reader) 方法,该方法返回一个*http.Request 和 error, 这个http.Request 就是我们需要构造的请求,web服务提供的是get请求方式,所以这里的method 也传入http.MethodGet,后面的url 传入/test?a=1&b=2, 就是我们要访问的url,这里也可以写成http://127.0.0.1:8080/test?a=1&b=2,但是很明显前面的方式更加简洁,所以还是不要写上host, body 由于我们传入的get请求,这里还用不到,之后说到post请求的时候再演示一下这个参数该怎么传,所以这里先传了一个nil, 这样构造出来的req 则可以传入index中的r *http.Request。 有了请求,还要有响应,正常情况下,我们使用浏览器,或者 curl 来请求接口,结果会返回到浏览器中或者curl出来的结果,但是作为单元测试,我们需要把响应的结果记录到某个变量中,这样我们就可以读取这个变量的内容,如状态码,header, 响应内容等,之后再进行测试用例的逻辑判断。 这里httptest库为我们提供了一个NewRecorder()方法,

http.ResponseWriter 接口定义了三个方法

Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)

httptest.NewRecorder() 返回的 httptest.ResponseRecorder 都进行实现,所以我们可以将这个变量传入index(rec, req)中。 调用完函数以后,就可以得到一个具体的响应 result := rec.Result(), 拿到这个result, 就可以获取到它的各种信息了.

  • result.StatusCode 为状态码
  • result.Body 为响应的body

可以使用 ioutil.ReadAll(result.Body) 来读取body 值为[]byte 再之后的处理就是一些字符串的处理了,不是本文的重点, 画一张图表示一下上面的过程

process

上面只是通过http 创建了一个简单的web服务, 并且使用testing库来进行单元测试,上面并没有使用表组测试,只是想把它放到下面的gin来说明, 方法都是一样的。

有一点要说明的是,在使用单元测试时,是不需要启web服务的,测试代码是直接调用web服务里的方法。

gin 框架构建的服务进行单元测试

虽然net/http提供了搭建web服务的功能,但是一般的业务开发我们还是会使用成熟的框架,有了这些框架会使得业务开发更加的简单便捷,以下使用gin来开发一个和上面的功能相同的一个接口。 使用gin编写一个简单的web服务,上面使用的是http库来搭建一个GET请求,这里我们使用gin来搭建一个POST请求。

package main

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
)

func TestHandler(c *gin.Context) {
	a := c.PostForm("a")
	b := c.PostForm("b")
	// 转换为int
	ia, err1 := strconv.Atoi(a)
	ib, err2 := strconv.Atoi(b)
	if err1 != nil || err2 != nil {
		c.JSON(http.StatusBadRequest, gin.H{"msg": "bad params"})
		return
	}
	result := ia + ib
	c.JSON(http.StatusOK, gin.H{"result": result})
}

func setupRouter() *gin.Engine {
	r := gin.Default()
	r.POST("/test", TestHandler)
	return r
}

func main() {
	r := setupRouter()
	r.Run()
}

启动完服务以后, 用curl -d 'a=1' -d 'b=2' -X POST [http://127.0.0.1:8080/test](http://127.0.0.1:8080/test) 来发送一个请求, 这时会得到正确的响应 {"result":3}

我们来看一下,该如何进行单元测试? 有了之前的经验, 我们来看一下要测试的handler 需要哪些参数, 只有一个参数*gin.Context,但是这个gin.Context 结构里却包含了众多的参数,如果我们要构造却非常麻烦。

我们可以直接使用gin中的ServeHTTP方法来直接让gin自己处理请求,先写一个正向测试

package main

import (
	"encoding/json"
	"io/ioutil"
	"net/http"
    "net/url"
	"net/http/httptest"
    "strings"
	"testing"
)

func TestSimpleGin(t *testing.T) {
    // 关键点1, 使用gin的Router
	r := setupRouter()
    // 关键点2 构造请求body
	data := url.Values{"a": {"1"}, "b": {"2"}}
	reqbody := strings.NewReader(data.Encode())
	req, err := http.NewRequest(http.MethodPost, "/test", reqbody)
	if err != nil {
		t.Fatalf("构建请求失败, err: %v", err)
	}
    // 关键点3, 设置请求头,一定
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	// 构造一个记录
	rec := httptest.NewRecorder()
	//关键点4, 调用web服务的方法
	r.ServeHTTP(rec, req)
	result := rec.Result()
	if result.StatusCode != 200 {
		t.Fatalf("请求状态码不符合预期")
	}
	body, err := ioutil.ReadAll(result.Body)
	if err != nil {
		t.Fatalf("读取返回内容失败, err:%v", err)
	}
	defer result.Body.Close()
	var res struct {
		Result int `json:"result"`
	}
	err = json.Unmarshal(body, &res)
	if err != nil {
		t.Fatalf("转换结果失败,err: %v", err)
	}
	if res.Result != 3 {
		t.Fatalf("结果不符合预期, 预期为:%v, 实际为:%v", 3, res.Result)
	}
	t.Log("用例测试通过")

}

以上的测试用例有几处关键点

  1. r := setupRouter() 这里直接调用 web服务的初始化*gin.Engine方法,这样我们就得到了一个*gin.Engine实例,和原应用是相同的实例,这个实例记录着路由信息。
  2. 关键点2是构造请求体,在使用GET方法请求的时候,我们并没有传入任务的值,只是传了一个nil, 但是使用post请求的时候 ,我们需要手工的构造请求体。以下的两行代码构造了一个请求体,之后将其传入http.NewRequest
data := url.Values{"a": {"1"}, "b": {"2"}}
reqbody := strings.NewReader(data.Encode())
  1. 一定要设置请求头,我这里是通过form表单提交的方式进行body上传,用的比较多的还有json和二进制的方式,二进制一般用于上传文件。
  2. 直接调用 r.ServeHTTP(rec, req)方法,这里我们不再像调用 原生http的方法,这里是使用*gin.Engine来调用,这个引擎会自己判断用哪个handler来处理请求。
  3. 之后的判断操作就和之前的没有什么两样了。

此时的调用过程以下图

2022-09-07-23-48-57.jpeg

这个正向的测试很简单,正常情况下,我们会对接口通过传入不同的入参来检查服务是否能符合预期 对于上面的接口,我们可能想到有以下的测试用例:

  1. 正向用例, a和b 参数都是数值类型,如a=2, b=4, 此时预期为状态码为200, 返回的json 为{"result": 6}
  2. a 为非数值类型,
  3. b 为非数值类型
  4. a和b 都不为数值类型
  5. 不传a和b

2-7 的场景下都应该返回状态码为400, 我们可以写一个表组测试 我们可以通过表组测试,将上面的7种用例进行测试

package main

import (
	"encoding/json"
	"io/ioutil"
	"net/http"
    "net/url"
	"net/http/httptest"
    "strings"
	"testing"
)

func TestSimpleGin(t *testing.T) {
	r := setupRouter()
	var cases = []struct {
		A            string
		B            string
		ExpextCode   int
		ExpectResult int
	}{
		{"1", "2", 200, 3},
		{"a", "2", 400, 0},
		{"1", "b", 400, 0},
		{"a", "b", 400, 0},
		{"nil", "2", 400, 0},
		{"1", "nil", 400, 0},
		{"nil", "nil", 400, 0},
	}

	for _, testcase := range cases {
		var values url.Values = make(url.Values)
		values["a"] = []string{""}
		values["b"] = []string{""}
		if testcase.A != "nil" {
			values["a"] = []string{testcase.A}
		}
		if testcase.B != "nil" {
			values["b"] = []string{testcase.B}
		}
		reqbody := strings.NewReader(values.Encode())
		req, err := http.NewRequest(http.MethodPost, "/test", reqbody)
		if err != nil {
			t.Fatalf("构建请求失败, err: %v", err)
		}
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		// 构造一个记录
		rec := httptest.NewRecorder()
		// 调用web服务的方法
		r.ServeHTTP(rec, req)
		result := rec.Result()
		if result.StatusCode != testcase.ExpextCode {
			t.Fatalf("请求状态码不符合预期, 预期是%d 实际是%d \n", testcase.ExpextCode, result.StatusCode)
		}
		body, err := ioutil.ReadAll(result.Body)
		if err != nil {
			t.Fatalf("读取返回内容失败, err:%v", err)
		}
		defer result.Body.Close()
		var res struct {
			Result int `json:"result"`
		}
		err = json.Unmarshal(body, &res)
		if err != nil {
			t.Fatalf("转换结果失败,err: %v", err)
		}
		if res.Result != testcase.ExpectResult {
			t.Fatalf("结果不符合预期, 预期为:%v, 实际为:%v", testcase.ExpectResult, res.Result)
		}
		t.Logf("%#v 用例测试通过", testcase)

	}

}

上面的输出为

main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"1", B:"2", ExpextCode:200, ExpectResult:3} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"a", B:"2", ExpextCode:400, ExpectResult:0} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"1", B:"b", ExpextCode:400, ExpectResult:0} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"a", B:"b", ExpextCode:400, ExpectResult:0} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"nil", B:"2", ExpextCode:400, ExpectResult:0} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"1", B:"nil", ExpextCode:400, ExpectResult:0} 用例测试通过
main_test.go:103: struct { A string; B string; ExpextCode int; ExpectResult int }{A:"nil", B:"nil", ExpextCode:400, ExpectResult:0} 用例测试通过
--- PASS: TestSimpleGin (0.00s)
PASS
ok      golock  0.622s

上面是通过模拟form 表单的方式,当然更多的情况下是使用json来传参,如果使用json的话,也比较简单, 只是改变一下reqbody的定义,并且修改一下Header

......
......
// 定义参数结构体
var testcase = struct {
    A            string
    B            string
    ExpextCode   int
    ExpectResult int
}{"1", "2", 200, 3}

testcasebyte, _ := json.Marshal(&testcase)
reqbody := bytes.NewReader(testcasebyte)
req, _ := http.NewRequest("POST", "/test", reqbody)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// 调用web服务的方法
r.ServeHTTP(rec, req)
......
......

自定义请求

通过上面使用http.NewRequest("POST", "/test", reqbody) 来定义一个请求,我们还可以添加更多详细的设置,如自定义Header,上面已经体验过了,这个对于需要登录的场景就非常有用了, 可以将cookie写到header中,当然也可以获取到响应的信息,httptest.NewRecorder().Result() 就是一个*http.Response , 如获取到响应的Cookie,可以使用

rec := httptest.NewRecorder()
// 调用web服务的方法
r.ServeHTTP(rec, req)
result := rec.Result()
fmt.Println(result.Header.Values("Cookie"))

是否进行基准测试Benchmarking

我的理解还是不要使用模拟测试来进行基准测试,当需要进行压力测试,最好还是使用真实的环境,因为有时候瓶颈并不在gin或者golang 的http 上,可能更多的是在数据库或者别的IO请求上,整个链路上都有可能成为拉垮的原因,在真实的测试环境上或者线上环境做全链路压测可能会更好, 但是Benchmarking 也可以做一下,这样可以看到耗时在哪里。

总结与思考

以上是记录了一些在开发web服务时如何进行单元测试,但是我始终觉得这种单元测试还是过于简单,还有很多场景不能覆盖到,尤其是对于性能方面的测试,单元测试就显示有点无从下手,但是单元测试也正是考验开发人员对于业务逻辑的把控程度,有时候在业务开发过程中,想得不是那么全面,但是如果自己能够写上一些单元测试,其实在写的过程中就会考虑到各种异常的情况,这也为后期的高质量上线提供的坚实的基础。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK