我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
作为一名合格的开发人员,写单元测试可以很好的梳理代码逻辑,让问题尽早的暴露出来,甚至有些公司或者团队会硬性要求开发人员写单元测试用例,并且还要有多少多少的覆盖度, 本文先以简单的函数单元测试为例,一点点的引入http的接口测试,最后再讨论一下gin 框架的单元测试,包括GET与POST方法 以下是本文的内容结构。
普通函数的测试
当我们在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
- -v 指令会显示过程中打印出来的log
- -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
再之后的处理就是一些字符串的处理了,不是本文的重点, 画一张图表示一下上面的过程
上面只是通过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("用例测试通过")
}
以上的测试用例有几处关键点
r := setupRouter()
这里直接调用 web服务的初始化*gin.Engine
方法,这样我们就得到了一个*gin.Engine
实例,和原应用是相同的实例,这个实例记录着路由信息。- 关键点2是构造请求体,在使用GET方法请求的时候,我们并没有传入任务的值,只是传了一个nil, 但是使用post请求的时候 ,我们需要手工的构造请求体。以下的两行代码构造了一个请求体,之后将其传入
http.NewRequest
中
data := url.Values{"a": {"1"}, "b": {"2"}}
reqbody := strings.NewReader(data.Encode())
- 一定要设置请求头,我这里是通过form表单提交的方式进行body上传,用的比较多的还有json和二进制的方式,二进制一般用于上传文件。
- 直接调用
r.ServeHTTP(rec, req)
方法,这里我们不再像调用 原生http的方法,这里是使用*gin.Engine
来调用,这个引擎会自己判断用哪个handler来处理请求。 - 之后的判断操作就和之前的没有什么两样了。
此时的调用过程以下图
这个正向的测试很简单,正常情况下,我们会对接口通过传入不同的入参来检查服务是否能符合预期 对于上面的接口,我们可能想到有以下的测试用例:
- 正向用例, a和b 参数都是数值类型,如
a=2, b=4
, 此时预期为状态码为200, 返回的json 为{"result": 6}
- a 为非数值类型,
- b 为非数值类型
- a和b 都不为数值类型
- 不传a
- 不传b
- 不传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服务时如何进行单元测试,但是我始终觉得这种单元测试还是过于简单,还有很多场景不能覆盖到,尤其是对于性能方面的测试,单元测试就显示有点无从下手,但是单元测试也正是考验开发人员对于业务逻辑的把控程度,有时候在业务开发过程中,想得不是那么全面,但是如果自己能够写上一些单元测试,其实在写的过程中就会考虑到各种异常的情况,这也为后期的高质量上线提供的坚实的基础。