目录

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

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

https://yyxbloguse.oss-cn-beijing.aliyuncs.com/img/yuque_mind.jpeg

普通函数的测试

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 结尾, 我们通过表组测试我们可以一次性的进行多组输入测试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 指令是指定运行某个用例

上面的测试用例打印输出

1
2
3
4
5
6
7
=== 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 库来写个简单的服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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库来写单元测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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 接口定义了三个方法

1
2
3
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 再之后的处理就是一些字符串的处理了,不是本文的重点, 画一张图表示一下上面的过程

https://yyxbloguse.oss-cn-beijing.aliyuncs.com/img/2022-09-07-23-47-49.jpeg

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

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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自己处理请求,先写一个正向测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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
1
2
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. 之后的判断操作就和之前的没有什么两样了。

此时的调用过程以下图

https://yyxbloguse.oss-cn-beijing.aliyuncs.com/img/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
  6. 不传b
  7. 不传a和b

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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)

	}

}

上面的输出为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
......
......
// 定义参数结构体
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,可以使用

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

是否进行基准测试Benchmarking

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

总结与思考

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

  • 文章标题: 一文详解gin框架中使用单元测试
  • 本文作者: 杨彦星
  • 本文链接: https://www.yangyanxing.com/article/test-in-gin.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。