前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Golang 高质量单元测试之 Table-Driven:从入门到真香

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

作者头像
腾讯云可观测平台
发布2022-01-21 21:10:21
7730
发布2022-01-21 21:10:21
举报

‍作者:雷畅,腾讯云监控高级工程师

作为一个程序猿

如何在不受外力(领导?)的胁迫下

自觉自愿写单测?

那必然是相信收益 > 成本

单测节省未来修 bug 的时间 > 写单测所花费的时间

为了保证上述不等式成立,强烈建议您考虑 table-driven 方法!table-driven 方法!!table-driven 方法!!!(只说三遍了)

使用 Table-driven 可以快速、无痛写出高质量单测,以降低“我要写单测”这事的心理门槛,最终达到信手拈来、一直写一直爽的神奇效果!(亲测可信)

什么是 table-driven?

表驱动法(Table-Driven Approach)这个概念,并不是 Golang 或者测试领域独有的;它是个编程模式,属于数据驱动编程的一种。

表驱动法的核心在于:把易变的数据部分,从稳定的处理数据的流程里分离,放进表里;而不是直接混杂在 if-else / switch-case 的多个分支里。

简单举例:写一个 func,输入第 index 天,输出这天是星期几。假如一周只有两三天,那么直接用 if-else / switch-case,倒也 ok。

但如果一周有七天,这代码就显得有些离谱了!

代码语言:javascript
复制
// 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……);分支与分支之间的唯一区别,在于可变的数据,而不是流程本身。

那如果把数据拆分出来,放入表的多个行里(表一般用数组实现;数组的一项即是表的一行),将大量的重复流程消消乐,代码就简洁很多:

代码语言:javascript
复制
// 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:

代码语言:javascript
复制
// 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 方法创建:

代码语言:javascript
复制
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 的名字、输入、期望的输出。

填充好的代码示例如下:

代码语言:javascript
复制
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 一起并行执行:

代码语言:javascript
复制
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 语言循环变量的一个经典大坑。有以下几大原因:

  1. for 循环迭代器的变量 tt,是被每次循环所共用的。也即,tt 一直是同一个 tt;每次循环只改变了 tt 的值,而地址和变量名一直没变。
  2. 每个加了 t.Parallel 的 subtest,被传给自己的 go routine 后不会马上执行,而是会暂停,等待与其并行的所有 subtest 都初始化完成。
  3. 那么,当 Go 调度器真正开始执行所有 subtest 的时候,外面的for循环已经跑完了;其迭代器变量 tt 的值,已经拿到了循环的最后一个值。
  4. 于是,所有 subtest 的 go routine 都拿到了同一个 tt 值,也即循环的最后一个值。

最坑的是,如果你不打印一些 log,还发现不了这个问题,因为虽然每次循环都在检查最后一组输入输出,但如果这组值是能 pass 的,那么所有测试全部能 pass,暴露不了问题:

[点击查看大图]

为了解决这个问题,最常用的方法,就是上述代码里的 tt := tt,也即,每次循环的代码块内部,都新建一个变量来保存当前的 tt 值。(当然,新变量可以叫 tt 也可以叫其他名字;如果叫 tt,那么这个新 tt 的作用域是在当次循环内部,覆盖了外面那个所有循环共用的 tt)

table-driven + assert

Go 的标准库本身不提供断言,但我们可以借助 testify 测试库的 assert 子库,引入断言,使得代码更简洁、可读性更强。

例如,在上述 TestGetWeekDay 中,本来我们是用下面语句做判断:

代码语言:javascript
复制
if got != tt.want {   t.Errorf("GetWeekDay() = %v, want %v", got, tt.want)}

如果 assert,判断代码可以简化为:

代码语言:javascript
复制
assert.Equal(t, tt.want, got, "should be equal")

完整代码如下:

代码语言:javascript
复制
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 出错:

代码语言:javascript
复制
{name: "index=0", args: args{index: 0}, want: "NotSunday"},

将得到以下错误日志:

[点击查看大图]

此外,还可以将 assert 逻辑作为一个 func 类型的字段,直接放在 table 的每行数据里:

代码语言:javascript
复制
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,才能拿到结果:

代码语言:javascript
复制
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:

代码语言:javascript
复制
mockgen -source=weekday_srv.go -destination=weekday_srv_mock.go -package=main

然后,把 GoLand 自动生成的单测模板改一改,加入 mock 和 assert 的逻辑:

代码语言:javascript
复制
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)         }      })   }}

mock 和 assert 的逻辑 说明:

  1. fields 是 WeekDayClient struct 里的字段,为了 mock,单测时将里面的外部依赖 svc 的原本类型 WeekDayService,替换为 mockgen 生成的 MockWeekDayService。
  2. 在每个 subtest 数据里,加一个 func 类型的 prepare 字段,可将 fields 作为入参,在 prepare 时对 fields.svc 的多种行为进行 mock。
  3. 在每个 t.Run 的准备阶段,创建 mock 控制器、用该控制器创建 mock 对象、调 prepare 对 mock 对象做行为注入、最后将该 mock 对象作为接口的实现,供 WeekDayClient 作为外部依赖使用。

自定义模板

如果觉得 GoLand Generate > Test for xx 自动生成的 table-driven 测试模板不够好用,可以考虑用 GoLand Live Template 自定义模板。

例如,若代码里很多方法都类似上文中的 GetWeekDay,那可以抽取通用部分,做成一个 table-driven + parallel + mock + assert 的代码模板:

代码语言:javascript
复制
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 业务逻辑改一改,就能使用了。

结语

不瞒您说,作者之前写单测的画风,比较接近本文中的低质量单测,不仅写和调试的时候费劲,后期维护成本也高,这样一来,说不清写单测是提高还是降低了我的生产力。

然而,命运的转机发现了 table-driven 单测法。此后我才着手改进,也顺便研究了其他相关工具和实践,逐步得到了写单测效率和质量的双提升。

联系我们

扫码加云监控小助手

加入更多技术交流群


关注我们,了解腾讯云监控的最新动态

其它文章推荐:

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-01-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯云可观测 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 例子一:低质量单测之平铺多个 test case
  • 例子二:低质量单测之平铺多个 subtest
  • 例子三:高质量单测之 table-driven
  • table-driven + parallel
  • table-driven + assert
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档