1. Go 测试代码的一般逻辑

众所周知,Go 的测试函数就是一个普通的 Go 函数,Go 仅对测试函数的函数名和函数原型有特定要求,除此之外,Go 对在测试函数TestXxx或其子测试函数(subtest)中如何编写测试逻辑并没有显式的约束。对测试失败与否的判断在于测试代码逻辑是否进入了包含Error/Errorf、Fatal/Fatalf等方法调用的代码分支。一旦进入这些分支,即代表该测试失败。不同的是Error/Errorf并不会立刻终止当前 goroutine 的执行,还会继续执行该 goroutine 后续的测试,而Fatal/Fatalf则会立刻停止当前 goroutine 的测试执行。

下面的测试代码示例改编自$GOROOT/src/strings/compare_test.go:

  1. // non_table_driven_strings_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. var a, b string
  9. var i int
  10. a, b = "", ""
  11. i = 0
  12. cmp := strings.Compare(a, b)
  13. if cmp != i {
  14. t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
  15. }
  16. a, b = "a", ""
  17. i = 1
  18. cmp = strings.Compare(a, b)
  19. if cmp != i {
  20. t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
  21. }
  22. a, b = "", "a"
  23. i = -1
  24. cmp = strings.Compare(a, b)
  25. if cmp != i {
  26. t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
  27. }
  28. }

2. 表驱动的测试实践

Go 测试代码的逻辑十分简单,约束也甚少。但我们发现:上面仅有三组预置输入数据的示例的测试代码已显得十分冗长,如果为测试预置的数据组数增多,测试函数本身就将变得十分庞大。并且,我们看到上述示例的测试逻辑中存在很多重复的代码,显得十分繁琐。我们来尝试对上述示例做一些改进:

  1. // table_driven_strings_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := []struct {
  9. a, b string
  10. i int
  11. }{
  12. {"", "", 0},
  13. {"a", "", 1},
  14. {"", "a", -1},
  15. }
  16. for _, tt := range compareTests {
  17. cmp := strings.Compare(tt.a, tt.b)
  18. if cmp != tt.i {
  19. t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
  20. }
  21. }
  22. }

在上面这个改进的示例中,我们将之前示例中重复的测试逻辑“合并”为一个,并将预置的输入数据放入一个自定义结构体类型的切片中。这个示例的长度看似并没有比之前的实例缩减多少,但是它却是一个“可扩展”的测试设计。如果我们增加输入测试数据的组数,就像下面这样:

  1. // table_driven_strings_more_cases_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := []struct {
  9. a, b string
  10. i int
  11. }{
  12. {"", "", 0},
  13. {"a", "", 1},
  14. {"", "a", -1},
  15. {"abc", "abc", 0},
  16. {"ab", "abc", -1},
  17. {"abc", "ab", 1},
  18. {"x", "ab", 1},
  19. {"ab", "x", -1},
  20. {"x", "a", 1},
  21. {"b", "x", -1},
  22. // test runtime·memeq's chunked implementation
  23. {"abcdefgh", "abcdefgh", 0},
  24. {"abcdefghi", "abcdefghi", 0},
  25. {"abcdefghi", "abcdefghj", -1},
  26. }
  27. for _, tt := range compareTests {
  28. cmp := strings.Compare(tt.a, tt.b)
  29. if cmp != tt.i {
  30. t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
  31. }
  32. }
  33. }

大家可以看到:我们无需改动后面的测试逻辑,只需在切片中增加数据条目即可。在这种测试设计中,这个自定义结构体类型的切片(上述示例中的compareTests)就是一个表,而基于这个数据表的测试设计和实现则被称为表驱动的测试

3. 表驱动测试的优点

表驱动测试有着诸多优点。

  • 简单与紧凑
  • 数据即测试
  • 结合子测试(subtest)后,可单独运行某个数据项的测试

我们将表驱动测试与子测试结合来改造一下上面的strings_test示例:

  1. // table_driven_strings_with_subtest_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := []struct {
  9. name, a, b string
  10. i int
  11. }{
  12. {`compareTwoEmptyString`, "", "", 0},
  13. {`compareSecondParamIsEmpty`, "a", "", 1},
  14. {`compareFirstParamIsEmpty`, "", "a", -1},
  15. }
  16. for _, tt := range compareTests {
  17. t.Run(tt.name, func(t *testing.T) {
  18. cmp := strings.Compare(tt.a, tt.b)
  19. if cmp != tt.i {
  20. t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
  21. }
  22. })
  23. }
  24. }

在示例中,我们将测试结果的判定逻辑放入一个单独的子测试中,这样我们可以单独执行表中某项数据的测试,比如:我们单独执行表中第一个数据项对应的测试:

  1. $go test -v -run /TwoEmptyString table_driven_strings_with_subtest_test.go
  2. === RUN TestCompare
  3. === RUN TestCompare/compareTwoEmptyString
  4. --- PASS: TestCompare (0.00s)
  5. --- PASS: TestCompare/compareTwoEmptyString (0.00s)
  6. PASS
  7. ok command-line-arguments 0.005s

4. 表驱动测试实践过程中的注意事项

1) 表的实现方式

在上面的示例中,测试中使用的表是用自定义结构体的切片实现的,表也可以使用基于自定义结构体的其他集合类型来实现,比如:map。我们将上面的例子改造为采用map来实现测试数据表:

  1. // table_driven_strings_with_map_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := map[string]struct {
  9. a, b string
  10. i int
  11. }{
  12. `compareTwoEmptyString`: {"", "", 0},
  13. `compareSecondParamIsEmpty`: {"a", "", 1},
  14. `compareFirstParamIsEmpty`: {"", "a", -1},
  15. }
  16. for name, tt := range compareTests {
  17. t.Run(name, func(t *testing.T) {
  18. cmp := strings.Compare(tt.a, tt.b)
  19. if cmp != tt.i {
  20. t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
  21. }
  22. })
  23. }
  24. }

使用map作为数据表时要注意:表内数据项的测试先后顺序是不确定的。

  1. 第一次:
  2. $go test -v table_driven_strings_with_map_test.go
  3. === RUN TestCompare
  4. === RUN TestCompare/compareTwoEmptyString
  5. === RUN TestCompare/compareSecondParamIsEmpty
  6. === RUN TestCompare/compareFirstParamIsEmpty
  7. --- PASS: TestCompare (0.00s)
  8. --- PASS: TestCompare/compareTwoEmptyString (0.00s)
  9. --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
  10. --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
  11. PASS
  12. ok command-line-arguments 0.005s
  13. 第二次:
  14. $go test -v table_driven_strings_with_map_test.go
  15. === RUN TestCompare
  16. === RUN TestCompare/compareFirstParamIsEmpty
  17. === RUN TestCompare/compareTwoEmptyString
  18. === RUN TestCompare/compareSecondParamIsEmpty
  19. --- PASS: TestCompare (0.00s)
  20. --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
  21. --- PASS: TestCompare/compareTwoEmptyString (0.00s)
  22. --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
  23. PASS
  24. ok command-line-arguments 0.005s

2) 测试失败时的数据项的定位

我们需要在测试失败的输出结果中输出数据表项的唯一标识。

一个最简单的方法就是通过输出数据表项在数据表中的偏移量来辅助定位“凶手”:

  1. // table_driven_strings_by_offset_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := []struct {
  9. a, b string
  10. i int
  11. }{
  12. {"", "", 7},
  13. {"a", "", 6},
  14. {"", "a", -1},
  15. }
  16. for i, tt := range compareTests {
  17. cmp := strings.Compare(tt.a, tt.b)
  18. if cmp != tt.i {
  19. t.Errorf(`[table offset: %v] want %v, but Compare(%q, %q) = %v`, i+1, tt.i, tt.a, tt.b, cmp)
  20. }
  21. }
  22. }

运行该示例:

  1. $go test -v table_driven_strings_by_offset_test.go
  2. === RUN TestCompare
  3. TestCompare: table_driven_strings_by_offset_test.go:21: [table offset: 1] want 7, but Compare("", "") = 0
  4. TestCompare: table_driven_strings_by_offset_test.go:21: [table offset: 2] want 6, but Compare("a", "") = 1
  5. --- FAIL: TestCompare (0.00s)
  6. FAIL
  7. FAIL command-line-arguments 0.005s
  8. FAIL

另外一个更直观的方式是使用名字来区分不同数据项:

  1. // table_driven_strings_by_name_test.go
  2. package string_test
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. func TestCompare(t *testing.T) {
  8. compareTests := []struct {
  9. name, a, b string
  10. i int
  11. }{
  12. {"compareTwoEmptyString", "", "", 7},
  13. {"compareSecondStringEmpty", "a", "", 6},
  14. {"compareFirstStringEmpty", "", "a", -1},
  15. }
  16. for _, tt := range compareTests {
  17. cmp := strings.Compare(tt.a, tt.b)
  18. if cmp != tt.i {
  19. t.Errorf(`[%s] want %v, but Compare(%q, %q) = %v`, tt.name, tt.i, tt.a, tt.b, cmp)
  20. }
  21. }
  22. }

运行该示例:

  1. $go test -v table_driven_strings_by_name_test.go
  2. === RUN TestCompare
  3. TestCompare: table_driven_strings_by_name_test.go:21: [compareTwoEmptyString] want 7, but Compare("", "") = 0
  4. TestCompare: table_driven_strings_by_name_test.go:21: [compareSecondStringEmpty] want 6, but Compare("a", "") = 1
  5. --- FAIL: TestCompare (0.00s)
  6. FAIL
  7. FAIL command-line-arguments 0.005s
  8. FAIL

3) Errorf 还是 Fatalf

关于是选择Errorf还是Fatalf并没有固定标准,一般而言,如果一个数据项导致的测试失败不会对后面的数据项的测试结果造成影响,那么推荐Errorf,这样可以通过一次测试执行,看到所有导致测试失败的数据表项;否则,如果数据项导致的测试失败会直接影响到后续数据项的测试结果,那么可以使用Fatalf让测试尽快结束,因为继续执行后面数据项驱动的测试的意义已经不大了。