一个开发人员,
在不受外力胁迫的情况下,如何能自觉自愿写单测?那必然是相信收益 > 成本、单测节省的未来修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 语言循环变量的一个经典大坑。
这是因为:
- 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 业务逻辑改一改,就能使用了。
结语(必看!)
不瞒你说,我本人之前写单测的画风,比较接近本文中的低质量单测,不仅写和调试的时候费劲,后期维护成本也高,这样一来,说不清写单测是提高还是降低了我的生产力。
然而,命运的转机发生在同事向我推荐了 table-driven 单测法,我这才着手改进,也顺便研究了其他相关工具和实践,逐步得到了写单测效率和质量的双提升。
最后(卖惨求赞预警),这里所有文字和代码都是一字一句原创手敲,实属不易,各位认识的不认识的朋友,咱们搁这就是说:
❤️ 网络一线牵,
❤️ 珍惜这段缘。
❤️ 不妨点个赞,
❤️ 给您拜早年!