Golang高质量单测之Table-Driven:从入门到真香!
导语 | 一个开发人员,如何能自觉自愿写单测?那必然是相信收益>成本、单测节省的未来修bug时间>写单测所花费的时间。为了保证上述不等式成立,这边建议您考虑table-driven方法,快速、无痛写出高质量单测,以降低“我要写单测”这事的心理门槛,最终达到信手拈来、一直写一直爽的神奇效果。
一、什么是table-driven
表驱动法(Table-Driven Approach)这个概念,并不是Golang或者测试领域独有的;它是个编程模式,属于数据驱动编程的一种。
表驱动法的核心在于:把易变的数据部分,从稳定的处理数据的流程里分离,放进表里;而不是直接混杂在if-else/switch-case的多个分支里。
简单举例:写一个func,输入第index天,输出这天是星期几。假如一周只有两三天,那么直接用if-else/switch-case,倒也ok。
但如果一周有七天,这代码就有些离谱了:
// GetWeekDay returns the week day name of a week day index.
func GetWeekDay(index int) string {
if index == 0 {
return "Sunday"
}
if index == 1 {
return "Monday"
}
if index == 2 {
return "Tuesday"
}
if index == 3 {
return "Wednesday"
}
if index == 4 {
return "Thursday"
}
if index == 5 {
return "Friday"
}
if index == 6 {
return "Saturday"
}
return "Unknown"
}
显然,控制流程的逻辑并不复杂,是个简单粗暴的映射(0->Sunday,1-> Monday……);分支与分支之间的唯一区别,在于可变的数据,而不是流程本身。
那如果把数据拆分出来,放入表的多个行里(表一般用数组实现;数组的一项即是表的一行),将大量的重复流程消消乐,代码就简洁很多:
// GetWeekDay returns the week day name of a week day index.
func GetWeekDay(index int) string {
if index < 0 || index > 6 {
return "Unknown"
}
weekDays := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
return weekDays[index]
}
把这套方法搬到单测领域,也是如此。
一个测试用例,一般包括以下部分:
稳定的流程
定义测试用例
定义输入数据和期望的输出数据
跑测试用例,拿到实际输出
比较期望输出和实际输出
易变的数据
输入的数据
期望的输出数据
而table-driven单测法,就是将流程沉淀为一个可复用的模板、并交由机器自动生成;人类则只需要准备数据部分,将自己的多条不同的数据一行行填充到表里,交给流程模板去构造子测试用例、查表、跑数据、比对结果,写单测这事就大功告成了。
二、为啥单测要table-driven?
在了解了table-driven的概念后,你多半能预见到,table-driven单测可带来以下好处:
写得快:人类只需准备数据,无需构造流程。
可读性强:将数据构造成表,结构更清晰,一行一行的数据变化对比分明。
子测试用例互相独立:每条数据是表里的一行,被流程模板构造成一个独立的子测试用例。
可调试性强:因为每行数据被构造成子测试用例,可以单独跑、单独调试。
可扩展/可维护性强:改一个子测试用例,就是改表里的一行数据。
接下来,通过举例对比TestGetWeekDay的不同单测风格,就能愈发看出 table-driven的好处。
例子一:低质量单测之平铺多个test case
从0->Sunday、1->Monday……到6->Saturday,给每条数据都写一个单独的test case:
// test case for index=0
func TestGetWeekDay_Sunday(t *testing.T) {
index := 0
want := "Sunday"
if got := GetWeekDay(index); got != want {
t.Errorf("GetWeekDay() = %v, want %v", got, want)
}
}
// test case for index=1
func TestGetWeekDay_Monday(t *testing.T) {
index := 1
want := "Monday"
if got := GetWeekDay(index); got != want {
t.Errorf("GetWeekDay() = %v, want %v", got, want)
}
}
...
一眼望去,重复代码太多,可维护性差;另外,这些针对同一个方法的test case,被拆成并列的多个,跟其他方法的test case放在同一文件里平铺的话,缺乏结构化的组织,可读性差。
例子二:低质量单测之平铺多个subtest
实际上,从Go 1.7开始,一个test case里可以有多个子测试(subtest),这些子测试用t.Run方法创建:
func TestGetWeekDay(t *testing.T) {
// a subtest named "index=0"
t.Run("index=0", func(t *testing.T) {
index := 0
want := "Sunday"
if got := GetWeekDay(index); got != want {
t.Errorf("GetWeekDay() = %v, want %v", got, want)
}
})
// a subtest named "index=1"
t.Run("index=1", func(t *testing.T) {
index := 1
want := "Monday"
if got := GetWeekDay(index); got != want {
t.Errorf("GetWeekDay() = %v, want %v", got, want)
}
})
...
}
比第一个例子简洁一些,并且子测试之间仍相互独立,可单独跑、单独调试。如图,在IDE里(我本地是GoLand 2021.3),可以单独run/debug每个subtest:
go test的log,也支持结构化输出subtest运行结果:
但是,当subtest很多的时候,仍然要手写很多重复的流程代码,比较臃肿,也不好维护。
例子三:高质量单测之table-driven
要生成table-driven单测模板非常简单,只需在GoLand里右键方法名>Generate>Test for function:
GoLand会自动生成如下模板,而我们只需填充红框部分,也即最核心的,用于驱动单测的数据表:
不难看出,这个模板在例子二的基础上,继续削减重复代码,不再平铺subtest,而是将公共流程放入一个循环,用数据表中的多行数据驱动循环遍历,并为每行数据构造一个subtest跑一遍。
所以,只需在上图的红框里,以表的形式填充数据,这个test case就写好了:
每行数据被t.Run构造出了一个独立的subtest,能被单独run/debug:
也能被go test打印出结构化的log:
三、怎么写table-driven单测?
其实,在上述例子三里,已经能看出table-driven单测的基本写法:
数据表里的每一行数据,一般包含:subtest的名字、输入、期望的输出。
填充好的代码如下:
func TestGetWeekDay(t *testing.T) {
type args struct {
index int
}
tests := []struct {
name string
args args
want string
}{
{name: "index=0", args: args{index: 0}, want: "Sunday"},
{name: "index=1", args: args{index: 1}, want: "Monday"},
{name: "index=2", args: args{index: 2}, want: "Tuesday"},
{name: "index=3", args: args{index: 3}, want: "Wednesday"},
{name: "index=4", args: args{index: 4}, want: "Thursday"},
{name: "index=5", args: args{index: 5}, want: "Friday"},
{name: "index=6", args: args{index: 6}, want: "Saturday"},
{name: "index=-1", args: args{index: -1}, want: "Unknown"},
{name: "index=8", args: args{index: 8}, want: "Unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetWeekDay(tt.args.index); got != tt.want {
t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)
}
})
}
}
注意给每行子测试一个有意义的name,作为它的标识。否则,自己测的时候可读性差不说,GoLand的单独测试也不认识它了:
四、高阶玩法
(一)table-driven+parallel
默认情况下,一个测试用例的所有subtests是串行执行的。如果需要并行,则要在t.Run里显式地写明t.Parallel,才能使这个subtest与其他带t.Parallel的subtets一起并行执行:
for _, tt := range tests {
tt := tt // 新变量 tt
t.Run(tt.name, func (t *testing.T) {
t.Parallel() // 并行测试
t.Logf("name: %s; args: %d; want: %s", tt.name, tt.args.index, tt.want)
if got := GetWeekDay(tt.args.index); got != tt.want {
t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)
}
})
}
此处需注意,在循环内,多加了一句tt:=tt。如果不加它,将会掉进Go语言循环变量的一个经典大坑。这是因为:
for循环迭代器的变量tt,是被每次循环所共用的。也即,tt一直是同一个 tt;每次循环只改变了tt的值,而地址和变量名一直没变。
每个加了t.Parallel的subtest,被传给自己的go routine后不会马上执行,而是会暂停,等待与其并行的所有subtest都初始化完成。
那么,当Go调度器真正开始执行所有subtest的时候,外面的for循环已经跑完了;其迭代器变量tt的值,已经拿到了循环的最后一个值。
于是,所有subtest的go routine都拿到了同一个tt值,也即循环的最后一个值。
最坑的是,如果你不打印一些log,还发现不了这个问题,因为虽然每次循环都在检查最后一组输入输出,但如果这组值是能pass的,那么所有测试全部能pass,暴露不了问题:
为了解决这个问题,最常用的方法,就是上述代码里的tt:=tt,也即,每次循环的代码块内部,都新建一个变量来保存当前的tt值。(当然,新变量可以叫tt也可以叫其他名字;如果叫tt,那么这个新tt的作用域是在当次循环内部,覆盖了外面那个所有循环共用的tt。)
(二)table-driven+assert
Go的标准库本身不提供断言,但我们可以借助testify测试库的assert子库,引入断言,使得代码更简洁、可读性更强。
例如,在上述TestGetWeekDay中,本来我们是用下面语句做判断:
if got != tt.want {
t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)
}
如果assert,判断代码可以简化为:
assert.Equal(t, tt.want, got, "should be equal")
完整代码如下:
func TestGetWeekDay(t *testing.T) {
type args struct {
index int
}
tests := []struct {
name string
args args
want string
}{
{name: "index=0", args: args{index: 0}, want: "Sunday"},
{name: "index=1", args: args{index: 1}, want: "Monday"},
{name: "index=2", args: args{index: 2}, want: "Tuesday"},
{name: "index=3", args: args{index: 3}, want: "Wednesday"},
{name: "index=4", args: args{index: 4}, want: "Thursday"},
{name: "index=5", args: args{index: 5}, want: "Friday"},
{name: "index=6", args: args{index: 6}, want: "Saturday"},
{name: "index=-1", args: args{index: -1}, want: "Unknown"},
{name: "index=8", args: args{index: 8}, want: "Unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetWeekDay(tt.args.index)
assert.Equal(t, tt.want, got, "should be equal")
})
}
}
错误日志的输出也更加结构清晰。例如,我们将table数据的第一行改为下面这样,使这个subtest出错:
{name: "index=0", args: args{index: 0}, want: "NotSunday"},
将得到以下错误日志:
此外,还可以将assert逻辑作为一个func类型的字段,直接放在table的每行数据里:
func TestGetWeekDay(t *testing.T) {
type args struct {
index int
}
tests := []struct {
name string
args args
assert func(got string)
}{
{
name: "index=0",
args: args{index: 0},
assert: func(got string) {
assert.Equal(t, "Sunday", got, "should be equal")
}},
{
name: "index=1",
args: args{index: 1},
assert: func(got string) {
assert.Equal(t, "Monday", got, "should be equal")
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetWeekDay(tt.args.index)
if tt.assert != nil {
tt.assert(got)
}
})
}
}
(三)table-driven+mock
当被测的方法存在第三方依赖,如数据库、其他服务接口等等,在写单测的时候,可以将外部依赖抽象为接口,再用mock来模拟外部依赖的各种行为。
我们可以借助Go官方的gomock框架,用其mockgen工具生成接口对应的Mock类源文件,再在测试用例中,使用gomock包结合这些Mock类进行打桩测试。
例如,我们可以改造之前的GetWeekDay func,把它作为WeekDayClient结构体的一个方法,并需要依赖一个外部接口WeekDayService,才能拿到结果:
package main
type WeekDayService interface {
GetWeekDay(int) string
}
type WeekDayClient struct {
svc WeekDayService
}
func (c *WeekDayClient) GetWeekDay(index int) string {
return c.svc.GetWeekDay(index)
}
使用mockgen工具,为接口生成mock:
mockgen -source=weekday_srv.go -destination=weekday_srv_mock.go -package=main
然后,把GoLand自动生成的单测模板改一改,加入mock和assert的逻辑:
package main
import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"testing"
)
func TestWeekDayClient_GetWeekDay(t *testing.T) {
// dependency fields
type fields struct {
svc *MockWeekDayService
}
// input args
type args struct {
index int
}
// tests
tests := []struct {
name string
fields fields
args args
prepare func(f *fields)
assert func(got string)
}{
{
name: "index=0",
args: args{index: 0},
prepare: func(f *fields) {
f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Sunday")
},
assert: func(got string) {
assert.Equal(t, "Sunday", got, "should be equal")
}},
{
name: "index=1",
args: args{index: 1},
prepare: func(f *fields) {
f.svc.EXPECT().GetWeekDay(gomock.Any()).Return("Monday")
},
assert: func(got string) {
assert.Equal(t, "Monday", got, "should be equal")
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// arrange
ctrl := gomock.NewController(t)
defer ctrl.Finish()
f := fields{
svc: NewMockWeekDayService(ctrl),
}
if tt.prepare != nil {
tt.prepare(&f)
}
// act
c := &WeekDayClient{
svc: f.svc,
}
got := c.GetWeekDay(tt.args.index)
// assert
if tt.assert != nil {
tt.assert(got)
}
})
}
}
其中:
fields是WeekDayClient struct里的字段,为了mock,单测时将里面的外部依赖svc的原本类型WeekDayService,替换为mockgen生成的MockWeekDayService。
在每个subtest数据里,加一个func类型的prepare字段,可将fields作为入参,在prepare时对fields.svc的多种行为进行mock。
在每个t.Run的准备阶段,创建mock控制器、用该控制器创建mock对象、调prepare对mock对象做行为注入、最后将该mock对象作为接口的实现,供WeekDayClient作为外部依赖使用。
(四)自定义模板
如果觉得GoLand Generate>Test for xx自动生成的table-driven测试模板不够好用,可以考虑用GoLand Live Template自定义模板。
例如,若我代码里很多方法都类似上文中的GetWeekDay,那我可以抽取通用部分,做成一个table-driven+parallel+mock+assert的代码模板:
func Test$NAME$(t *testing.T) {
// dependency fields
type fields struct {
}
// input args
type args struct {
}
// tests
tests := []struct {
name string
fields fields
args args
prepare func(f *fields)
assert func(got string)
}{
// TODO: Add test cases.
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// run in parallel
t.Parallel()
// arrange
ctrl := gomock.NewController(t)
defer ctrl.Finish()
f := fields{}
if tt.prepare != nil {
tt.prepare(&f)
}
// act
// TODO: add test logic
// assert
if tt.assert != nil {
tt.assert($GOT$)
}
})
}
}
然后打开GoLand>Preference>Editor>Live Template,新建一个自定义的模板:
把代码贴在Template text里,并且Define适用范围部分勾选Go,然后保存。
那么,在后续写代码时,我们只要敲出这个Live Template的名字,就能召唤出这段代码模板:
然后,把里面的$$变量部分和TODO业务逻辑改一改,就能使用了。
作者简介
雷畅
腾讯后台开发工程师
腾讯后台开发工程师,毕业于复旦大学,目前负责腾讯云性能测试服务的后台开发,具有丰富的云原生监控系统和性能测试系统的开发经验。
推荐阅读