一文说尽Golang单元测试实战的那些事儿
导语 | 单元测试,通常是单独测试一个方法、类或函数,让开发者确信自己的代码在按预期运行,为确保代码可以测试且测试易于维护。腾讯后台开发工程师张力结合了公司级漏洞扫描系统洞犀在DevOps上探索的经验,以Golang为例,列举了编写单元测试需要的工具和方法,然后针对写单测遇到的各种依赖问题,详细介绍了通过Mock的方式解决各种常用依赖,方便读者在写go语言UT的时候,遇到依赖问题,能够快速找到解决方案。最后再和大家探讨一下关于单元测试上的一些思考。
一、前言
单元测试,通常是单独测试一个方法、类或函数,让开发者确信自己的代码在按预期运行,为确保代码可以测试且测试易于维护。另一方面,DevOps里提倡自动化测试,并且主张越早发现代价越小。关于单元测试的更多思考,可以看看本文最后一节。
本文结合了公司级漏洞扫描系统洞犀在DevOps上探索的经验,以Golang为例,列举了编写单元测试需要的工具和方法,然后针对写单测遇到的各种依赖问题,提出相应的解决办法,并展示了自动化单元测试的结果。最后再和大家探讨一下关于单元测试上的一些思考。
二、测试工具与方法
1.测试框架
相信大家都熟悉go内置了go test测试框架来执行和管理测试用例。通过文件名_test.go结尾来表示测试文件,通过函数以Test开头并只有一个参数*testing.T来表示一个测试函数。例如:
// sample_test.go
package sample_test
import ( "testing" )
func TestDownload(t *testing.T) {}
func TestUpload(t *testing.T) {}
而其中测试框架testing的类型*T提供了一系列方法,例如主要会用到的下面三个方法:
t.Fatal:会让测试函数立刻返回错误
t.Error:会输出错误并记录失败,但任然会继续运行
t.Log:输出 debug 信息,go test -v参数下有效
除此之外,还有其他用的比较多的测试包。例如断言包"github.com/stretchr/testify/assert",比如如果想判断返回的错误是否是空,如果用原生方法会是:
if err != nil
t.Errorf("got error %v", err)
}
表格驱动测试通过定义一组不同的输入,可以让代码得到充分的测试,同时也能有效地测试负路径。 例如下面函数会判断参数类型,如果是int就乘以二,如果是string就先转成int然后乘以二,如果是其他类型就返回错误:
func twice(i interface{}) (int, error) {
switch v := i.(type) {
case int:
return v * 2, nil
case string:
value, err := strconv.Atoi(v)
if err != nil {
return 0, errors.Wrapf(err, "invalid string num %s", v)
}
return value * 2, nil
default:
return 0, errors.New("unknown type")
}
}
func Test_twice(t *testing.T) {
type args struct {
i interface{}
}
tests := []struct {
name string
args args
want int
wantErr bool
}{
{
name: "int",
args: args{i: 10},
want: 20,
},
{
name: "string success",
args: args{i: "11"},
want: 22,
},
{
name: "string failed",
args: args{i: "aaa"},
wantErr: true,
},
{
name: "unknown type",
args: args{i: []byte("1")},
wantErr: true,
},
}
for _, tt := range tests {
func(t *testing.T) {
err := twice(tt.args.i)
if (err != nil) != tt.wantErr {
error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
got = %v, want %v", got, tt.want)
}
})
}
}
三、解决常见的依赖等问题
解决常见的依赖等问题目前有两种思路:
通过mock方式替换实际依赖,并通过打桩操作其返回内容。
通过本地启动一个模拟依赖环境,比如模拟redis服务等,然后直接访问模拟服务。
下面几小节详细介绍了上述两种办法在不通场景下的应用,其中替换函数或方法、依赖接口类型和mysql数据库依赖对应了第一种思路;访问访问http接口、mysql数据库依赖和redis依赖对应了上面第二条思路。
四、访问 http 接口
代码里经常会遇到要访问http接口的情况,这时如果在测试代码里不做处理直接访问,可能遇到环境不同访问不通等问题。为此go标准库内置了专门用于测试http服务的包net/http/httptest,不过我们这里并不用它来测试http服务,而是用来模拟要请求的http服务。
基本流程是先创建一个路由器,然后注册一个响应函数用来模拟要请求的服务:
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
writer.Write([]byte(`{"code":0,"msg":"ok"}`))
})
server := httptest.NewServer(mux)
defer server.Close()
url := server.URL
五、替换函数或方法
大家用的最多的应该就是monkey补丁库了,可以用它来替换各种函数和方法,使用起来非常方便,这类库原理大致相同,通过运行时用unsafe包替换函数地址来实现。比如https://github.com/agiledragon/gomon
key,不过这次我们用公司内部同源测试团队封装的monkey库来演示ngmock。
首先是替换函数,新建一个函数mock对象,参数有*testing.T和要mock的函数。比如被测函数需要调用db.New新建一个DB,那么下面就mock了db.New函数。
dbNewMock := ngmock.MockFunc(t, db.New)
defer dbNewMock.Finish()
dbNewMock.Mock(ngmock.Any()).Return(nil, errors.New("fake err")).AnyTimes()
接下来就是执行被测函数函数来验证是否生效了,这里用到了上面提到的另一个测试框架convey,convey.Convey同*T.Run(),convey.So是 assert。
func TestNewDBs(t *testing.T) {
convey.Convey("TestNewDBs", t, func() {
dbNewMock := ngmock.MockFunc(t, db.New)
defer dbNewMock.Finish()
convey.Convey("TestNewDBs failed", func() {
dbNewMock.Mock(ngmock.Any()).Return(nil, errors.New("fake err")).AnyTimes()
dbs, err := NewDBs(DbUrl{}) // 执行被测函数
convey.So(dbs, convey.ShouldResemble, &DBs{})
convey.So(err, convey.ShouldNotBeNil)
convey.So(err.Error(), convey.ShouldEqual, "fake err") // 验证是否生效
})
})
}
execCmdMock := ngmock.MockStruct(t, exec.Cmd{}, ngmock.Silent)
defer execCmdMock.Finish()
mock模式主要有两种:
Silent结构体内没有mock的方法返回类型默认值,调用没有mock的方法不会报错,最后对调用方法的统计不会报错。
KeepOrigin结构体内没有mock的方法按照原方法逻辑返回数据,调用没有mock的方法不会报错,最后对调用方法的统计不会报错。
在mock方法时,需要指定方法名,比如下面就mock了该结构体的Output方法,方法如果有参数的话,可以在后面加上参数。其他的就和前面一样了。
execCmdMock.Mock("Output").Return([]byte("1"), nil)
如果在MacOS上执行测试遇到了permission denied的错误,这是 MacOS保护机制导致的,具体解决办法见https://github.com/eisenx
p/macos-golink-wrapper 。
六、依赖接口类型
如果依赖的数据是接口类型,那么可以很方便的通过依赖注入的方式传入测试用的接口实现来替换原始依赖。go 官方出品的gomock 可以根据接口定义自动生成相应实现的mock桩代码:https://github.com/golang/
mock。gomock库会有个二进制文件mockgen用来生成代码, 比如文件里有一些接口定义:
// interfaces.go
// Encoder 编码器
type Encoder interface {
Encode(obj interface{}, w io.Writer) error
}
//go:generate mockgen -destination=./mockdata/interfaces_mock.go -package=mockdata -self_package=./mockdata -source=interfaces.go
ctrl := gomock.NewController(t) // *testing.T
defer ctrl.Finish()
// mockdata 是上面生成的桩代码目录
encoderMock := mockdata.NewMockEncoder(ctrl)
codecMock.EXPECT().Encode(gomock.Any(), gomock.Any()).DoAndReturn(func(obj interface{}, w io.Writer) error {
w.Write([]byte("test_data"))
return nil
})
七、mysql 数据库依赖
数据库依赖也是经常要遇到的一个问题,如何解决测试过程中的依赖呢?我这里总结了两种办法: 首先是sqlmock:https://github.com/DATA-DOG/go-sqlmock。看到mock字眼大家大概也知道它是怎么使用的了,也是通过对执行sql语句打桩来完成测试。首先初始化mock对象,返回第一个是*sql.DB,用来传给被测代码依赖的db,第二个就是mock对象,用来设置打桩代码。控制sqlDB的行为。
sqlDB, dbMock, err := sqlmock.New()
具体使用项目文档里有,我这里简单说一下:比如下面一个函数执行一些sql语句,先调用Begin创建事务,然后分别Query和Exec执行sql,最后如果返回错误则Rollback否则Commit。
func testFunc(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
rows, err := tx.Query("select * from test where id > 10")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
// 省略
}
if _, err := tx.Exec("update test set num = num +1"); err != nil {
return err
}
return nil
}
那么针对上面函数,编写测试用例如下。其中打桩代码按照上面顺序,希望先执行Begin;然后执行Query,并且希望sql语句满足正则select .* from test并返回两行结果;然后执行Exec,希望 sql 满足正则update test并返回错误;最后执行Rollback。接下来执行被测函数,如果被测函数按照打桩代码的顺序执行相应sql的话就会返回指定内容,否则就会报错。
func Test_testFunc(t *testing.T) {
convey.Convey("Test_testFunc exec failed", t, func() {
sqlDB, dbMock, err := sqlmock.New()
convey.So(err, convey.ShouldBeNil)
dbMock.ExpectBegin()
// sql支持正则匹配
dbMock.ExpectQuery("select .* from test").
WillReturnRows(sqlmock.NewRows([]string{"id", "num"}).
AddRow(1, 10).AddRow(2, 20))
dbMock.ExpectExec("update test").WillReturnError(errors.New("fake error"))
dbMock.ExpectRollback()
err = testFunc(sqlDB) // 执行被测函数
convey.So(err, convey.ShouldNotBeNil)
convey.So(err.Error(), convey.ShouldEqual, "fake error") // 验证打桩是否生效
// 确认所有打桩都被调用
convey.So(dbMock.ExpectationsWereMet(), convey.ShouldBeNil)
})
}
有时候我们的代码不会直接使用*sql.DB,而是用到一些第三方 ORM 框架,那么需要想办法让这些框架使用我们的 mock db,比如对于 gorm 框架,可以这么配置:
sqlDB, dbMock, err := sqlmock.New()
// "gorm.io/driver/mysql"
// "gorm.io/gorm"
db, err = gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{})
谈到gorm框架,那么问题来了,如果我不直接操作*sql.DB而是用的框架,但我不知道最后生成的sql是什么那该怎么办?或者说被测函数有一堆sql语句,一个一个打桩起来实在是太麻烦。那么对于这种情况如果能有一个本地数据库环境就好了,省去了打桩的麻烦,但是如果是mysql这种DB的话,本地建一个最快也是用容器跑才行。那么有没有更轻量化的办法呢?
可以本地临时创建一个sqlite数据库来代替当前依赖的数据库比如mysql等,sqlite是可以在本地直接跑的轻量级数据库,常见sql语句增删改查什么的和mysql区别不大。不过需要注意的是目前所有的go sqlite驱动都是基于CGO的,因为sqlite使用C写的。所以引用这些驱动会导致测试前程序编译速度变慢和跨平台支持问题,不过目前测试在MacOS和linux上是没有问题的。
如下所示首先创建一个临时的sqlite gorm框架DB,其中连接地址置空,这样在关闭db之后数据库也会自动删除。之后就可以正常使用了。它底层使用的是这个驱动github.com/mattn/go-sqlite3。
import(
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
db, err := gorm.Open(sqlite.Open(""), &gorm.Config{})
如果使用场景只是增删改查什么的,问题不会很大,我目前遇到的和 mysql 不兼容的就是create table a like b这种 sql。而且如果不直接执行 sql 而用框架取调用相关函数的话,兼容性会好很多。
八、redis 依赖
很多项目还会依赖redis数据库,那么这种怎么解决依赖问题呢?可以使用miniredis库解决问题:https://github.com/alicebob/miniredis 。
miniredis是一个纯GO写的测试用的redis服务,它支持绝大多数redis命令,具体可以看项目介绍。使用起来很简单,直接调用Run函数启动一个测试服务,服务对象的Addr()方法返回服务连接地址。接下来可以就可以拿着这个地址替换当前依赖了。
import "github.com/alicebob/miniredis/v2"
import "github.com/go-redis/redis/v8"
mr, err := miniredis.Run()
addr := mr.Addr() // redis服务的tcp连接地址
// 比如创建一个客户端
opt, err := redis.ParseURL("redis://:@" + mr.Addr())
cli := redis.NewClient(opt)
九、执行测试用例前后设置
有时需要在测试之前或之后进行额外的设置(setup)或拆卸(teardown),为此testing包提供了TestMain函数作为测试文件的入口。如下所示,该文件的测试用例都会在m.Run里运行,如果成功返回0否则非零,因此可以判断执行是否成功。值得注意的是最后应该使用code作为os.Exit参数退出。
func TestMain(m *testing.M) {
code := m.Run()
if code == 0 {
TearDone(true)
} else {
TearDone(false)
}
os.Exit(code)
}
func TearDone(isSuccess bool) {
fmt.Println("Global test environment tear-down")
if isSuccess {
fmt.Println("[ PASSED ]")
} else {
fmt.Println("[ FAILED ]")
}
}
十、忽略指定目录
有时需要忽略指定目录,例如自动生成的桩代码,proto文件等,以提高覆盖率,那么对于下面的测试命令:go test -v -covermode=count -coverprofile=coverage_unit.out '-gcflags=all=-N -l' ./... 如果要忽略掉mockdata目录的话,后面加上grep -v mockdata即可:
go test -v -covermode=count -coverprofile=coverage_unit.out '-gcflags=all=-N -l' `go list ./... | grep -v /mockdata`
然后可以运行go tool cover -html=coverage_unit.out -o cover.html,生成网页版报告,查看覆盖率情况。当然还有一个比较tricky的方法,如果生成的桩代码仅限于某个包内使用,那么直接把桩代码文件名改成_test.go后缀的就行了。
十一、关于单元测试的思考
1.单测的意义
首先必须承认有了单元测试之后,增加了代码质量的保障。而且在做修改和重构的时候,也能降低心智负担,相信大家都体验过对一堆没有单测的代码做修改时心里都会有点打怵,生怕改出什么问题。
但是对于没有单元测试的人来说,刚开始写单测无疑是让人非常头大,简直寸步难行。因为已经维护的代码可能在设计上就很难测试,各种耦合各种依赖没有抽象混在一起,一行代码成百上千行,这些都加深了接入单元测试的难度和工作量。而由于没有质量保证又不敢动这些祖传代码,从而导致陷入死循环。
但总得想办法改变现状,最近看了公司内部技术论坛的测试专题,之后也是跟各路大神学习到了一些东西。首先可以先让重要逻辑代码有测试。其次就是关注代码设计问题,对新增代码坚持写单侧,我在码客上看到有前辈说,**UT 不是用来找BUG的,而是通过UT来改良设计,从而提升代码质量,降低BUG数量。**反之如果UT不好写,说明代码结构混乱,出现BUG的概率也变高。
2.不能为了单测而单测
单元测试覆盖率高真的可以确保质量吗?是否能消除BUG?这个按我个人经验其实是不能完全保证的。首先得考虑单测覆盖代码分支是否完备?有时候为了偷懒只测了主路径,对于其他负路径等没有测试,那么肯定会有问题的。其次测试环境和线上实际环境的潜在差异可能也会导致代码BUG没测试出来。我遇到过在写打桩代码的时候,懒得校验参数,直接用mock.Any代替,导致做集成测试的时候发现参数传错了,写这种单测除了浪费时间之外基本上也发现不了什么问题。
3.有没有更好折中方案
有时候函数逻辑比较复杂导致插桩过程繁琐,或者有些依赖不方便 mock,那么是否能在执行测试用例的时候创建一个本地测试环境,里面包含了各种依赖,这样或许会方便很多。比如上一节介绍解决依赖的办法里有提到为了解决DB依赖,可以临时创建一个sqlite数据库,或者启动一个容器来模拟执行环境。
作者简介
张力
腾讯后台开发工程师,负责高危服务扫描系统建设。
推荐阅读