跳转至

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

作为一名合格的开发人员,写单元测试可以很好的梳理代码逻辑,让问题尽早的暴露出来,甚至有些公司或者团队会硬性要求开发人员写单元测试用例,并且还要有多少多少的覆盖度, 本文先以简单的函数单元测试为例,一点点的引入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 指令是指定运行某个用例

上面的测试用例打印输出

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 库来写个简单的服务

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 接口定义了三个方法

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

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

  3. 一定要设置请求头,我这里是通过form表单提交的方式进行body上传,用的比较多的还有json和二进制的方式,二进制一般用于上传文件。

  4. 直接调用 r.ServeHTTP(rec, req)方法,这里我们不再像调用 原生http的方法,这里是使用*gin.Engine来调用,这个引擎会自己判断用哪个handler来处理请求。
  5. 之后的判断操作就和之前的没有什么两样了。

此时的调用过程以下图

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

  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种用例进行测试

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,可以使用

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