3

单元测试了解

 3 years ago
source link: https://wiki.eryajf.net/pages/9bb8c8/#_1-%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95%E7%A4%BA%E4%BE%8B
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

# 1,go test

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

*_test.go文件中有三种类型的函数,单元测试函数基准测试函数示例函数

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

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

# 2,测试函数格式

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

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

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

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

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

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) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 3,测试函数示例

模拟日常开发过程, 我们先写一个功能单一的程序,这个程序可以分割字符串,代码如下:

package split

import "strings"

// Split 切割一个字符串
func Split(str string, sep string) []string {
	var tmp []string
	index := strings.Index(str, sep)
	for index >= 0 {
		tmp = append(tmp, str[:index])
		str = str[index+1:]
		index = strings.Index(str, sep)
	}
	tmp = append(tmp, str)
	return tmp
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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

package split

import (
	"reflect"
	"testing"
)

// 测试函数名必须以Test开头,必须接收一个*testing.T类型参数
func TestSplit(t *testing.T) {
	// 程序输出的结果
	got := Split("a:b:c", ":")
	// 期望的结果
	want := []string{"a", "b", "c"}
	// 因为slice不能比较直接,借助反射包中的方法比较
	if !reflect.DeepEqual(want, got) {
		// 测试失败输出错误提示
		t.Errorf("test failed, want:%v but got:%v\n", want, got)
	}
}

func Test2Split(t *testing.T) {
	got := Split("abcdef", "c")
	want := []string{"ab", "def"}
	if !reflect.DeepEqual(want, got) {
		t.Errorf("test failed, want:%v but got:%v\n", want, got)
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

然后在这个目录中,直接运行 go test可以看到输出结果:

$ go test 
PASS
ok      oldboy/day09/split      0.005s

使用 go test -v可以看到详细内容:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
=== RUN   Test2Split
--- PASS: Test2Split (0.00s)
PASS
ok      oldboy/day09/split      0.005s
1
2
3
4
5
6
7

如上测试用例当中写了两个测试用例,都验证通过了,现在再写一个如下内容的用例:

func Test3Split(t *testing.T) {
	got := Split("abcdef", "cd")
	want := []string{"ab", "ef"}
	if !reflect.DeepEqual(want, got) {
		t.Errorf("test failed, want:%v but got:%v\n", want, got)
	}
}
1
2
3
4
5
6
7

跑一下,发现报错了:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
=== RUN   Test2Split
--- PASS: Test2Split (0.00s)
=== RUN   Test3Split
--- FAIL: Test3Split (0.00s)
    split_test.go:33: test failed, want:[ab ef] but got:[ab def]
FAIL
exit status 1
FAIL    oldboy/day09/split      0.004s
1
2
3
4
5
6
7
8
9
10
11

看起来程序采用这个用例就跑不过去了,那么就需要调整程序:

package split

import (
	"fmt"
	"strings"
)

// Split 切割一个字符串
func Split(str string, sep string) []string {
	var tmp []string
	index := strings.Index(str, sep)
	fmt.Println(index)
	for index >= 0 {
		tmp = append(tmp, str[:index])
		str = str[index+len(sep):]  // 这里使用len(sep)获取sep的长度
		index = strings.Index(str, sep)
		fmt.Println(index)
	}
	tmp = append(tmp, str)
	return tmp
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

再跑一下,就通过。

# 4,测试组

我们现在还想要测试一下split函数对中文字符串的支持,这个时候我们可以再编写一个TestChineseSplit测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。

package split

import (
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	// 定义一个测试用例类型
	type testCase struct {
		str  string
		sep  string
		want []string
	}
	// 定义一个存储测试用例的切片
	testGroup := []testCase{
		testCase{"a:b:c", ":", []string{"a", "b", "c"}},
		testCase{"abcdef", "c", []string{"ab", "def"}},
		testCase{"你好吗", "好", []string{"你", "吗"}},
	}
	// 遍历切片,逐一执行测试用例
	for _, tc := range testGroup {
		got := Split(tc.str, tc.sep)
		if !reflect.DeepEqual(tc.want, got) {
			t.Fatalf("test failed,want:%v,got:%v\n", tc.want, got)
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

执行结果如下:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok      oldboy/day09/split      0.004s
1
2
3
4
5

# 5,子测试

上边的定义方式大大简化了针对同一功能代码用例的编写,只需要在切片中新增一个testCase即可,但是有一个不足就是所有用例执行结果不能够清晰逐个看到。在Go1.7+中新增了子测试,可以通过定义map的方式,将每个用例名字打印出来:

package split

import (
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	// 定义一个测试用例类型
	type testCase struct {
		str  string
		sep  string
		want []string
	}
	// 定义一个存储测试用例的map
	testGroup := map[string]testCase{
		"case-1": testCase{"a:b:c", ":", []string{"a", "b", "c"}},
		"case-2": testCase{"abcdef", "c", []string{"ab", "def"}},
		"case-3": testCase{"你好吗", "好", []string{"你", "吗"}},
	}
	// 遍历map,逐一执行测试用例
	for name, tc := range testGroup {
		t.Run(name, func(t *testing.T) {
			got := Split(tc.str, tc.sep)
			if !reflect.DeepEqual(tc.want, got) {
				t.Fatalf("test failed,want:%#v,got:%#v\n", tc.want, got)
			}
		})

	}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

执行效果如下:

$ go test -v
=== RUN   TestSplit
=== RUN   TestSplit/case-3
=== RUN   TestSplit/case-1
=== RUN   TestSplit/case-2
--- PASS: TestSplit (0.00s)
    --- PASS: TestSplit/case-3 (0.00s)
    --- PASS: TestSplit/case-1 (0.00s)
    --- PASS: TestSplit/case-2 (0.00s)
PASS
ok      oldboy/day09/split      0.005s
1
2
3
4
5
6
7
8
9
10
11

如果想要指定执行其中某个用例,可用如下方式进行:

$ go test -v -run=TestSplit/case-2
=== RUN   TestSplit
=== RUN   TestSplit/case-2
--- PASS: TestSplit (0.00s)
    --- PASS: TestSplit/case-2 (0.00s)
PASS
ok      oldboy/day09/split      0.004s
1
2
3
4
5
6
7

# 6,测试覆盖率

测试覆盖率是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。

Go提供内置功能来检查你的代码覆盖率。我们可以使用go test -cover来查看测试覆盖率。例如:

$ go test -cover
PASS
coverage: 100.0% of statements
ok      oldboy/day09/split      0.004s

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

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

$ go test -cover -coverprofile=c.out 
PASS
coverage: 100.0% of statements
ok      oldboy/day09/split      0.004s

上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中,然后我们执行go tool cover -html=c.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。

image-20200219225154550

图中每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。

日常开发当中:

函数覆盖率:应该达到100%

代码覆盖率:应该超过60%

# 7,基准测试函数

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

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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 1,基准测试示例

我们为split包中的Split函数编写基准测试如下:

$ cat split_test.go

package split

import (
	"testing"
)

func BenchmarkSplit(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Split("a:b:c:d:e", ":")
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

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

$ go test -bench=Split
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4         5000000               242 ns/op
PASS
ok      oldboy/day09/split      1.473s
1
2
3
4
5
6
7

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

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

$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4         5000000               250 ns/op             240 B/op          4 allocs/op
PASS
ok      oldboy/day09/split      1.515s
1
2
3
4
5
6
7

其中,240 B/op表示每次操作内存分配了240字节,4 allocs/op则表示每次操作进行了3次内存分配。 我们将我们的Split函数优化如下:

// Split 切割一个字符串
func Split(str string, sep string) []string {
	var tmp = make([]string, 0, strings.Count(str, sep)+1)  // 在声明变量的时候直接make分配内存空间
	index := strings.Index(str, sep)
	for index >= 0 {
		tmp = append(tmp, str[:index])
		str = str[index+len(sep):]
		index = strings.Index(str, sep)
	}
	tmp = append(tmp, str)
	return tmp
}
1
2
3
4
5
6
7
8
9
10
11
12

改进之后再次进行性能测试看看效果:

$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4        20000000               101 ns/op              80 B/op          1 allocs/op
PASS
ok      oldboy/day09/split      2.146s
1
2
3
4
5
6
7

这个使用make函数提前分配内存的改动,减少了2/3的内存分配次数,并且减少了一半的内存分配。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK