Golang高质量单测之Table-Driven:从入门到真香!

2022年2月16日 486点热度 0人点赞 0条评论

图片


导语 | 一个开发人员,如何能自觉自愿写单测?那必然是相信收益>成本、单测节省的未来修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]}

把这套方法搬到单测领域,也是如此。

一个测试用例,一般包括以下部分:


  • 稳定的流程

  1. 定义测试用例

  2. 定义输入数据和期望的输出数据

  3. 跑测试用例,拿到实际输出

  4. 比较期望输出和实际输出

  • 易变的数据

  1. 输入的数据

  2. 期望的输出数据

而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=0func 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=1func 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业务逻辑改一改,就能使用了。

 作者简介


图片

雷畅

腾讯后台开发工程师

腾讯后台开发工程师,毕业于复旦大学,目前负责腾讯云性能测试服务的后台开发,具有丰富的云原生监控系统和性能测试系统的开发经验。



 推荐阅读


10分钟搞懂!消息队列选型全方位对比

在线教程!C++如何在云应用中快速实现编译优化?

CGO让Go与C手牵手,打破双方“壁垒”!

前端推荐!玩转Webpack共需几步?

图片

7810Golang高质量单测之Table-Driven:从入门到真香!

root

这个人很懒,什么都没留下

文章评论