作为一个程序猿
如何在不受外力(领导?)的胁迫下
自觉自愿写单测?
那必然是相信收益 > 成本
单测节省未来修 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。
但如果一周有七天,这代码就显得有些离谱了!
显然,控制流程的逻辑并不复杂,是个简单粗暴的映射(0 -> Sunday,1 -> Monday……);分支与分支之间的唯一区别,在于可变的数据,而不是流程本身。
那如果把数据拆分出来,放入表的多个行里(表一般用数组实现;数组的一项即是表的一行),将大量的重复流程消消乐,代码就简洁很多:
把这套方法搬到单测领域,也是如此。
一个测试用例,一般包括以下部分:
- 稳定的流程
- 定义测试用例
- 定义输入数据和期望的输出数据
- 跑测试用例,拿到实际输出
- 比较期望输出和实际输出
- 易变的数据
- 输入的数据
- 期望的输出数据
而 table-driven 单测法,就是将流程沉淀为一个可复用的模板、并交由机器自动生成;人类则只需要准备数据部分,将自己的多条不同的数据一行行填充到表里,交给流程模板去构造子测试用例、查表、跑数据、比对结果,写单测这事就大功告成了。
为什么单测需要 table-driven?
在了解了 table-driven 的概念后,你多半能预见到 table-driven 单测可带来以下好处:
- 写得快:人类只需准备数据,无需构造流程。
- 可读性强:将数据构造成表,结构更清晰,一行一行的数据变化对比分明。
- 子测试用例互相独立:每条数据是表里的一行,被流程模板构造成一个独立的子测试用例。
- 可调试性强:因为每行数据被构造成子测试用例,可以单独跑、单独调试。
- 可扩展/可维护性强:改一个子测试用例,就是改表里的一行数据。
接下来,通过举例对比 TestGetWeekDay 的不同单测风格,就能愈发看出 table-driven 的好处。
例子一:低质量单测之平铺多个 test case
从 0 -> Sunday,1 -> Monday…… 到 6 -> Saturday,给每条数据都写一个单独的 test case:
一眼望去,重复代码太多,可维护性差;另外,这些针对同一个方法的 test case,被拆成并列的多个,跟其他方法的 test case 放在同一文件里平铺的话,缺乏结构化的组织,可读性差。
例子二:低质量单测之平铺多个 subtest
实际上,从 Go 1.7 开始,一个 test case 里可以有多个子测试(subtest),这些子测试用 t.Run 方法创建:
例子二比第一个例子简洁一些,并且子测试之间仍相互独立,可单独跑、单独调试。如图,在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 的名字、输入、期望的输出。
填充好的代码示例如下:
注意:给每行子测试一个有意义的 name,作为它的标识。否则,自行测试时不仅可读性差不说,GoLand 的单独测试也无识别它了!
高阶玩法
table-driven + parallel
默认情况下,一个测试用例的所有 subtests 是串行执行的。如果需要并行,则要在 t.Run 里显式地写明 t.Parallel,才能使这个 subtest 与其他带 t.Parallel 的 subtets 一起并行执行:
此处需注意,在循环内,多加了一句 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 中,本来我们是用下面语句做判断:
如果 assert,判断代码可以简化为:
完整代码如下:
错误日志的输出也更加结构清晰。例如,我们将 table 数据的第一行改为下面这样,使这个 subtest 出错:
将得到以下错误日志:
此外,还可以将 assert 逻辑作为一个 func 类型的字段,直接放在 table 的每行数据里:
table-driven + mock
当被测的方法存在第三方依赖,如数据库、其他服务接口等等,在写单测的时候,可以将外部依赖抽象为接口,再用 mock 来模拟外部依赖的各种行为。
我们可以借助 Go 官方的 gomock 框架,用其 mockgen 工具生成接口对应的 Mock 类源文件,再在测试用例中,使用 gomock 包结合这些 Mock 类进行打桩测试。
例如,我们可以改造之前的 GetWeekDay func,把它作为 WeekDayClient 结构体的一个方法,并需要依赖一个外部接口 WeekDayService,才能拿到结果:
使用 mockgen 工具,为接口生成 mock:
然后,把 GoLand 自动生成的单测模板改一改,加入 mock 和 assert 的逻辑:
mock 和 assert 的逻辑 说明:
- 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 的代码模板:
然后打开 GoLand > Preference > Editor > Live Template,新建一个自定义的模板:
把代码贴在 Template text里,并且 Define 适用范围部分勾选 Go,然后保存。
那么,在后续写代码时,我们只要敲出这个 Live Template 的名字,就能召唤出这段代码模板:
然后,把里面的 $$ 变量部分和 TODO 业务逻辑改一改,就能使用了。
结语
不瞒您说,作者之前写单测的画风,比较接近本文中的低质量单测,不仅写和调试的时候费劲,后期维护成本也高,这样一来,说不清写单测是提高还是降低了我的生产力。
然而,命运的转机发现了 table-driven 单测法。此后我才着手改进,也顺便研究了其他相关工具和实践,逐步得到了写单测效率和质量的双提升。