Go语言单元测试详解

本文最后更新于 2024年10月15日 下午

测试工具

测试命令

在Go语言中,使用单元测试的主要工具命令就是go test,不需要额外其他工具或库来进行单元测试,在使用go test命令时,系统会自动根据一定的约定将当前包内的所有以_test.go为后缀的源代码文件作为测试文件,同时测试文件中所有以Test为前缀的函数都将被作为测试函数全部执行。

文件命名

基于上面的描述,在写单元测试代码时,需要遵循一定的规范:

  1. 通常单元测试文件和源代码程序文件放在一起,不使用额外的test包;
  2. 单元测试文件必须以_test.go为后缀;
  3. 单元测试函数必须以Test为前缀(基准测试函数以Benchmark为前缀);
  4. 单元测试函数参数必须为t *testing.T(基准测试函数为b *testing.B);

单元测试

根据上面的规则,在使用单元测试时,必须导入testing包,函数名称必须以Test为前缀,后缀必须以大写字母开头,通常后缀为对应需要测试的函数名称,以下示例为测试函数的基本格式:

1
2
3
4
// Add函数对应单元测试函数
func TestAdd(t *testing.T){

}

基本使用

现在,我们写一个简单的计算绝对值的函数Abs,然后通过这个函数来看单元测试的基本使用函数,首先定义一个包,在包里面新建源代码文件和测试文件,目录结构如下:

1
2
3
calc
--calc.go
--calc_test.go

在calc.go源代码文件中写一个简单的函数用于计算绝对值。

1
2
3
4
5
6
7
func Abs(i int) int {
if i >= 0 {
return i
} else {
return -i
}
}

然后在calc_test.go测试文件中写一个简单的单元测试函数。

1
2
3
4
5
6
func TestAbs(t *testing.T) {
got := Abs(-1)
if got != 1 {
t.Errorf("excepted: %d, got: %d", 1, got)
}
}

代码中调用Abs函数,并传入参数-1,使用got接收返回值,如果返回值不是我们期望的1,则可以使用t.Errorf输出错误信息。
然后使用go test命令运行单元测试:

1
2
3
PS C:\Users\lee\GolandProjects\test\test> go test
PASS
ok test/calc 0.200s

可以看到输出了PASS表示测试通过,同时还可以在命令后面加上-v查看更详细输出

1
2
3
4
5
PS C:\Users\lee\GolandProjects\test\calc> go test -v
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok test/calc 0.142s

记得我们上面说过的,如果使用go test命令系统会自动根据一定的约定将当前包内的所有以_test.go为后缀的源代码文件作为测试文件,同时测试文件中所有以Test为前缀的函数都将被作为测试函数全部执行。但如果我们仅仅只需要跑其中一个测试函数怎么办呢,可以在命令后面加上-run参数指定函数名称,即可运行指定的测试函数。
首先在源代码文件和测试文件中分别加入一个功能函数和一个测试函数。

1
2
3
func Add(i, j int) int {
return i + j
}
1
2
3
4
5
6
func TestAdd(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Errorf("excepted: %d, got: %d", 1, got)
}
}

这时候如果直接执行go test就会将两个测试函数都执行,通过指定的方式只执行其中一个测试函数。

1
2
3
4
5
PS C:\Users\lee\GolandProjects\test\calc> go test -v -run=Add
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok test/calc 0.151s

在通过-run命令指定函数的时候可以写功能函数的名称,也可以写测试函数的名称,两者都是一样的效果。

测试覆盖率

go test命令还有一个参数是用来专门看单元测试对于功能代码的覆盖情况,命令为go test -cover,使用该命令可以清晰的看到单元测试的覆盖率。

1
2
3
4
PS C:\Users\lee\GolandProjects\test\calc> go test -cover
PASS
coverage: 75.0% of statements
ok test/calc 0.188s

使用该命令针对我们上面的单元测试进行测试,能够看到单元测试的覆盖率为75%,这表示在功能性代码里面还有一部分的情况没有测试到,这样的话我们可以使用-coverprofile参数将测试的覆盖率保存到文件中,然后在文件中查看到底是哪部分的内容没有被测试到的。

1
2
3
4
C:\Users\lee\GolandProjects\test\calc>go test -cover -coverprofile=cover.out
PASS
coverage: 75.0% of statements
ok test/calc 0.184s

使用该命令之后就会在当前目录下生成一个cover.out文件(注意:使用该命令需要确保当前命令行有管理员权限,否则会导致创建文件失败),用来保存测试覆盖率相关的记录,但是我们打开当前文件发现里面的内容并不能看出是哪个地方没有被覆盖到,这时候我们可以使用go tool命令来将记录文件生成一个HTML格式的报告。

1
go tool cover -html=cover.out

直接运行这个命令就会生成HTML的报告,并直接在浏览器中打开,报告如下:

通过报告就能看到是Abs函数中关于负数部分的分支没有被覆盖到,这时候如果我们在TestAbs函数中增加一个测试用例测试负数的情况,再跑覆盖率就会达到100%的覆盖率。

测试组

正如上面所讲,单元测试需要尽可能多的覆盖功能代码中的不同情况,这个时候可能就需要在测试代码中写上很多的测试用例,但是一个个去写测试用例肯定不是一个明确的做法,所以这个时候就需要使用测试组,测试组简单理解就是将多个测试用例汇聚到一起,然后使用循环的方式来一个个测试,如果需要新增或删除测试用例可以直接在数组中修改,不需要改动代码。测试组的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestAbs(t *testing.T) {
// 定义测试用例需要使用到的结构体,包含输入以及期望的结果
type test struct {
input int
want int
}
// 构建测试用例
tests := []test{
{input: 1, want: 1},
{input: 2, want: 2},
{input: 0, want: 0},
{input: -1, want: 1},
}
// 循环测试组对每个测试用例进行测试
for _, tc := range tests {
got := Abs(tc.input)
if got != tc.want {
t.Errorf("want: %d, got: %d", tc.want, got)
}
}
}

使用命令进行测试:

1
2
3
4
5
PS C:\Users\lee\GolandProjects\test\calc> go test -v -run=Abs
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok test/calc 0.163s

通过测试组可以将测试多个测试用例聚合到一起进行测试,方便测试用例的新增和删除,这样不仅代码美观还提高了代码的扩展性。

子测试

通过上面测试组的方法,能够很容易的将多个测试用例汇聚到一起,但同时也带来了一个问题,那就是当测试用例过多的时候,如果其中某个测试用例发生错误,不太容易能够看出是哪个测试用例的问题,这时候我们就需要给所有的测试用例一个特定的标签或名字,代码修改如下,并将其中的某一个用例的期望值改成错误的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestAbs(t *testing.T) {
// 定义测试用例需要使用到的结构体,包含输入以及期望的结果
type test struct {
input int
want int
}
// 构建测试用例
tests := map[string]test{
"No1": {input: 1, want: 1},
"No2": {input: 2, want: 3},
"No3": {input: 0, want: 0},
"No4": {input: -1, want: 1},
}
// 循环测试组对每个测试用例进行测试
for name, tc := range tests {
got := Abs(tc.input)
if got != tc.want {
t.Errorf("name: %s, want: %d, got: %d", name, tc.want, got)
}
}
}

然后开始测试

1
2
3
4
5
6
7
PS C:\Users\lee\GolandProjects\test\calc> go test -v -run=Abs
=== RUN TestAbs
calc_test.go:35: name: No2, want: 3, got: 2
--- FAIL: TestAbs (0.00s)
FAIL
exit status 1
FAIL test/calc 0.196s

通过上面的输出就能够看到是名称为No2的测试用例没有通过,使用上面的方法虽然可以简单的看到出错的测试用例,但是还不够明显,这时候可以使用单元测试中的子测试,通过子测试方法能够更清晰明了的看到每一个用例的通过情况,修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func TestAbs(t *testing.T) {
// 定义测试用例需要使用到的结构体,包含输入以及期望的结果
type test struct {
input int
want int
}
// 构建测试用例
tests := map[string]test{
"No1": {input: 1, want: 1},
"No2": {input: 2, want: 3},
"No3": {input: 0, want: 0},
"No4": {input: -1, want: 1},
}
// 循环测试组对每个测试用例进行测试
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := Abs(tc.input)
if got != tc.want {
t.Errorf("want: %d, got: %d", tc.want, got)
}
})
}
}

测试函数的参数t可以调用Run方法,该方法接收一个name参数和一个函数类型的参数,然后再次开始测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PS C:\Users\lee\GolandProjects\test\calc> go test -v -run=Abs
=== RUN TestAbs
=== RUN TestAbs/No3
=== RUN TestAbs/No4
=== RUN TestAbs/No1
=== RUN TestAbs/No2
calc_test.go:43: want: 3, got: 2
--- FAIL: TestAbs (0.00s)
--- PASS: TestAbs/No3 (0.00s)
--- PASS: TestAbs/No4 (0.00s)
--- PASS: TestAbs/No1 (0.00s)
--- FAIL: TestAbs/No2 (0.00s)
FAIL
exit status 1
FAIL test/calc 0.182s

通过上面的输出能够清晰的看到通过的测试用例以及没有通过的测试用例,相比较于第一种的方式,输出更加清晰明了,这就是使用子测试的好处。

基准测试

基准测试,也就是BenchmarkTest,基准测试是用来测试代码性能的的一种方法,使用基准测试时,测试函数必须以Benchmark开头,后面跟具体需要测试的函数的名称,基本格式如下:

1
2
3
4
// Add函数对应的基准测试函数
func BenchmarkAdd(b *testing.B){

}

基准测试函数中的参数是b *testing.B,基准测试时函数必须要执行b.N次,只有这样测试才能具有参考性和一定的准确性。b.N这个值并不是固定的,而是根据情况变化,从1开始,如果当前测试函数能够在1秒内执行完毕,则会将b.N的值增加到2,如果测试函数同样在1秒内执行完毕,则会继续增加b.N的值,b.N的递增序列为1,2,3,5,10,20,30,50,100

基本使用

现在我们先写一个计算斐波拉契数列的函数:

1
2
3
4
5
6
7
// Fib 递归函数计算斐波拉契数列的第 x 个数
func Fib(x int) int {
if x <= 1 {
return x
}
return Fib(x-1) + Fib(x-2)
}

然后在对应的测试文件中写上针对Fib函数的基准测试函数:

1
2
3
4
5
func BenchmarkFib(b *testing.B) {
for i := 0; i < b.N; i++ {
Fib(10)
}
}

在功能函数和基准测试函数都准备完毕之后我们可以使用go test命令来执行对应的基准测试,使用该命令不能直接运行基准测试,需要在参数后面指定-bench参数并指定响应的函数名称。

1
2
3
4
5
6
7
8
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib-8 4861580 235.1 ns/op
PASS
ok test/calc 1.567s

上面的输出中,上面都是一些系统相关信息,下面BenchmarkFib-8后面的数字表示的是GOMAXPROCS,默认等于CPU的核数,后面的4861580235.1 ns/op表示当前测试用例执行的4861580次,平均花费时间为235.1 ns,总耗时为1.567s
在使用的时候还可以通过其他参数来获取更多的测试数据,用来提升测试的准确率和可参考性。
使用-benchmem参数来获取内存分配的数据。

1
2
3
4
5
6
7
8
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib -benchmem
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib-8 4749666 251.0 ns/op 0 B/op 0 allocs/op
PASS
ok test/calc 1.609s

在耗时的后面新增了0 B/op0 allocs/op,其中0 B/op表示每次运行测试分配了0B的内存,0 allocs/op表示每次运行测试进行了0次的内存分配,因为我们的功能函数没用到额外的内存,所以这两个值都是0,大家可以自己尝试其他方法来看看这个值的变化。
使用-benchtime参数指定测试的基准时间或次数,在前面我们说过测试用例会执行b.N次,这个数量是动态变化的,只要运行测试用例的时间没有超过1秒就会递增这个值。现在可以使用-benchtime参数指定这个基准时间,如果修改为5,可以使用-benchtime=5s,则表示执行时间不超过5秒,就会递增b.N

1
2
3
4
5
6
7
8
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib -benchtime=5s
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib-8 23184202 235.6 ns/op
PASS
ok test/calc 5.859s

通过上面的输出可以看到,一共运行了23184202次,平均每次耗时235.6 ns,总耗时5.859s。因为通过-benchtime指定了基准时间为5秒,所以总运行次数大概是之前的5倍。
-benchtime除了指定时间之外,还可以用来指定具体的次数,假设指定执行100次,可以使用-benchtime=100x

1
2
3
4
5
6
7
8
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib -benchtime=100x
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib-8 100 487.0 ns/op
PASS
ok test/calc 0.167s

上面可以看到通过-benchtime指定次数后,一共调用了Fib函数100次,总耗时0.167s
-count参数可以用来指定测试的轮数,比如指定执行3轮。

1
2
3
4
5
6
7
8
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib -count=3       
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib-8 4641591 261.0 ns/op
BenchmarkFib-8 5031808 238.0 ns/op
BenchmarkFib-8 5103565 245.1 ns/op

性能比较

在基准测试中,某些功能函数在不同的输入时,相对应的性能也会有所差别,在前面的所有测试中,我们传递的参数都是10,也就是每次都计算的是斐波拉契数列的第10个,如果我们需要测试一个函数在不同的输入下的性能差异,或者是测试两个函数在相同输入下的性能差异,就会使用到性能比较的测试方法,在比较性能的时候就需要使用到一个带参数的测试函数,再使用其他的Benchmark函数传入不同的值来调用,用以测试不同输入的性能差别。
我们将上面的测试代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func benchmarkFib(b *testing.B, n int) {
for i := 0; i < b.N; i++ {
Fib(n)
}
}

func BenchmarkFib2(b *testing.B) {
benchmarkFib(b, 2)
}

func BenchmarkFib10(b *testing.B) {
benchmarkFib(b, 10)
}

func BenchmarkFib20(b *testing.B) {
benchmarkFib(b, 20)
}

func BenchmarkFib30(b *testing.B) {
benchmarkFib(b, 30)
}

在代码中写了一个带有参数的辅助函数benchmarkFib,可以传入参数,并且构造了4个测试用例,分别传入不同的参数,执行上面的测试用例。

1
2
3
4
5
6
7
8
9
10
11
PS C:\Users\lee\GolandProjects\test\calc> go test -bench=Fib
goos: windows
goarch: amd64
pkg: test/calc
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkFib2-8 304879520 3.793 ns/op
BenchmarkFib10-8 5249786 232.8 ns/op
BenchmarkFib20-8 41760 28409 ns/op
BenchmarkFib30-8 345 3571588 ns/op
PASS
ok test/calc 6.245s

可以看到,在不同的输入下,函数的执行次数和平均执行时间都有不小的差距,当计算第30个数的时候一共才执行了345次,而每次的平均执行时间为3571588 ns

重置时间

在进行基准测试的时候,通常都是用来测试函数的性能,我们上面的函数都是简单的一些示例,但是在日常的工作中,我们测试之前可能需要有一些准备工作,例如测试前读取文件,这样的话在测试的时候就会将读取文件的耗时也计算到测试报告里面去,这个时候我们可以使用到ResetTimer来重置时间,代码如下:

1
2
3
4
5
6
7
8
9
func BenchmarkFib(b *testing.B) {
// sleep 3秒,模拟测试前的准备工作
time.Sleep(time.Second * 3)
// 重置定时器
b.ResetTimer()
for i := 0; i < b.N; i++ {
Fib(10)
}
}

在代码中调用了b.ResetTimer()方法,这样就表示重置定时器,意味着这行代码之前的所有代码的耗时都不会被计算到测试中,这样能够确保代码测试的准确性。


Go语言单元测试详解
http://example.com/p/43abdc4.html
作者
jrlee
发布于
2024年8月9日
许可协议