一个开发人员,
在不受外力胁迫的情况下,如何能自觉自愿写单测?那必然是相信收益 > 成本、单测节省的未来修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"