Skip to content

Latest commit

 

History

History
392 lines (309 loc) · 14.4 KB

29 Go 单元测试惯例:表驱动.md

File metadata and controls

392 lines (309 loc) · 14.4 KB

29 Go 单元测试惯例:表驱动

Go 单元测试惯例:表驱动

在前面章节中,我们明确了测试代码放置的位置(包内测试或包外测试)以及如何根据实际情况更有层次地组织测试代码。在这一节中我们将聚焦于测试函数的内部的代码该如何编写

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

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

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

// non_table_driven_strings_test.go
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	var a, b string
	var i int

	a, b = "", ""
	i = 0
	cmp := strings.Compare(a, b)
	if cmp != i {
		t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
	}

	a, b = "a", ""
	i = 1
	cmp = strings.Compare(a, b)
	if cmp != i {
		t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
	}

	a, b = "", "a"
	i = -1
	cmp = strings.Compare(a, b)
	if cmp != i {
		t.Errorf(`want %v, but Compare(%q, %q) = %v`, i, a, b, cmp)
	}
} 

我们看到上述示例的测试函数TestCompare中使用了三组预置的测试数据对目标函数strings.Compare进行了测试。每次的测试逻辑都比较简单:为被测函数/方法传入预置的测试数据,然后判断被测函数/方法的返回结果是否与预期一致,如果不一致,则测试代码逻辑进入带有testing.Errorf的分支。由此可以得出 Go 测试代码的一般逻辑,那就是针对给定的输入数据,比较被测函数/方法返回的实际结果值与预期值,如有差异,则通过testing包提供的相关函数输出差异信息

2. 表驱动的测试实践

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

// table_driven_strings_test.go 
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := []struct {
		a, b string
		i    int
	}{
		{"", "", 0},
		{"a", "", 1},
		{"", "a", -1},
	}

	for _, tt := range compareTests {
		cmp := strings.Compare(tt.a, tt.b)
		if cmp != tt.i {
			t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
		}
	}
} 

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

// table_driven_strings_more_cases_test.go 
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := []struct {
		a, b string
		i    int
	}{
		{"", "", 0},
		{"a", "", 1},
		{"", "a", -1},
		{"abc", "abc", 0},
		{"ab", "abc", -1},
		{"abc", "ab", 1},
		{"x", "ab", 1},
		{"ab", "x", -1},
		{"x", "a", 1},
		{"b", "x", -1},
		// test runtime·memeq's chunked implementation
		{"abcdefgh", "abcdefgh", 0},
		{"abcdefghi", "abcdefghi", 0},
		{"abcdefghi", "abcdefghj", -1},
	}

	for _, tt := range compareTests {
		cmp := strings.Compare(tt.a, tt.b)
		if cmp != tt.i {
			t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
		}
	}
} 

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

3. 表驱动测试的优点

表驱动测试本身是编程语言无关的,Go 核心团队和 Go 早期开发者在实践过程中发现“表驱动测试”十分适宜 Go 代码测试并在标准库和第三方项目中大量使用此种测试设计,这样“表驱动测试”也就逐渐形成了 Go 的一个惯用法。就像我们在上面示例中看到的那样,表驱动测试有着诸多优点。

  • 简单与紧凑

从上面示例我们看到:表驱动测试将不同测试项经由被测目标执行后的实际输出结果与预期结果的差异判断逻辑合并为一个,这使得测试函数逻辑结构更简单和紧凑。这种简单和紧凑意味着测试代码更容易被开发者理解,因此在测试代码的生命周期内,基于表驱动的测试代码的可维护性更好。

  • 数据即测试

表驱动测试,实质是数据驱动的测试,扩展输入数据集即扩展测试。通过扩展数据集,我们可以很容易地实现提高被测目标测试覆盖率的目的。

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

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

// table_driven_strings_with_subtest_test.go
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := []struct {
		name, a, b string
		i          int
	}{
		{`compareTwoEmptyString`, "", "", 0},
		{`compareSecondParamIsEmpty`, "a", "", 1},
		{`compareFirstParamIsEmpty`, "", "a", -1},
	}

	for _, tt := range compareTests {
		t.Run(tt.name, func(t *testing.T) {
			cmp := strings.Compare(tt.a, tt.b)
			if cmp != tt.i {
				t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
			}
		})
	}
} 

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

$go test -v  -run /TwoEmptyString table_driven_strings_with_subtest_test.go
=== RUN   TestCompare
=== RUN   TestCompare/compareTwoEmptyString
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/compareTwoEmptyString (0.00s)
PASS
ok  	command-line-arguments	0.005s 

综上,我们建议在编写 Go 测试代码时,优先编写基于表驱动的测试。

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

1) 表的实现方式

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

// table_driven_strings_with_map_test.go     
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := map[string]struct {
		a, b string
		i    int
	}{
		`compareTwoEmptyString`:     {"", "", 0},
		`compareSecondParamIsEmpty`: {"a", "", 1},
		`compareFirstParamIsEmpty`:  {"", "a", -1},
	}

	for name, tt := range compareTests {
		t.Run(name, func(t *testing.T) {
			cmp := strings.Compare(tt.a, tt.b)
			if cmp != tt.i {
				t.Errorf(`want %v, but Compare(%q, %q) = %v`, tt.i, tt.a, tt.b, cmp)
			}
		})
	}
} 

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

我们两次执行上面的示例,得到下面不同的结果:

第一次:

$go test -v table_driven_strings_with_map_test.go
=== RUN   TestCompare
=== RUN   TestCompare/compareTwoEmptyString
=== RUN   TestCompare/compareSecondParamIsEmpty
=== RUN   TestCompare/compareFirstParamIsEmpty
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/compareTwoEmptyString (0.00s)
    --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
    --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
PASS
ok  	command-line-arguments	0.005s

第二次:

$go test -v table_driven_strings_with_map_test.go
=== RUN   TestCompare
=== RUN   TestCompare/compareFirstParamIsEmpty
=== RUN   TestCompare/compareTwoEmptyString
=== RUN   TestCompare/compareSecondParamIsEmpty
--- PASS: TestCompare (0.00s)
    --- PASS: TestCompare/compareFirstParamIsEmpty (0.00s)
    --- PASS: TestCompare/compareTwoEmptyString (0.00s)
    --- PASS: TestCompare/compareSecondParamIsEmpty (0.00s)
PASS
ok  	command-line-arguments	0.005s 

我们看到上面两次测试执行的输出结果中,子测试的执行先后次序是不确定,这是由于对map类型的自身性质所决定的:对map集合类型进行迭代所返回的集合中的元素顺序是不确定的。

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

非表驱动的测试,当测试失败时,我们往往通过失败点所在的行数即可判定究竟是哪块儿的测试代码未过:

$go test -v non_table_driven_strings_test.go
=== RUN   TestCompare
    TestCompare: non_table_driven_strings_test.go:16: want 1, but Compare("", "") = 0
--- FAIL: TestCompare (0.00s)
FAIL
FAIL	command-line-arguments	0.005s
FAIL 

在上面这个测试失败的输出结果中,我们可以直接通过行数(non_table_driven_strings_test.go的第 16 行)定位问题所在。但在表驱动的测试中,由于一般情况下,表驱动的测试的测试结果成功与否的判定逻辑是共享的,因此再通过行数来定位问题所在就不可行了,因为无论是表中哪一项导致的测试失败,失败结果中输出的引发错误的行号都是相同的:

$go test -v table_driven_strings_test.go 
=== RUN   TestCompare
    TestCompare: table_driven_strings_test.go:21: want -1, but Compare("", "") = 0
    TestCompare: table_driven_strings_test.go:21: want 6, but Compare("a", "") = 1
--- FAIL: TestCompare (0.00s)
FAIL
FAIL	command-line-arguments	0.005s
FAIL 

我们看到上面这个测试失败的输出结果中,两个测试失败的输出结果中的行号都是21,这样我们就无法快速定位表中导致测试失败的那个“罪魁祸首”。因此,为了在表测试驱动的测试中能快速从输出的结果中定位导致测试失败的表项,我们需要在测试失败的输出结果中输出数据表项的唯一标识

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

// table_driven_strings_by_offset_test.go 
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := []struct {
		a, b string
		i    int
	}{
		{"", "", 7},
		{"a", "", 6},
		{"", "a", -1},
	}

	for i, tt := range compareTests {
		cmp := strings.Compare(tt.a, tt.b)
		if cmp != tt.i {
			t.Errorf(`[table offset: %v] want %v, but Compare(%q, %q) = %v`, i+1, tt.i, tt.a, tt.b, cmp)
		}
	}
} 

运行该示例:

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

在上面这个例子中,我们通过在测试结果输出中增加数据项在表中的偏移信息来快速定位问题数据。由于切片的数据项下标从 0 开始,这里做了一个+1处理。

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

// table_driven_strings_by_name_test.go 
package string_test

import (
	"strings"
	"testing"
)

func TestCompare(t *testing.T) {
	compareTests := []struct {
		name, a, b string
		i          int
	}{
		{"compareTwoEmptyString", "", "", 7},
		{"compareSecondStringEmpty", "a", "", 6},
		{"compareFirstStringEmpty", "", "a", -1},
	}

	for _, tt := range compareTests {
		cmp := strings.Compare(tt.a, tt.b)
		if cmp != tt.i {
			t.Errorf(`[%s] want %v, but Compare(%q, %q) = %v`, tt.name, tt.i, tt.a, tt.b, cmp)
		}
	}
} 

运行该示例:

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

在上面这个例子中,我们通过在自定义结构体中添加一个name字段来区分不同数据项,并在测试结果输出该name字段以在测试失败时辅助快速定位问题数据。

3) Errorf 还是 Fatalf

一般情况下,在基于表测试的测试中,数据表中的所有表项共享同一个测试结果的判定逻辑。这样我们需要在ErrorfFatalf中选择一个来作为测试失败信息的输出途径。前面提到过Errorf不会中断当前的 goroutine 的执行,即便某个数据项导致了测试失败,测试依旧会继续执行下去,而Fatalf恰相反,它会终止测试执行。

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

5. 小结

本节要点:

  • 了解 Go 测试代码的一般逻辑;
  • 如何进行表驱动测试;
  • 表驱动测试的优点;
  • 了解实施表驱动测试时的一些注意事项。