忽视表驱动测试
表驱动测试是编写精简测试的一种有效技术。它减少了样板代码(具有固定模式的代码块,冗余但是又不得不写),帮助我们更加专注于重要的事情:测试逻辑。本文将通过一个具体的例子来说明为什么使用表驱动测试值得我们了解。
下面函数实现的功能是将给定字符串的后缀n或rn全部删除,直到末尾不含换行符n或rn终止。
代码语言:javascript复制func removeNewLineSuffixes(s string) string {
if s == "" {
return s
}
if strings.HasSuffix(s, "rn") {
return removeNewLineSuffixes(s[:len(s)-2])
}
if strings.HasSuffix(s, "n") {
return removeNewLineSuffixes(s[:len(s)-1])
}
return s
}
上面函数采用了递归实现。现在,假设我们要全面地测试这个函数,至少要覆盖以下几种情况:
- 输入的是空串
- 输入的字符串以n结尾
- 输入的字符串以rn结尾
- 输入的字符串以多个n结尾
- 输入的字符串不含换行符
一种可能的方法是为上面的每种输入情况创建一个单元测试,代码如下:
代码语言:javascript复制func TestRemoveNewLineSuffix_Empty(t *testing.T) {
got := removeNewLineSuffixes("")
expected := ""
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine(t *testing.T) {
got := removeNewLineSuffixes("arn")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithNewLine(t *testing.T) {
got := removeNewLineSuffixes("an")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithMultipleNewLines(t *testing.T) {
got := removeNewLineSuffixes("annn")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithoutNewLine(t *testing.T) {
got := removeNewLineSuffixes("an")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
上述的每个测试函数都代表我们想要覆盖的一个特定测试案例。观察上述代码,我们会注意到它存在两个缺点。第一个很明显的缺点是函数名称变得很复杂,像TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine长达55个字符,阅读会比较困难,会影响我们阅读函数体内容。第二个缺点是这些函数存在重复的语句,因为它们的结构是相同的,整个结构都是下面这样。
- 调用removeNewLineSuffixes函数
- 定义预期结果值
- 对结果值进行比较
- 记录错误信息
如果我们想要修改上面结构中的某个步骤,例如,将预期结果值作为记录错误信息的一部分,则不得不在所有测试函数中重复这个语句。并且编写的测试用例越多,维护也就越困难。由于这些原因,我们可以使用表驱动测试,这样只编写一次逻辑即可。表驱动测试依赖于子测试,这意味着单个测试函数可以包含多个子测试。例如下面的测试包含两个子测试:
代码语言:javascript复制func TestFoo(t *testing.T) {
t.Run("subtest 1", func(t *testing.T) {
if false {
t.Error()
}
})
t.Run("subtest 2", func(t *testing.T) {
if 2 != 2 {
t.Error()
}
})
}
上面的TestFoo函数包含两个子测试,运行上述代码,会输出子测试1和子测试2的内容,具体显示内容如下:
代码语言:javascript复制--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
--- PASS: TestFoo/subtest_2 (0.00s)
PASS
我们还可以使用-run参数运行单个测试,例如,如果只想运行 subtest 1, 可以将父测试名称与子测试通过 / 连接起来赋值给-run参数,像下面这样:
代码语言:javascript复制$ go test -run=TestFoo/subtest_1 -v
=== RUN TestFoo
=== RUN TestFoo/subtest_1
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
现在回到本文开头的例子,看看如何利用子测试来防止重复测试逻辑。实现思路是为每个案例点创建一个子测试,定义一个map结构,map的键代表测试名称,map的值代表测试数据的输入值和预期值。实现代码如下:
代码语言:javascript复制func TestRemoveNewLineSuffix(t *testing.T) {
tests := map[string]struct {
input string
expected string
}{
`empty`: {
input: "",
expected: "",
},
`ending with rn`: {
input: "arn",
expected: "a",
},
`ending with n`: {
input: "an",
expected: "a",
},
`ending with multiple n`: {
input: "annn",
expected: "a",
},
`ending without newline`: {
input: "a",
expected: "a",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := removeNewLineSuffixes(tt.input)
if got != tt.expected {
t.Errorf("got: %s, expected: %s", got, tt.expected)
}
})
}
}
像上面这样,使用包含测试数据的数据结构并利用子测试来避免重复代码的做法正是表驱动测试的概念。上述代码中的tests变量是一个map,键是测试名称,值表示测试数据。在此处的例子中,测试数据包含输入和预期结果的字符串。map中的每个元素都是我们想要覆盖的测试用例。然后通过循环,为每个测试用例运行一个新的子测试。
上面通过表驱动测试实现解决了前面测试代码存在的两个缺点:
- 每个测试名称现在都是一个字符串,而不是Pascal命名法(首字母大写,像EndingWithCarriageReturnNewLine)函数名称,方便阅读。
- 测试逻辑只写一次,所有的测试用例都共享它。后续如果添加新的测试用例,只需向结构体添加数据而不用动测试逻辑。
在Go语言中常见100问题-#84 Not using test execution modes中,讨论了我们可以通过调用t.Parallel来标记并行运行的测试,我们也可以在提供给t.Run的闭包内的子测试中执行该操作,示例程序如下:
代码语言:javascript复制for name, tt := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
// Use tt
})
}
但在使用表驱动测试的时候有一件事需要小心,稍不留意可能导致错误。就是在上面的闭包程序中使用了一个循环变量tt, 导致闭包可能使用错误的tt变量值,为了防止出现Go语言中常见100问题-#63 Not being careful with goroutines and loop ...中的问题,我们应该创建一个新的变量,将tt的值赋值给它, 像下面这样,每个闭包都将访问自己的tt变量。
代码语言:javascript复制for name, tt := range tests {
tt := tt
t.Run(name, func(t *testing.T) {
t.Parallel()
// Use tt
})
}
总结,如果多个单元测试具有相似的结构,我们可以使用表驱动对它们进行优化。这会带给我们两个好处,一是避免了大量重复逻辑,方便维护;二是可以轻松更改测试逻辑,添加新的测试用例也很容易。