GoLang快速上手单元测试(思想、框架、实践)
1、单元测试是什么
单元测试可以检查我们的代码能否按照预期进行,代码逻辑是否有问题,以此可以提升代码质量。
简单来说单元测试就是针对某一个函数方法进行测试,我们要先测试正确的传值与获取正确的预期结果,然后再添加更多测试用例,得出多种预期结果。尽可能达到该方法逻辑没有问题,或者问题都能被我们预知到。这就是单元测试的好处。
2、Golang是怎么写单元测试的?
很简单!Golang本身对自动化测试非常友好,并且有许多优秀的测试框架支持,非常好上手。
文中将以新手的角度,快速上手一一实践给大家感受Go单元测试的魅力。
3、写一个最简单的Test吧!
先来了解一下Go官方的testing包
要编写一个测试文件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数,如上所述。 将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test 命令时将被包含。
测试函数的签名必须接收一个指向testing.T类型的指针,并且不能返回任何值。函数名最好是Test+要测试的方法函数名。
记得一定要先看一下Testing的官方文档!
目录结构
test_study
—–samples.go
—–samples_test.go
被测试代码
package test_study
import "fmt"
func Hello() string {
return "Hello, world"
}
func main() {
fmt.Println(Hello())
}
这个代码将输出一句“Hello, world”
测试
package test_study
import "testing"
func TestHello(t *testing.T) {
got := Hello()
want := "Hello, world"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
测试结果
D:\goproj\test_study>go test -v
=== RUN TestHello
--- PASS: TestHello (0.00s)
PASS
ok _/D_/goproj/test_study 0.030s
说明我们的测试通过了,返回的值与我们预期的值是相同的。
现在尝试把预期结果修改一下
want := "xdcute.com"
测试结果
D:\goproj\test_study>go test -v
=== RUN TestHello
samples_test.go:9: got "Hello, world" want "xdcute.com"
--- FAIL: TestHello (0.00s)
FAIL
exit status 1
FAIL _/D_/goproj/test_study 0.159s
此时提示测试不通过,得到的值与预期的值不相同。
这就是一个最简单的测试写法,我们可以进行正确或错误的测试。
这里介绍几个常用的参数:
- -bench regexp 执行相应的 benchmarks,例如 -bench= (基准测试)
- -cover 开启测试覆盖率
- -run regexp 只运行 regexp 匹配的函数,例如 -run=Array 那么就执行包含有 Array 开头的函数;
- -v 显示测试的详细命令
再添加一个被测试方法
func Add(a,b int) int{
return a+b
}
测试代码
func TestAdd(t *testing.T) {
sum := Add(5,5)
if sum == 10 {
t.Log("the result is ok")
} else {
t.Fatal("the result is wrong")
}
}
使用-run来测试,发现只运行了TestAdd测试方法。
D:\goproj\test_study>go test -v -run TestAdd
=== RUN TestAdd
samples_test.go:16: the result is ok
--- PASS: TestAdd (0.00s)
PASS
ok _/D_/goproj/test_study 0.176s
4.看看单元测试覆盖率
写好测试后,可以利用Go自带的工具 test coverage 查看一下单元测试覆盖率
测试覆盖率是一个术语,用于统计通过运行程序包的测试多少代码得到执行。 如果执行测试套件导致80%的语句得到了运行,则测试覆盖率为80%。
来尝试一下吧~
D:\goproj\test_study>go test -cover
PASS
coverage: 66.7% of statements
ok _/D_/goproj/test_study 0.163s
可以看到刚才我们写的测试,覆盖率为66.7%。
再回过头看一下我们的被测试代码,还有一个main()在测试中没有执行到,只执行了Hello()和Add(),所以覆盖率只有66.7%。试着在测试代码中直接加上“main()”的调用。
现在的测试覆盖率就是100%了,并且在PASS之前输出了一句“Hello, world”
D:\goproj\test_study>go test -cover
Hello, world
PASS
coverage: 100.0% of statements
ok _/D_/goproj/test_study 0.162s
5.学习GoConvey测试框架
在前面,我们判断预期结果都是用if…else之类的判断语句,如果面对更庞大的测试,有更多的测试用例,可能需要思考更多逻辑判断,加大代码长度与复杂度,不便于编写与管理。所以我们需要用到更好的测试框架来增强测试编写。
GoConvey是一款针对Golang的测试框架,它可以更好的管理和运行测试用例,而且又很丰富的断言函数,能够写出更完善的测试用例,并且还有web界面,是极其常用的测试框架。
添加一个匹配url的方法
func CheckUrl(url string) bool {
var urlList = [2]string{"learnku.com", "xdcute.com"}
for v := range urlList {
if urlList[v] == url {
return true
}
}
return false
}
测试代码
记得先import "github.com/smartystreets/goconvey/convey"
或者在命令行输入go get github.com/smartystreets/goconvey
func TestCheckUrl(t *testing.T) {
convey.Convey("TestCheckTeachUrl", t, func() {
ok:=CheckUrl("learnku.com")
convey.So(ok,convey.ShouldBeTrue)
})
}
convey.Convey定义了测试用例名称、t指针、测试代码。
convey.So用来判断预期结果。
convey提供了大量的断言函数,比如刚才使用的convey.ShouldBeTrue,就是判断ok的值应该为true。
更多方法请前往GoConvey官方文档查看。
测试结果
D:\goproj\test_study>go test -v -run TestCheckTeachUrl
=== RUN TestCheckTeachUrl
TestCheckTeachUrl .
1 total assertion
--- PASS: TestCheckTeachUrl (0.00s)
PASS
ok _/D_/goproj/test_study 0.187s
因为传入的是正确的url,可以匹配到,所以测试通过。
6.利用GoConvey定义多个测试用例
我们可以定义多个测试用例。
以下分别是输入正确、错误、空值的Url。
func TestCheckUrl(t *testing.T) {
convey.Convey("TestCheckTeachUrl true", t, func() {
ok:=CheckUrl("learnku.com")
convey.So(ok,convey.ShouldBeTrue)
})
convey.Convey("TestCheckTeachUrl false", t, func() {
ok:=CheckUrl("xxxxxx.com")
convey.So(ok,convey.ShouldBeFalse)
})
convey.Convey("TestCheckTeachUrl null", t, func() {
ok:=CheckUrl("")
convey.So(ok,convey.ShouldBeFalse)
})
}
测试结果
三个测试用例都符合我们的预期结果,测试通过。
也可以尝试修改一下错误的预期结果,测试用例就会失败。
D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN TestCheckUrl
TestCheckTeachUrl true .
1 total assertion
TestCheckTeachUrl false .
2 total assertions
TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok _/D_/goproj/test_study 0.197s
以上三个测试用例都是分开执行的,convey是可以嵌套执行的,我们可以更好的将测试用例组织起来。
外层再套一个convey,需要传t指针,里面的convey都不需要t指针。
func TestCheckUrl(t *testing.T) {
convey.Convey("TestCheckTeachUrl", t, func() {
convey.Convey("TestCheckTeachUrl true", func() {
ok := CheckUrl("learnku.com")
convey.So(ok, convey.ShouldBeTrue)
})
convey.Convey("TestCheckTeachUrl false", func() {
ok := CheckUrl("xxxxxx.com")
convey.So(ok, convey.ShouldBeFalse)
})
convey.Convey("TestCheckTeachUrl null",func() {
ok := CheckUrl("")
convey.So(ok, convey.ShouldBeFalse)
})
})
}
看看测试结果,变得非常简短许多了,我比较喜欢这种方式。
D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN TestCheckUrl
TestCheckTeachUrl
TestCheckTeachUrl true .
TestCheckTeachUrl false .
TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok _/D_/goproj/test_study 0.191s
经过以上体验,使用GoConvey确实可以更加快捷的编写和管理测试用例,很棒吧。
7.学习Testify测试框架
Testify也是一个断言库,它的功能相对于GoConvey而言比较简单,主要是在提供断言功能之外,提供了mock的功能。
在使用前请记得import "github.com/stretchr/testify"
或者在命令行输入go get -t github.com/stretchr/testify
用Testify下assert来写测试代码
func TestCheckUrl2(t *testing.T) {
ok := CheckUrl("learnku.com")
assert.True(t, ok)
}
避免文章太长,测试结果就不贴出来了,这个测试用例肯定是PASS的。
8.结合表格驱动测试
结合表格测试可以写多个测试用例。testing本身也可以写表格测试,这里使用Testify演示。
测试代码
func TestCheckUrl3(t *testing.T) {
assert := assert.New(t)
var tests = []struct {
input string
expected bool
}{
{"xdcute.com", true},
{"xxx.com", false},
}
for _, test := range tests {
fmt.Println(test.input)
assert.Equal(CheckUrl(test.input), test.expected)
}
}
这就是关于Testify的快速上手,关于它的mock功能,将在后面引入mock概念后再介绍。
9.使用Gomock框架-模拟接口
特点:
1.基于接口
2.能够与Golang内置的testing包良好集成
我觉得一开始不太好理解它的用法,先来看看这段代码。
目录结构:
test_study
├── db
│ ├── db.go
package main
import (
"fmt"
"log"
)
//定义了一个订单接口,有一个获取名称的方法
type OrderDBI interface {
GetName(orderid int) (string)
}
//定义结构体
type OrderInfo struct {
orderid int
}
//实现接口的方法
func (order OrderInfo) GetName(orderid int) string {
log.Println("原本应该连接数据库去取名称")
return "xdcute"
}
func main() {
//创建接口实例
var orderDBI OrderDBI
orderDBI = new(OrderInfo)
//调用方法,返回名称
ret := orderDBI.GetName(1)
fmt.Println("取到的用户名:",ret)
}
运行这段代码可以得到“取到的用户名:xdcute”。
假设这个GetName是需要连接数据库去取用户名,那我们想针对GetName写测试的话,就要真实连接一个数据库才行,意味着在任何一台电脑上想进行代码测试都必须要依赖数据库。
mock就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试。
意味着我们可以利用gomock来模拟一个假的数据库对象,提前定义好返回内容。
gomock 是官方提供的 mock 框架,同时还提供了 mockgen 工具用来辅助生成测试代码。
通过以下命令安装:
go get -u github.com/golang/mock/gomock
go get -u github.com/golang/mock/mockgen
安装好后,我们使用mockgen来mock前面的db.go
mockgen -source=./db/db.go -destination=./db/db_mock.go -package=main
-source 需要mock的源文件
-destination 生成的mock文件存放目录
-package 所属包
(务必开启Modules管理,不然使用mockgen可能会出现这样的提示Loading input failed: Source directory is outside GOPATH
)
目录结构:
test_study
├── db
│ ├── db.go
│ ├── db_mock.go
命令执行成功后会在目录下生成对应mock文件,点进去查阅一下,代码较长就不贴出来了。
然后会发现里面确实已经自动生成了接口与方法,有一个EXPECT()重点留意一下。
接下来新建一个db_test.go
func TestGetName(t *testing.T) {
//新建一个mockController
ctrl := gomock.NewController(t)
// 断言 DB.GetName() 方法是否被调用
defer ctrl.Finish()
//mock接口
mock := NewMockOrderDBI(ctrl)
//模拟传入值与预期的返回值
mock.EXPECT().GetName(gomock.Eq(1225)).Return("xdcutecute")
//前面定义了传入值与返回值
//在这里
if v := mock.GetName(1225); v != "xdcutecute"{
t.Fatal("expected xdcute, but got", v)
}else{
log.Println("通过mock取到的name:",v)
}
}
Eq(value) 表示与 value 等价的值。
Any() 可以用来表示任意的入参。
Not(value) 用来表示非 value 以外的值。
Nil() 表示 None 值
测试结果:
D:\goproj\test_study\db>go test -run TestGetName
2020/12/25 16:41:00 通过mock取到的name: xdcutecute
PASS
ok test_study/db 0.174s
可以看到测试通过了,与我们预期的值相符。是不是突然Get到了GoMock的好处~
mock工具的作用是指定函数的行为(模拟函数的行为)。可以对入参进行校验,对出参进行设定,还可以指定函数的返回值。
10.最简单的打桩(stubs)
像第九节这种模拟接口调用方法,有明确的参数值与返回值,就是最简单的打桩。
桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数B用B1来代替,那么,B称为原函数,B1称为桩函数。打桩就是编写或生成桩代码。
下一节会展现更多场景的打桩。
11.使用GoStub框架-变量、函数、过程打桩
记得使用该命令安装噢 go get github.com/prashantv/gostub
GoStub框架的使用场景很多,依次为:
- 基本场景:为一个全局变量打桩
- 基本场景:为一个函数打桩
- 基本场景:为一个过程打桩
- 复合场景:由任意相同或不同的基本场景组合而成
全局变量:
var str="xdcute.com"
func main() {
stubs := Stub(&str, "learnku")
defer stubs.Reset()
fmt.Println(str)
// 可以多次打桩
stubs.Stub(&str, "xdcute")
fmt.Println(str)
}
//输出
//learnku
//xdcute
stubs是GoStub框架的函数接口Stub返回的对象,Reset方法将全局变量的值恢复为原值。
不论是调用Stub函数还是StubFunc函数,都会生成一个Stubs对象,Stubs对象仍然有Stub方法和StubFunc方法,所以在一个测试用例中可以同时对多个全局变量、函数或过程打桩。全局变量、函数或过程会将初始值存在一个map中,并在延迟语句中通过Reset方法统一做回滚处理。
函数:(针对有参数,有返回值的写法,使用Stub())
var printStr = func(val string) string {
return val
}
stubs := Stub(&printStr, func(val string) string {
return "hello," + val
})
defer stubs.Reset()
fmt.Println("After stub:", printStr("xdcute"))
//输出
//After stub: hello,xdcute
针对无参数,有返回值的函数打桩,可以使用StubFunc()
var printStr = func(val string) string {
return val
}
// StubFunc 第一个参数必须是一个函数变量的指针,该指针指向的必须是一个函数变量,第二个参数为函数 mock 的返回值
stubs := StubFunc(&printStr, "xdcute,万生世代")
defer stubs.Reset()
fmt.Println("After stub:", printStr("lalala"))
//输出
//After stub: xdcute,万生世代
通过StubFunc()已经设置了mock固定返回值。
过程:
没有返回值的函数称为过程。
var PrintStr = printStr
var printStr = func(val string) {
fmt.Println(val)
}
func main() {
stubs := StubFunc(&printStr)
PrintStr("xdcute")
defer stubs.Reset()
}
//输出
//xdcute
12.HttpMock-模拟http请求
在web项目中,大多接口是处理http请求(post、get之类的),可以利用官方自带的http包来进行模拟请求。
假如有一个HttpGetWithTimeOut方法,内部逻辑会有一个get请求,最后返回内容。我们在测试环境中,是访问不到它发起的get请求的url的,此时就可以模拟http请求来写测试。
代码示例:
func TestHttpGetWithTimeOut(t *testing.T) {
Convey("TestHttpGetWithTimeOut", t, func() {
Convey("TestHttpGetWithTimeOut normal", func() {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("TestHttpGetWithTimeOut success!!"))
if r.Method != "GET" {
t.Errorf("Except 'Get' got '%s'", r.Method)
}
if r.URL.EscapedPath() != "/要访问的url" {
t.Errorf("Expected request to '/要访问的url', got '%s'", r.URL.EscapedPath())
}
}))
api := ts.URL
defer ts.Close()
var header = make(map[string]string)
HttpGetWithTimeOut(api, header, 30)
})
httptest.NewServer():创建一个http请求
http.ResponseWriter:响应体
http.Request:请求体
这段代码中,通过w来设置返回的头内容与写入内容,通过r来设置请求方法和请求的url。
最后将模拟好的请求,传参对应方法。
13.SqlMock-模拟数据库请求
引用命令 go get -t https://github.com/DATA-DOG/go-sqlmock
特点:
1.模拟任何实现了sql/driver接口的db驱动,无需关注db连接。
使用参考:
构建模拟sql
db, mock, err = sqlmock.New() // mock sql.DB
defer db.Close()
执行查询语句
mock.ExpectQuery(sqlSelectAll).WillReturnRows(sqlmock.NewRows(nil))
有更多调用方法请查看官方文档
14.GoMonkey-强大的打桩框架
特点:
1.直接在方法级别上进行mock
(在运行时通过汇编语句重写可执行文件,将待打桩函数或方法的实现跳转到桩实现)
在编译阶段直接替换掉真的函数代码部分
2.非线程安全,请勿用于并发测试
使用:PatchInstanceMethod()
对于方法
在使用前,先要定义一个目标类的指针变量x
第一个参数是reflect.TypeOf(x)
第二个参数是字符串形式的函数名
返回值是一个PatchGuard对象指针,主要用于在测试结束时删除当前的补丁
var e *Etcd
guard := PatchInstanceMethod(reflect.TypeOf(e), "Get", func(_ *Etcd, _ string) []string {
return []string{"task1", "task5", "task8"}
})
defer guard.Unpatch()
Patch()
对于过程
当一个函数没有返回值时,该函数我们一般称为过程。很多时候,我们将资源清理类函数定义为过程。
guard := Patch(DestroyResource, func(_ string) {
})
defer guard.Unpatch()
对于函数
第一个参数是目标函数的函数名
第二个参数是桩函数的函数名,习惯用法是匿名函数或闭包
返回值是一个PatchGuard对象指针,主要用于在测试结束时删除当前的补丁
func TestExec(t *testing.T) {
Convey("test has digit", t, func() {
Convey("for succ", func() {
outputExpect := "xxx-vethName100-yyy"
guard := Patch(osencap.Exec, func(_ string, _ ...string) (string, error) {
return outputExpect, nil
})
defer guard.Unpatch()
output, err := osencap.Exec(any, any)
So(output, ShouldEqual, outputExpect)
So(err, ShouldBeNil)
})
})
}
15.测试风格介绍
TDD(Test Drive Development)测试驱动开发
它的基本思想就是在开发功能代码之前,先编写测试代码。再通过开发功能来满足测试代码的通过,一旦出bug就需要修复重构。以此循环,来保证功能开发的完备性。
这样的好处是能够编写出足够健壮的代码,但前提是需要确保所有的需求在测试用例中都被照顾到,而且测试用例需要尽可能覆盖分支和行。
简单来说,不可运行/可运行/重构——这正是测试驱动开发的口号,也是 TDD 的核心。在这个闭环中,每一个阶段的输出都会成为下一阶段的输入。
- 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
- 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
- 重构——消除刚刚编码过程引入的重复设计,优化设计结构。
参考文章:测试驱动开发(TDD)总结——原理篇
BDD(Behavior Driven Development)行为驱动开发
它是在软件开发前,需要给出用例和预期,促使项目中人员沟通。
产品或者项目人员都参与进来,加强沟通,写出更符合产品需求预期的用例。只不过用例不再局限于一个函数或者类型,而是更高层面。
参考文章:Go项目中的BDD实践
16.总结
关于单元测试,经过一段时间的理论结合实践的学习,熟悉了Golang中单元测试的使用以及加深了单元测试对于项目的重要性。
在为不同的接口写测试时,能够快速熟悉项目,并且发现原有代码中可能存在的一些需要改进的地方。例如有 些方法,内部逻辑没有问题,但是缺少对于空值、非法值的处理。此时可以在该方法中添加注释,提醒开发人员可以进行改进,或者主动联系开发人员沟通,自己直接优化代码。
利用单元测试,可以很好的熟悉项目、发现可能存在的问题、发现可以优化的部分。bug发现的越晚,修改它所需要的成本就越高,所以应该尽可能早地查找和修改bug
重视单元测试后你将会发现单元测试带来的好处远远不止这些~
今后我也会继续学习如何更好的写测试,加强理论知识的实践,继续提升代码能力。
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: