作者:雷畅,腾讯云监控高级工程师
作为一个程序猿
如何在不受外力(领导?)的胁迫下
自觉自愿写单测?
那必然是相信收益 > 成本
单测节省未来修 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 的好处。
从 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 放在同一文件里平铺的话,缺乏结构化的组织,可读性差。
实际上,从 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 单测模板非常简单,只需在 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 的单独测试也无识别它了!
[点击查看大图]
高阶玩法
默认情况下,一个测试用例的所有 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 语言循环变量的一个经典大坑。有以下几大原因:
最坑的是,如果你不打印一些 log,还发现不了这个问题,因为虽然每次循环都在检查最后一组输入输出,但如果这组值是能 pass 的,那么所有测试全部能 pass,暴露不了问题:
[点击查看大图]
为了解决这个问题,最常用的方法,就是上述代码里的 tt := tt,也即,每次循环的代码块内部,都新建一个变量来保存当前的 tt 值。(当然,新变量可以叫 tt 也可以叫其他名字;如果叫 tt,那么这个新 tt 的作用域是在当次循环内部,覆盖了外面那个所有循环共用的 tt)
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 的逻辑 说明:
自定义模板
如果觉得 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 单测法。此后我才着手改进,也顺便研究了其他相关工具和实践,逐步得到了写单测效率和质量的双提升。
联系我们
扫码加云监控小助手
加入更多技术交流群
关注我们,了解腾讯云监控的最新动态
其它文章推荐: