导语 | 单元测试,通常是单独测试一个方法、类或函数,让开发者确信自己的代码在按预期运行,为确保代码可以测试且测试易于维护。腾讯后台开发工程师张力结合了公司级漏洞扫描系统洞犀在DevOps上探索的经验,以Golang为例,列举了编写单元测试需要的工具和方法,然后针对写单测遇到的各种依赖问题,详细介绍了通过Mock的方式解决各种常用依赖,方便读者在写go语言UT的时候,遇到依赖问题,能够快速找到解决方案。最后再和大家探讨一下关于单元测试上的一些思考。
一、前言
本文结合了公司级漏洞扫描系统洞犀在DevOps上探索的经验,以Golang为例,列举了编写单元测试需要的工具和方法,然后针对写单测遇到的各种依赖问题,提出相应的解决办法,并展示了自动化单元测试的结果。最后再和大家探讨一下关于单元测试上的一些思考。
二、测试工具与方法
// 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)
}
但用assert包只需要一行代码就可以实现上述功能,而且可以输出具体错误代码行:assert.Nil(t, err)。另外还有封装了testing的测试框架https://github.com/smartystreets/goconvey,里面包含了子测试断言等功能。
2.表格驱动测试
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 {
t.Run(tt.name, func(t *testing.T) {
got, err := twice(tt.args.i)
if (err != nil) != tt.wantErr {
t.Errorf("twice() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("twice() got = %v, want %v", got, tt.want)
}
})
}
}
上面还用到了go test的子测试功能t.Run(name string, subTest func(t *T))。如果想在一个测试函数里面执行多个测试用例,例如要同时测试一个函数的返回成功和失败等各种情况,那么可以使用子测试来区分不同情况。
另外,上面表格测试代码框架是用Goland自动生成的,自己只需要填写tests数组就行了。点击函数名然后右键,选择generate,然后选择test for function就会自动生成测试函数了。不过上面生成的函数没有校验返回的错误内容,如有需要可以自己稍微修改一下。
三、解决常见的依赖等问题
四、访问 http 接口
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
writer.Write([]byte(`{"code":0,"msg":"ok"}`))
})
接着启动这个服务,httptest会真的在localhost启动它,然后这个URL就是要访问的服务地址了。
server := httptest.NewServer(mux)
defer server.Close()
url := server.URL
五、替换函数或方法
dbNewMock := ngmock.MockFunc(t, db.New)
defer dbNewMock.Finish()
然后在执行被测函数之前,设置mock函数接收什么参数,并且要返回什么,比如下面指定接收一个任意参数并且让db.New返回指定错误。该设置默认只会生效一次,如果要生效多次或者一直生效可以配置次数。
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") // 验证是否生效
})
})
}
可以看到,mock依赖函数之后执行被测函数,会返回我们设置的错误fake error,在调用完成获得返回错误之后可以判断一下是否是我们设置的错误。还可以mock结构体方法,使用方式和上面类似,第二个参数传结构体或者指针,第三个是mock模式:
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
可以执行mockgen来生成上述接口,具体命令如上,-destination指定生成文件名,-package是生成文件包名,-self_package指定生成的包路径,-source就是源接口文件路径名。如果最后不指定接口名的话,会生成所有接口或者可以指定要生成的接口,多个用逗号连接。 当然也可以读取标准库的接口:mockgen database/sql/driver Conn,Driver桩代码生成好了之后,就可以调用代码里类似 NewMockXXXX(ctrl)方法来创建mock对象,如下所示,这样创建的encoderMock实现了上面的Encoder接口,接下来就用这个encoderMock来初始化被测函数依赖的接口即可。
ctrl := gomock.NewController(t) // *testing.T
defer ctrl.Finish()
// mockdata 是上面生成的桩代码目录
encoderMock := mockdata.NewMockEncoder(ctrl)
在调用被测函数之前,需要先打桩:我们希望如果encoderMock在执行Encode方法时传入会两个指定参数,那么就执行指定的函数并返回:
codecMock.EXPECT().Encode(gomock.Any(), gomock.Any()).DoAndReturn(func(obj interface{}, w io.Writer) error {
w.Write([]byte("test_data"))
return nil
})
接下来执行被测函数,当实际调用到Encode方法时,就会执行我们设置的函数。看起来和上面一节的替换函数和方法类似是吧?这种希望当调用函数Encode()并且参数一致,那么就执行指定逻辑的方式,就是打桩(stub)。打桩过程还可以配置执行次数和执行顺序等,如果不知道打桩函数具体会被传入什么参数可以用gomock.Any()来代替。通过打桩可以控制依赖接口的行为,解决测试时接口依赖的问题。
七、mysql 数据库依赖
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 依赖
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)
九、执行测试用例前后设置
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 ]")
}
}
十、忽略指定目录
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.单测的意义
2.不能为了单测而单测
单元测试覆盖率高真的可以确保质量吗?是否能消除BUG?这个按我个人经验其实是不能完全保证的。首先得考虑单测覆盖代码分支是否完备?有时候为了偷懒只测了主路径,对于其他负路径等没有测试,那么肯定会有问题的。其次测试环境和线上实际环境的潜在差异可能也会导致代码BUG没测试出来。我遇到过在写打桩代码的时候,懒得校验参数,直接用mock.Any代替,导致做集成测试的时候发现参数传错了,写这种单测除了浪费时间之外基本上也发现不了什么问题。
3.有没有更好折中方案
有时候函数逻辑比较复杂导致插桩过程繁琐,或者有些依赖不方便 mock,那么是否能在执行测试用例的时候创建一个本地测试环境,里面包含了各种依赖,这样或许会方便很多。比如上一节介绍解决依赖的办法里有提到为了解决DB依赖,可以临时创建一个sqlite数据库,或者启动一个容器来模拟执行环境。
作者简介
张力
腾讯后台开发工程师,负责高危服务扫描系统建设。
推荐阅读
燃爆盛夏 | 腾讯极客挑战赛正式开启!
你一定听过这些不太标准的技术圈发音...
系统如何设计才能更快地查询到数据?
前以色列国防军安全技术成员教你做好 Serverless 追踪
替代Docker,登上顶刊,这款开源沙箱牛在哪里?