我是测试的忠实粉丝,特别是单元测试和TDD(当然前提是, 恰当的做好 )。 围绕Go项目的一种实践是 table driven test
方法。 这篇文章探讨了编写 table driven test
的方式和原因。
假设我们有一个分割字符串的函数:
// Split slices s into all substrings separated by sep and // returns a slice of the substrings between those separators. func Split(s, sep string) []string { var result []string i := strings.Index(s, sep) for i > -1 { result = append(result, s[:i]) s = s[i+len(sep):] i = strings.Index(s, sep) } return append(result, s) }
在Go中,单元测试只是常规的Go函数(有一些规则),所以我们在同一目录的文件中,使用相同的包名 strings
,开始为这个函数编写一个单元测试。
package split import ( "reflect" "testing" ) func TestSplit(t *testing.T) { got := Split("a/b/c", "/") want := []string{"a", "b", "c"} if !reflect.DeepEqual(want, got) { t.Fatalf("expected: %v, got: %v", want, got) } }
测试只是常规的有一些规则的Go函数:
- 测试函数的名称必须以Test开头。
- 测试函数必须采用*testing.T 类型的一个参数。 *testing.T 是测试包本身注入的类型,用于提供打印,跳过和失败测试的方法。
在我们的测试中,我们使用一些输入调用 Split
,然后将其与我们预期的结果进行比较。
Code coverage
接下来的问题是,这个包的覆盖范围是什么? 幸运的是,go tool 具有内置的分支覆盖。 我们可以像这样调用它:
% go test -coverprofile=c.out PASS coverage: 100.0% of statements ok split 0.010s
结果表明,代码有100%的分支覆盖率,这并不奇怪,这段代码中只有一个分支。
如果我们想深入了解覆盖率报告,那么 go tool 有几个选项来打印覆盖率报告。 我们可以使用 go tool cover -func
来细分每个函数的覆盖率:
% **go tool cover -func=c.out** split/split.go:8: Split 100.0% total: (statements) 100.0%
如果在该软件包中只有一个功能,并不足令人兴奋,但我相信你会发现更多令人兴奋的软件包来测试。
Spray some .bashrc on that
这两个命令对我来说非常有用,因此我有一个shell alias,它可以一个命令运行测试覆盖率并得到报告:
cover () { local t=$(mktemp -t cover) go test $COVERFLAGS -coverprofile=$t $@ \ && go tool cover -func=$t \ && unlink $t }
Going beyond 100% coverage
我们编写了一个测试用例,获得了100%的覆盖率,但这并不是故事的结尾。 我们有很好的分支覆盖,但我们可能需要测试一些边界条件。 例如,如果我们尝试将使用逗号分割字符串会发生什么?
func TestSplitWrongSep(t *testing.T) { got := Split("a/b/c", ",") want := []string{"a/b/c"} if !reflect.DeepEqual(want, got) { t.Fatalf("expected: %v, got: %v", want, got) } }
抑或,如果源字符串中没有分隔符会发生什么?
func TestSplitNoSep(t *testing.T) { got := Split("abc", "/") want := []string{"abc"} if !reflect.DeepEqual(want, got) { t.Fatalf("expected: %v, got: %v", want, got) } }
我们开始构建一组运行边界条件的测试用例。 这相当不错。
Introducing table driven tests
然而,我们的测试中有很多重复。 对于每个测试用例,只有输入,预期输出和测试用例的名称发生变化。 其他一切都是样板。 我们想要设置所有的输入和预期输出,感受它们在单个测试套件的效果。 这是引入 table driven test 的好时机。
func TestSplit(t *testing.T) { type test struct { input string sep string want []string } tests := []test{ {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}}, {input: "a/b/c", sep: ",", want: []string{"a/b/c"}}, {input: "abc", sep: "/", want: []string{"abc"}}, } for _, tc := range tests { got := Split(tc.input, tc.sep) if !reflect.DeepEqual(tc.want, got) { t.Fatalf("expected: %v, got: %v", tc.want, got) } } }
我们声明了一个结构来保存我们的测试输入和预期输出。 这是我们的表。tests
结构通常是局部声明,因为我们希望将此名称重用于此包中的其他测试。
实际上,我们甚至不需要给类型命名,我们可以使用匿名结构字面值来减少样板文件,如下所示:
func TestSplit(t *testing.T) { tests := []struct { input string sep string want []string }{ {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}}, {input: "a/b/c", sep: ",", want: []string{"a/b/c"}}, {input: "abc", sep: "/", want: []string{"abc"}}, } for _, tc := range tests { got := Split(tc.input, tc.sep) if !reflect.DeepEqual(tc.want, got) { t.Fatalf("expected: %v, got: %v", tc.want, got) } } }
现在,添加一个新的测试是直截了当的事情; 只需在 tests
结构中添加另一行。 例如,如果我们的输入字符串有一个尾随分隔符会发生什么?
{input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}}, {input: "a/b/c", sep: ",", want: []string{"a/b/c"}}, {input: "abc", sep: "/", want: []string{"abc"}}, **{input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}}, // trailing sep**
但是,当我们运行 go test
,我们得到了
% go test --- FAIL: TestSplit (0.00s) split_test.go:24: expected: [a b c], got: [a b c ]
抛开测试失败,有一些问题需要讨论。
第一种,将每个测试从函数重写到表中的一行,我们已经丢失了失败测试的名称。 我们在测试文件中添加了一个注释来强调这种情况,但我们无法在 go test
输出中访问该注释。
有几种方法可以解决这个问题。 你会在Go代码库中看到混合风格的使用,因为table testing的习惯用法随着人们对该类型的不断试验而不断发展。