go test工具

Go语言中的测试依赖go test命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。

go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以xxx_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。

xxx_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。

类型 格式 作用
测试函数 函数名前缀为Test 测试程序的一些逻辑行为是否正确
基准函数 函数名前缀为Benchmark 测试函数的性能
示例函数 函数名前缀为Example 为文档提供示例文档

go test命令会遍历所有的xxx_test.go文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

单元测试函数Test

格式

每个测试函数必须导入testing包,测试函数的基本格式(签名)如下:

func TestName(t *testing.T){
    // ...
}

测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,举几个例子:

func TestAdd(t *testing.T){ ... }
func TestSum(t *testing.T){ ... }
func TestLog(t *testing.T){ ... }

其中参数t用于报告测试失败和附加的日志信息。 testing.T的拥有的方法如下:

func (c *T) Cleanup(func())
func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Helper()
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool
func (c *T) TempDir() string

单元测试示例

单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。

我在 factorial.go 中定义了一个 Factorial 函数,具体实现如下:

// Fact 定义返回的结构体
type Fact struct {
	ret int
	nums []int
}

// Factorial 计算给定数字的阶乘
func Factorial(x int) *Fact {
	var factS = new(Fact)
	return operation(x,factS)
}

// 递归运算
func operation(x int, factS *Fact) *Fact {
	factS.nums = append(factS.nums, x)
	if x == 1 {
		factS.ret = x
		return factS
	}
	factS.ret = x * operation(x-1, factS).ret
	return factS
}

在同级目录下,我们创建一个factorial_test.go的测试文件,并定义一个测试函数如下:

func TestFactorial(t *testing.T) {
	// 程序输出的结果
	got := Factorial(5)

	// 期望的结果
	want := &Fact{
		ret:  120,
		nums: []int{5,4,3,2,1},
	}

	// 因为struct不能比较直接,借助反射包中的方法比较
	if !reflect.DeepEqual(want, got) {
		// 测试失败输出错误提示
		t.Errorf("expected:%v, got:%v", want, got)
	}
}

在当前路径下执行go test命令,可以看到输出结果如下:

shell > go test
PASS
ok      go_study/22.unit_test   0.564s
go test -v

我们再往factorial_test.go文件中添加一个测试用例:

// 这里给到一个错误的结果,测试错误情况
func TestFactEr(t *testing.T) {
	got := Factorial(3)
	want := &Fact{
		ret:  6,
		nums: []int{3,2,1},
	}
	if !reflect.DeepEqual(want, got) {
		t.Errorf("expected:%v, got:%v", want, got)
	}
}

现在我们有多个测试用例了,为了能更好的在输出结果中看到每个测试用例的执行情况,我们可以为go test命令添加-v参数,让它输出完整的测试结果。

shell> go test -v
=== RUN   TestFactorial
--- PASS: TestFactorial (0.00s)
=== RUN   TestFactEr
    factorial_test.go:38: expected:&{666 [2 2 2]}, got:&{6 [3 2 1]}
--- FAIL: TestFactEr (0.00s)
FAIL
exit status 1
FAIL    go_study/22.unit_test   0.096s

从上面的输出结果我们能清楚的看到是TestFactEr这个测试用例没有测试通过。

go test -run

在执行go test命令的时候可以添加-run参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被go test命令执行。

例如通过给go test添加 -run=Sep 参数来告诉它本次测试只运行TestFactorial这个测试用例:

shell> go test -run=TestFactorial -v
=== RUN   TestFactorial
--- PASS: TestFactorial (0.00s)
PASS
ok      go_study/22.unit_test   0.099s
回归测试

我们修改了代码之后仅仅执行那些失败的测试用例或新引入的测试用例是错误且危险的,正确的做法应该是完整运行所有的测试用例,保证不会因为修改代码而引入新的问题。

shell> go test -v
=== RUN   TestFactorial
--- PASS: TestFactorial (0.00s)
=== RUN   TestFactEr
--- PASS: TestFactEr (0.00s)
PASS
ok      go_study/22.unit_test   0.539s

测试结果表明我们的单元测试全部通过。

通过这个示例我们可以看到,有了单元测试就能够在代码改动后快速进行回归测试,极大地提高开发效率并保证代码的质量。

跳过某些测试用例

为了节省时间支持在单元测试时跳过某些耗时的测试用例。

func TestTimeConsuming(t *testing.T) {
    if testing.Short() {
        t.Skip("short模式下会跳过该测试用例")
    }
    ...
}

当执行go test -short时就不会执行上面的TestTimeConsuming测试用例。

子测试

在上面的示例中我们为每一个测试数据编写了一个测试函数,而通常单元测试中需要多组测试数据保证测试的效果。Go1.7+中新增了子测试,支持在测试函数中使用t.Run执行一组测试用例,这样就不需要为不同的测试数据定义多个测试函数了。

表格驱动测试不是工具、包或其他任何东西,它只是编写更清晰测试的一种方式和视角。
使用表格驱动测试能够很方便的维护多个测试用例,避免在编写单元测试时频繁的复制粘贴。
表格驱动测试的步骤通常是定义一个测试用例表格,然后遍历表格,并使用t.Run对每个条目执行必要的测试。

func TestXXX(t *testing.T){
  t.Run("case1", func(t *testing.T){...})
  t.Run("case2", func(t *testing.T){...})
  t.Run("case3", func(t *testing.T){...})
}

测试演示:

func TestFactGroup(t *testing.T)  {
	t.Parallel()  // 将 TLog 标记为能够与其他测试并行运行

	// 定义测试表格
	testCases := []struct{
		testName string
		input int
		output *Fact
	}{
		{"case1",5, &Fact{ret:  120, nums: []int{5,4,3,2,1}}},
		{"case2",3, &Fact{ret:  6, nums: []int{3,2,1}}},
	}

	// 运行子测试代码
	for _,tt := range testCases {
		tt := tt
		t.Run(tt.testName, func(t *testing.T) {
			t.Parallel()  // 将每个测试用例标记为能够彼此并行运行
			got := Factorial(tt.input)
			if !reflect.DeepEqual(tt.output, got) {
				t.Errorf("expected:%v, got:%v", tt.output, got)
			}
		})
	}
}

在终端执行go test -v,会得到如下测试输出结果:

shell> go test -v
=== RUN   TestFactGroup
=== RUN   TestFactGroup/case1
=== RUN   TestFactGroup/case2
--- PASS: TestFactGroup (0.00s)
    --- PASS: TestFactGroup/case1 (0.00s)
    --- PASS: TestFactGroup/case2 (0.00s)
PASS
ok      go_study/22.unit_test   0.405s
并行测试

表格驱动测试中通常会定义比较多的测试用例,而Go语言又天生支持并发,所以很容易发挥自身并发优势将表格驱动测试并行化。 想要在单元测试过程中使用并行测试,可以像下面的代码示例中那样通过添加t.Parallel()来实现。

t.Parallel()

这样我们执行go test -v的时候就会看到每个测试用例并不是按照我们定义的顺序执行,而是互相并行了。

使用工具生成测试代码

社区里有很多自动生成表格驱动测试函数的工具,比如gotests等,很多编辑器如Goland也支持快速生成测试文件。这里简单演示一下gotests的使用。

安装

go get github.com/cweill/gotests/...

执行

gotests -all -w factorial.go

上面的命令表示,为split.go文件的所有函数生成测试代码至split_test.go文件(目录下如果事先存在这个文件就不再生成)。

生成的测试代码大致如下:

func Test_operation(t *testing.T) {
	type args struct {
		x     int
		factS *Fact
	}
	tests := []struct {
		name string
		args args
		want *Fact
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := operation(tt.args.x, tt.args.factS); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("operation() = %v, want %v", got, tt.want)
			}
		})
	}
}
测试覆盖率

测试覆盖率是指代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。在公司内部一般会要求测试覆盖率达到80%左右。

Go提供内置功能来检查你的代码覆盖率,即使用go test -cover来查看测试覆盖率。

shell> go test -cover
PASS
coverage: 100.0% of statements
ok      go_study/22.unit_test   0.291s

从上面的结果可以看到我们的测试用例覆盖了100%的代码。

Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。例如:

shell> go test -cover -coverprofile=file.out
PASS
coverage: 100.0% of statements
ok      go_study/22.unit_test   0.180s

上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中。

❯ tree .
.
├── file.out
├── factorial.go
└── factorial_test.go

然后我们执行go tool cover -html=file.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。

image-20211102160747032

testify/assert

testify是一个社区非常流行的Go单元测试工具包,其中使用最多的功能就是它提供的断言工具——testify/asserttestify/require

安装:

go get github.com/stretchr/testify

我们在写单元测试的时候,通常需要使用断言来校验测试结果,但是由于Go语言官方没有提供断言,所以我们会写出很多的if...else...语句。而testify/assert为我们提供了很多常用的断言函数,并且能够输出友好、易于阅读的错误描述信息。

比如我们之前在TestSplit测试函数中就使用了reflect.DeepEqual来判断期望结果与实际结果是否一致。

testify/assert提供了非常多的断言函数,这里没办法一一列举出来,大家可以查看官方文档了解。

testify/require拥有testify/assert所有断言函数,它们的唯一区别就是——testify/require遇到失败的用例会立即终止本次测试。

此外,testify包还提供了mockhttp等其他测试工具。

单元基准测试Benchmark

格式:

基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:

func BenchmarkName(b *testing.B){
    // ...
}

基准测试以Benchmark为前缀,需要一个*testing.B类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。 testing.B拥有的方法如下:

func (c *B) Error(args ...interface{})
func (c *B) Errorf(format string, args ...interface{})
func (c *B) Fail()
func (c *B) FailNow()
func (c *B) Failed() bool
func (c *B) Fatal(args ...interface{})
func (c *B) Fatalf(format string, args ...interface{})
func (c *B) Log(args ...interface{})
func (c *B) Logf(format string, args ...interface{})
func (c *B) Name() string
func (b *B) ReportAllocs()
func (b *B) ResetTimer()
func (b *B) Run(name string, f func(b *B)) bool
func (b *B) RunParallel(body func(*PB))
func (b *B) SetBytes(n int64)
func (b *B) SetParallelism(p int)
func (c *B) Skip(args ...interface{})
func (c *B) SkipNow()
func (c *B) Skipf(format string, args ...interface{})
func (c *B) Skipped() bool
func (b *B) StartTimer()
func (b *B) StopTimer()

示例:

我们为 factorial.go 中的 Factorial 方法便携基准测试代码:

func BenchmarkFactorial(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Factorial(10)
	}
}

基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test -bench=Split命令执行基准测试,输出结果如下:

shell> go test -bench=Factorial
goos: darwin
goarch: amd64
pkg: go_study/22.unit_test
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFactorial-4     6067362               198.5 ns/op
PASS
ok      go_study/22.unit_test   1.605s

其中BenchmarkSplit-4表示对Factorial函数进行基准测试,数字4表示GOMAXPROCS的值,这个对于并发基准测试很重要。6067362198.5ns/op表示每次调用Factorial函数耗时198.05ns,这个结果是6067362次调用的平均值。

我们还可以为基准测试添加-benchmem参数,来获得内存分配的统计数据。

shell> go test -bench=Factorial -benchmem
goos: darwin
goarch: amd64
pkg: go_study/22.unit_test
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFactorial-4     5139259               198.6 ns/op           248 B/op          5 allocs/op
PASS
ok      go_study/22.unit_test   1.383s

其中,248 B/op表示每次操作内存分配了112字节,5 allocs/op则表示每次操作进行了5次内存分配。

性能比较函数

上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理1万甚至100万个元素的耗时的差别是多少?再或者对于同一个任务究竟使用哪种算法性能最佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。

性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举个例子如下:

func benchmark(b *testing.B, size int){/* ... */}
func Benchmark10(b *testing.B){ benchmark(b, 10) }
func Benchmark100(b *testing.B){ benchmark(b, 100) }
func Benchmark1000(b *testing.B){ benchmark(b, 1000) }

编写一个性能比较测试函数如下:

shell> go test -bench=.
goos: darwin
goarch: amd64
pkg: go_study/22.unit_test
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFactorial-4     6278547               189.5 ns/op
BenchmarkFact1-4        50307910                22.53 ns/op
BenchmarkFact5-4         8841597               133.6 ns/op
BenchmarkFact10-4        6290251               199.7 ns/op
BenchmarkFact50-4        2198989               534.9 ns/op
BenchmarkFact100-4       1257559               955.7 ns/op
PASS
ok      go_study/22.unit_test   9.797s

这里需要注意的是,默认情况下,每个基准测试至少运行1秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。

所以如果函数的运行接近1秒,则测试出的数据可能不准确。像这种情况下我们应该可以使用-benchtime标志增加最小基准时间,以产生更准确的结果。例如:

shell> go test -bench=Fact50 -benchtime=20s
goos: darwin
goarch: amd64
pkg: go_study/22.unit_test
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFact50-4       42403383               557.6 ns/op
PASS
ok      go_study/22.unit_test   24.516s
重置时间

b.ResetTimer之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。例如:

func BenchmarkFactorial(b *testing.B) {
	time.Sleep(5 * time.Second) // 假设需要做一些耗时的无关操作
	b.ResetTimer()              // 重置计时器
	for i := 0; i < b.N; i++ {
		Factorial(10)
	}
}
并行测试

格式:

func (b *B) RunParallel(body func(*PB))

RunParallel会创建出多个goroutine,并将b.N分配给这些goroutine执行, 其中goroutine数量的默认值为GOMAXPROCS

用户如果想要增加非CPU受限(non-CPU-bound)基准测试的并行性, 那么可以在RunParallel之前调用SetParallelism

RunParallel通常会与-cpu标志一同使用。

shell> go test -bench=. 
goos: darwin
goarch: amd64
pkg: go_study/22.unit_test
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFactorialParallel-4     9344024               132.3 ns/op
PASS
ok      go_study/22.unit_test   1.658s

还可以通过在测试命令后添加-cpu参数如go test -bench=. -cpu 1来指定使用的CPU数量。

Setup与TearDown

测试程序有时需要在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)。

setUp()函数是在众多函数或者说是在一个类里面最先被调用的函数,而且每执行完一个函数都要从setUp()调用开始后再执行下一个函数,有几个函数就调用他几次,与位置无关,随便放在那里都是他先被调用。

tearDown()函数是在众多函数执行完后他才被执行,意思就是不管这个类里面有多少函数,他总是最后一个被执行的,与位置无关,放在那里都行,最后不管测试函数是否执行成功都执行tearDown()方法;如果setUp()方法失败,则认为这个测试项目失败,不会执行测试函数也不执行tearDown()方法。

TestMain

通过在*_test.go文件中定义TestMain函数来可以在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)操作。

如果测试文件包含函数:func TestMain(m *testing.M)那么生成的测试会先调用 TestMain(m),然后再运行具体测试。TestMain运行在主goroutine中, 可以在调用 m.Run前后做任何设置(setup)和拆卸(teardown)。退出测试的时候应该使用m.Run的返回值作为参数调用os.Exit

一个使用TestMain来设置Setup和TearDown的示例如下:

func TestMain(m *testing.M) {
	fmt.Println("write setup code here...") // 测试之前的做一些设置
	// 如果 TestMain 使用了 flags,这里应该加上flag.Parse()
	retCode := m.Run()                         // 执行测试
	fmt.Println("write teardown code here...") // 测试之后做一些拆卸工作
	os.Exit(retCode)                           // 退出测试
}

需要注意的是:在调用TestMain时, flag.Parse并没有被调用。所以如果TestMain 依赖于command-line标志 (包括 testing 包的标记), 则应该显示的调用flag.Parse

子测试的Setup与Teardown

有时候我们可能需要为每个测试集设置Setup与Teardown,也有可能需要为每个子测试设置Setup与Teardown。下面我们定义两个函数工具函数如下:

// 测试集的Setup与Teardown
func setupFactsTest(t *testing.T) func(t *testing.T) {
	t.Log("如有需要在此执行:Setup")
	return func(t *testing.T) {
		t.Log("如有需要在此执行:Setup")
	}
}

// 子测试的Setup与Teardown
func downFactsTest(t *testing.T) func(t *testing.T) {
	t.Log("如有需要在此执行:Teardown")
	return func(t *testing.T) {
		t.Log("如有需要在此执行:Teardown")
	}
}

测试代码如下:

执行顺序如下:setUp —> case1 —> setUp —> case2 —> tearDown

func TestFactsGroup(t *testing.T)  {
	t.Parallel()

	// 定义测试表格
	testCases := []struct{
		testName string
		input int
		output *Fact
	}{
		{"case1",5, &Fact{ret:  120, nums: []int{5,4,3,2,1}}},
		{"case2",3, &Fact{ret:  6, nums: []int{3,2,1}}},
	}

	setupTest := setupFactsTest(t)
	defer setupTest(t)            // 测试之前执行setup操作

	// 运行子测试代码
	for _,tt := range testCases {
		tt := tt
		t.Run(tt.testName, func(t *testing.T) {
			t.Parallel()

			downTest := downFactsTest(t)
			defer downTest(t) // 测试之后执行testdown操作

			got := Factorial(tt.input)
			if !reflect.DeepEqual(tt.output, got) {
				t.Errorf("expected:%v, got:%v", tt.output, got)
			}
		})
	}
}

测试结果如下:

libin@bogon 22.unit_test % go test -v -run=TestFactsGroup
=== RUN   TestFactsGroup
=== PAUSE TestFactsGroup
=== CONT  TestFactsGroup
    factorial_test.go:97: 如有需要在此执行:Setup
=== RUN   TestFactsGroup/case1
=== PAUSE TestFactsGroup/case1
=== RUN   TestFactsGroup/case2
=== PAUSE TestFactsGroup/case2
=== CONT  TestFactsGroup
    factorial_test.go:99: 如有需要在此执行:Setup
=== CONT  TestFactsGroup/case1
=== CONT  TestFactsGroup/case2
=== CONT  TestFactsGroup/case1
    factorial_test.go:105: 如有需要在此执行:Teardown
=== CONT  TestFactsGroup/case2
    factorial_test.go:105: 如有需要在此执行:Teardown
    factorial_test.go:107: 如有需要在此执行:Teardown
=== CONT  TestFactsGroup/case1
    factorial_test.go:107: 如有需要在此执行:Teardown
--- PASS: TestFactsGroup (0.00s)
    --- PASS: TestFactsGroup/case1 (0.00s)
    --- PASS: TestFactsGroup/case2 (0.00s)
PASS
ok      go_study/22.unit_test   0.554s

单元示例函数Example

格式

go test特殊对待的第三种函数就是示例函数,它们的函数名以Example为前缀。它们既没有参数也没有返回值。标准格式如下:

func ExampleName() {
    // ...
}

示例

func ExampleFactorial() {
	fmt.Println(unittest.Factorial(5))

	// Output:&Fact{ret:120, nums:[]int{5,4,3,2,1}}
}

为你的代码编写示例代码有如下三个用处:

  1. 示例函数能够作为文档直接使用,例如基于web的godoc中能把示例函数与对应的函数或包相关联。

  2. 示例函数只要包含了// Output:也是可以通过go test运行的可执行测试。

shell> go test -run Example
PASS
ok      go_study/22.unit_test       0.006s
  1. 示例函数提供了可以直接运行的示例代码,可以直接在golang.orggodoc文档服务器上使用Go Playground运行示例代码。