专栏首页腾讯云监控专栏Golang 高质量单元测试之 Table-Driven:从入门到真香

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

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

作为一个程序猿

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

自觉自愿写单测?

那必然是相信收益 > 成本

单测节省未来修 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。

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

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

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

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

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

联系我们

扫码加云监控小助手

加入更多技术交流群


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

其它文章推荐:

文章分享自微信公众号:
腾讯云监控

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

如有侵权,请联系 yunjia_community@tencent.com 删除。
登录 后参与评论
0 条评论

相关文章

  • Go语言单元测试入门

    每次提到“单元测试”,容易跟“集成测试”混淆,如果确定要推广“单元测试”,首先一定明确“单元测试”的目的和边界。下面是一段StackOverflow上针对两者之...

    nevermosby
  • 【初识Go】| Day12 单元测试

    我们说测试的时候一般是指自动化测试,也就是写一些小的程序用来检测被测试代码(产品代码)的行为和预期的一样,这些通常都是精心设计的执行某些特定的功能或者是通过随机...

    yussuy
  • Python和Go都很火,我要怎么选?

    互联网上有大量优秀的代码,它们构成了多种基础架构的基石。甚至本文所在网站的创建初衷也是创建优秀代码。虽然普通用户并没有注意到这一点,但优秀的开发者总是致力于优化...

    机器之心
  • Python和Go都很火,我要怎么选?

    互联网上有大量优秀的代码,它们构成了多种基础架构的基石。甚至本文所在网站的创建初衷也是创建优秀代码。虽然普通用户并没有注意到这一点,但优秀的开发者总是致力于优化...

    Python数据科学
  • Python和Go都很火,我要怎么选?

    互联网上有大量优秀的代码,它们构成了多种基础架构的基石。甚至本文所在网站的创建初衷也是创建优秀代码。虽然普通用户并没有注意到这一点,但优秀的开发者总是致力于优化...

    OpenCV学堂
  • Python和Go都很火,我要怎么选?

    互联网上有大量优秀的代码,它们构成了多种基础架构的基石。甚至本文所在网站的创建初衷也是创建优秀代码。虽然普通用户并没有注意到这一点,但优秀的开发者总是致力于优化...

    CV君
  • 灵魂拷问:Python和Go都很火,我要怎么选?

    互联网上有大量优秀的代码,它们构成了多种基础架构的基石。甚至本文所在网站的创建初衷也是创建优秀代码。虽然普通用户并没有注意到这一点,但优秀的开发者总是致力于优化...

    CDA数据分析师
  • 从头到脚说单测——谈有效的单元测试(上篇)

    作 者 杨迪,腾讯PCG高级工程师 商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。 作者导语 从4月份至今,我能够全身心投入到腾讯新闻的单元...

    WeTest质量开放平台团队
  • 一顿烤羊腿换来的Golang学习路线

    这篇学习路线写完其实很久了,不过前段时间又请组内的Go后端资深研发工程师吃了一顿烤羊腿。

    拓跋阿秀
  • 从头到脚说单测——谈有效的单元测试

    导语 非常幸运的是,从4月份至今,我能够全身心投入到腾讯新闻的单元测试专项任务中,从无知懵懂,到不断深入理解的过程,与开发同学互帮互助,受益匪浅。在此过程中,得...

    腾讯技术工程官方号
  • Golang单元测试

    Go提供了test工具用于代码的单元测试,test工具会查找包下以_test.go结尾的文件,调用测试文件中以 Test或Benchmark开头的函数并给出运行...

    仙人技术
  • 大数据揭秘:学历真的改变能命运?

    作者:LinkedIn;中外学术情报 央视新闻曾做过关于高考的调查,结果有七成网友支持高考取消数学,看到新闻后,有一位网友却一针见血地评论道:数学考试存在的意...

    钱塘数据
  • 大数据告诉你:学历真的能改变命运

    央视新闻曾做过关于高考的调查,结果有七成网友支持高考取消数学,看到新闻后,有一位网友却一针见血地评论道:数学考试存在的意义就是把这七成网友筛选掉。

    IT派
  • Golang 单元测试详尽指引

    文末有彩蛋。 作者:yukkizhang,腾讯 CSIG 专项技术测试工程师 本篇文章站在测试的角度,旨在给行业平台乃至其他团队的开发同学,进行一定程度的单元...

    腾讯技术工程官方号
  • 前端自动化测试探索和实践

    众所周知的原因,前端作为一种特殊的 GUI 软件,做自动化测试困难重重。在快速迭代,UI 变动大的业务中,自动化测试想要落地更是男上加男 ?。

    ConardLi
  • 行业视角 | 大数据分析,不读书成功逆袭概率多少?

    很多人想要快乐地生活下去,靠的是创造与重复假象不断地麻痹自己,这也正是绝大多数人传播读书无用论的根本动机。 无奈国内反智主义盛行的大环境侵犯到了每一个受过高等教...

    加米谷大数据
  • 大数据揭秘:低学历成功逆袭概率多少?

    无奈国内反智主义盛行的大环境侵犯到了每一个受过高等教育的人的切身利益,总得有人站出来发声。

    华章科技
  • 万字详文阐释程序员修炼之道

    作者:cheaterlin,腾讯 PCG 后台开发工程师 综述 我写过一篇《Code Review 我都 CR 些什么》,讲解了 Code Review 对团队...

    腾讯技术工程官方号
  • 使用go/analysis自己实现linter

    golang虽然是门很火的语言,但是其缺点也是很明显的。由于最初目标就是替换C语言,考虑到复杂性等各种原因没有引入泛型,而是采用了interface{}这个带了...

    王沛文

扫码关注云+社区

领取腾讯云代金券