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

一个开发人员,在不受外力胁迫的情况下 ,如何能自觉自愿写单测?

那必然是相信收益 > 成本、单测节省的未来修bug时间 > 写单测所花费的时间。

为了保证上述不等式成立,这边建议您考虑 table-driven
方法,快速、无痛写出高质量单测,以降低“我要写单测”这事的心理门槛,最终达到信手拈来、一直写一直爽的神奇效果。

What:什么是 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 单测法,就是将流程沉淀为一个可复用的模板、并交由机器自动生成;人类则只需要准备数据部分,将自己的多条不同的数据一行行填充到表里,交给流程模板去构造子测试用例、查表、跑数据、比对结果,写单测这事就大功告成了。

Why:为啥单测要 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:

在这里插入图片描述

How:怎么写 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 语言循环变量的一个经典大坑
这是因为:

  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 中,本来我们是用下面语句做判断:

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)
         }
      })
   }
}

其中:

  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 的代码模板:

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 单测法,我这才着手改进,也顺便研究了其他相关工具和实践,逐步得到了写单测效率和质量的双提升。

最后(卖惨求赞预警),这里所有文字和代码都是一字一句原创手敲,实属不易,各位认识的不认识的朋友,咱们搁这就是说:

❤️ 网络一线牵,

❤️ 珍惜这段缘。

❤️ 不妨点个赞,

❤️ 给您拜早年!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值