单元测试、基准测试、覆盖测试

背景: 之前很长一段时间再写Golang程序时,不会有意识去写单元测试,直到后来写了独立项目后,慢慢才发现给一个功能编写对应的单元测试是多么高效和方便,接下来就再一起复习下Golang中的测试.

UnitTest(单元测试)

单元测试是程序开发者适用一段代码来验证另外一段代码写的是否符合预期的一种相对高效的自我测试方法。

还记得最早开始搞运维时,写的程序基本上是通过main程序去调用具体的功能函数,然后通过具体的输出来主观验证结果是否符合预期,这种方式对于搞正统的软件开发者而言会感觉很傻,但这对于运维领域来说却很实用,很有效,因为通常运维工作中需要的一些开发都不会是逻辑较为复杂的程序,所以没有必要专门去写测试程序去测试另外一个程序是否符合预期。

但是随着工作内容和运维需求的变化,不得不使用一些正规软件工程领域的相关方法来进行测试,因为对于程序开发来说,经过长期的积累和方法总结,单元测试是一种比较好的开发程序验证方式,而且能够提高程序开发的质量。而在Golang语言中内置了一系列的测试框架,加下来就主要讲讲UnitTest单元测试的相关知识点。

UnitTest的编写

注意:在Golang中,对于单元测试程序来说通常会有一些重要约束,主要如下:

  • 单元测试文件名必须为xxx_test.go(其中xxx为业务逻辑程序)
  • 单元测试的函数名必须为Testxxx(xxx可用来识别业务逻辑函数)
  • 单元测试函数参数必须为t *testing.T(测试框架强要求)
  • 测试程序和被测试程序文件在一个包package
 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 示例文件
# 假设我们为某段业务逻辑专门写了一个package(用来初始化一个矩形并计算体积)此时看到到整体结构如下
$ tree -L 2 ./unittest
./unittest
├── area.go
└── area_test.go

# 业务逻辑代码(业务逻辑需要和单元测试在一个package下)
$ cat ./unittest/area.go
package unittest

type box struct {
    length  int
    width   int
    height  int
    name    string
}

// 初始化一个结构体指针对象,后面使用结构体指针方法来设置和获取对象属性
func Newbox() (*box) {
    return &box{}
}

// 给结构体对象设置具体的属性(名称,规格大小)
// 注意: 在如下几个方法中,方法接受者为指针类型,而方法参数为值类型,因此在赋值时可能有人产生疑惑,这里其实是Golang底层做了优化(v.name = name 等同于(*v).name = name)
func (v *box) SetName(name string) {
    v.name = name
}
func (v *box) SetSize(l,w,h int) {
    v.length = l
    v.width = w
    v.height = h
}

// 获取对象的一些属性(名称和体积)
func (v *box) GetName() (string) {
    return v.name
}
func (v *box) GetVolume() (int) {
    return (v.length)*(v.width)*(v.height)
}

# 对应业务逻辑的单元测试逻辑
$ cat unittest/area_test.go
package unittest
// 必须导入testing模块,并且方法的接受者为(t *testing.T)
import (
    "fmt"
    "testing"
)
// 测试1: 测试名称是否符合预期
func TestSetSomething(t *testing.T) {
    box := Newbox()
    box.SetName("bgbiao")
    if box.GetName() == "bgbiao" {
        fmt.Println("the rectangular name's result is ok")
    }
}
// 测试2: 测试计算出来的体积是否符合预期
func TestGetSomething(t *testing.T) {
    box := Newbox()
    box.SetSize(3,4,5)
    if box.GetVolume() == 60 {
        fmt.Println("the rectangular volume's result is ok")
    }
}

# 运行单元测试程序
# 可以看到我们编写的两个单元测试都经过预期测试
$ cd unittest
$ go test
the rectangular name's result is ok
the rectangular volume's result is ok
PASS
ok  	_/User/BGBiao/unittest	0.005s

单元测试的运行

通过上面那个测试示例,我们都知道了可以使用go test来对Golang代码进行测试,接下来具体讲解一些go test的其他用法(其实上面说的那些规则也可以在go help test帮助文档中找到)

这里主要总结下几个常用的参数:

  • -args: 指定一些测试时的参数(可以指定超时时间,cpu绑定,压测等等(go test包含单元测试,压力测试等))

    • -test.v: 是否输出全部的单元测试用例(不管成功或者失败),默认没有加上,所以只输出失败的单元测试用例
    • -test.run pattern: 只跑哪些单元测试用例
    • -test.bench patten: 只跑那些性能测试用例
    • -test.benchmem : 是否在性能测试的时候输出内存情况
    • -test.benchtime t : 性能测试运行的时间,默认是1s
    • -test.cpuprofile cpu.out : 是否输出cpu性能分析文件
    • -test.memprofile mem.out : 是否输出内存性能分析文件
    • -test.blockprofile block.out : 是否输出内部goroutine阻塞的性能分析文件
  • -c: 编译测试文件到pkg.test,但是不会运行测试程序

  • -exec xprog: 使用xprog参数来运行编译的测试文件(参数类似go run后的参数)

  • -i: 安装测试程序中的依赖包,但是不运行测试程序

  • -json: 以json格式输出测试结果

  • -o file: 指定测试程序编译后生成的文件名

单元测试中常用的命令参数:

 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
# 对当前目录下的全部单元测试程序进行运行测试(也就是所有的xxx_test.go文件中的所有function都会运行)
$ go test
the rectangular name's result is ok
the rectangular volume's result is ok
PASS
ok  	_/Users/BGBiao/unittest	0.005s

# 查看详细的单元测试结果
# (go test -v 等同于go test -args -test.v)
$ go test -v
=== RUN   TestSetSomething
the rectangular name's result is ok
--- PASS: TestSetSomething (0.00s)
=== RUN   TestGetSomething
the rectangular volume's result is ok
--- PASS: TestGetSomething (0.00s)
PASS
ok  	_/Users/BGBiao/unittest	0.005s

# 指定单元测试function来进行测试(-run参数可以指定正则匹配模式-run="test1|test2")
# go test -v -run functionname 
$ go test -v -test.run TestGetSomething
=== RUN   TestGetSomething
the rectangular volume's result is ok
--- PASS: TestGetSomething (0.00s)
PASS
ok  	_/Users/BGBiao/unittest	0.005s

单元测试注意事项

注意: 在单元测试时,一个比较重要的事情就是如何构造测试数据,因为通常我们能够想到的测试数据都是在预期之中的,有些核心逻辑的测试数据往往不能考虑到,因此构造测试数据时可考虑如下几个方面:

    1. 正常输入: 正常的可预测的测试用例
    1. 边界输入: 极端情况下的输入来测试容错性
    1. 非法输入: 输入异常数据类型,整个逻辑是否能够正常处理或者捕获
    1. 白盒覆盖: 需要设计的测试用例能够覆盖所有代码(语句覆盖、条件覆盖、分支覆盖、分支/条件覆盖、条件组合覆盖)

注意: 在写项目时,对于基础的工具层util的逻辑代码,一定要进行全方位,多场景的进行测试,否则当项目大起来后到处引用可能会造成较大麻烦;其次,我们的代码逻辑通常是更新迭代的,单元测试代码也应该进行定期更新.


华丽的分割线

Golang的测试断言工具

注意:testing包中包含了一些常用的断言工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func TestPrint(t *testing.T) {
    // 输出测试日志
    t.Logf()
    // 标记错误,但仍然执行后面的语句
    t.Fail()
    // 获取是否当前用例是执行错误的
    t.Failed()
    // 错误输出,等于 t.Logf 再执行 t.Fail()
    t.Errorf("%s", "run ErrorF")
    // 标记函数错误,并中断后面的执行
    t.FailNow()
    // 致命错误输出,等同于调用了 t.Logf 然后调用 t.FailNow()
    t.Fatalf("%s", "run Fatelf")
    // 测试用例的名字
    t.Name()
    //运行子测试用例
    t.Run()
    // 跳过后面的内容,后面将不再运行
    t.SkipNow()
    // 告知当前的测试是否已被忽略
    t.Skipped()
    // 并行测试
    t.Parallel()
}

测试覆盖率统计

注意:Golang内置工具包中也提供了测试覆盖率相关的工具,go test常用参数如下:

  • -cover: 是否开启覆盖测试率统计的开关.(当有-covermode-coverpkg-coverprofile参数时会自动打开)
  • -covermode: 设置覆盖测试率模式(可选值:set,count,atomic). set(默认)仅统计语法块是否覆盖;count 会统计语法块覆盖了多少次;atomic 用于多线程测试中统计语法块覆盖了多少次
  • -coverpkg: 指定覆盖率统计package的范围(默认只统计有执行了测试的packages)
  • -timeout: 指定单个测试用例的超时时间,默认10分钟
  • -coverprofile: 指定覆盖率profile文件的输出地址

第三方的测试覆盖统计:

goconvey
goconvey
codecov

1
2
3
4
5
6
7
8
9
# 使用golang内置的工具来执行覆盖测试,执行之后生成.test的执行文件,执行后会执行所有单元测试代码,然后输出覆盖率的报告
$ go test -c -covermode=count -coverpkg ./
➜  unittest git:(master) ✗ ls
area.go       area_test.go  unittest.test
➜  unittest git:(master) ✗ ./unittest.test
the rectangular name's result is ok
the rectangular volume's result is ok
PASS
coverage: 100.0% of statements in ./

统计单元测试的覆盖率,也就是白盒测试的覆盖率.

覆盖率测试报告:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 将测试覆盖率结果写入一个数据文件
$ go test -coverpkg=./ -coverprofile=coverage.data -timeout=5s

# 将覆盖率报告数据文件转化成对应的人类可识别模式(go tool cover可查看覆盖率相关的工具)
$  go tool cover -func=coverage.data -o coverage.txt
➜  unittest git:(master) ✗ cat coverage.txt
/Users/BGBiao/unittest/area.go:19:	Newbox		100.0%
/Users/BGBiao/unittest/area.go:23:	SetName		100.0%
/Users/BGBiao/unittest/area.go:27:	SetSize		100.0%
/Users/BGBiao/unittest/area.go:33:	GetName		100.0%
/Users/BGBiao/unittest/area.go:37:	GetVolume	100.0%
total:										(statements)	100.0%

# 转化成html格式(会在本地生成html文件)
$ go tool cover -html=coverage.data -o coverage.html

# 直接以html形式展示覆盖测试率报告
$ go tool cover -html=coverage.data

基准测试

基准测试是测量一个程序在固定工作负载下的性能。在Golang中,基准测试函数和普通的单元测试函数写法类似,同样需要遵循以下规则:

  • 1.函数以Benchmark开头
  • 2.函数参数为b *testing.B (区别于单元测试的t *testing.T)

注意: *testing.B参数提供了一些额外的性能测量相关的方法,同时还提供了一个随机整数N,用于限定执行的循环次数。

 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
# 编写benchmark函数
func Benchmark_GetSomething(b *testing.B) {
    box := Newbox()
    volume := 0
    for i := 0; i < b.N; i++ {
        box.SetSize(10,1111,2222)
        volume = box.GetVolume()
    }
    b.Log(volume)
}

# 运行测试(运行所有的基准测试,-bench可以指定函数名,-benchmem可以指定分配内存的次数和字节数)
# 和单元测试不同的是,我们需要使用-bench来手工指定需运行的基准测试函数(.表示全部的基准测试函数)
# 如下输出结果表示:GOMAXPROCS为4核心,每次调用GetSomething函数平均花费0.35ns(调用了2000000000次)
$ go test -v -run="none" -bench=Benchmark_GetSomething -benchmem
goos: darwin
goarch: amd64
Benchmark_GetSomething-4   	2000000000	         0.35 ns/op	       0 B/op	       0 allocs/op
--- BENCH: Benchmark_GetSomething-4
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
PASS
ok  	_/Users/BGBiao/unittest	0.749s

性能分析

注意: 当我们的程序在运行过程中可能会消耗非常多的资源(通常是程序性价比较低时,比如处理一个很小的数据,却占用了几个G的内存,并且CPU长期处于高负荷状态),此时我们就需要通过一些技术手段来分析程序性能损耗点,以此来提高程序的性价比。

Go语言支持多种类型的剖析性能分析,每一种关注不同的方面,但它们都涉及到每个采样记录的感兴趣的一系列事件消息,每个事件都包含函数调用时函数调用堆栈的信息。基本上常用的为MEM分析CPU分析以及block分析

  • MEM分析: 主要是堆分析,可以标识出最耗内存的逻辑,内置库会会记录调用内部内存分配的操作,平均每512KB的内存申请会触发一个剖析数据.
  • CPU分析: 可以标识最耗CPU时间的函数,每个CPU上运行的线程在每隔几毫秒都会遇到操作系统的中断事件,每次中断时都会记录一个剖析数据然后恢复正常的运行.
  • Block分析: 记录阻塞goroutine最久的操作,例如系统调用、管道发送和接收,还有获取锁; 当goroutine被这些操作阻塞时,剖析库都会记录相应的事件.

注意: 剖析对于长期运行的程序尤其有用,因此可以通过调用Go的runtime API来启用运行时剖析。

1
2
3
4
# 通过不同的参数来获取指定性能分析数据
$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out

一旦我们通过上述内置工具获取到相关的分析数据,我们就可以使用pprof来分析数据,使用go help pprof 可以查看更多帮助信息,最常用的即: 生成这个概要文件的可执行程序和对应的剖析数据

 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
33
34
35
# 获取CPU基准测试数据
$ go test  -run="none" -bench=Benchmark_GetSomething -cpuprofile=cpu.log
goos: darwin
goarch: amd64
Benchmark_GetSomething-4   	2000000000	         0.36 ns/op
--- BENCH: Benchmark_GetSomething-4
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
    area_test.go:40: 24686420
PASS
ok  	_/Users/BGBiao/unittest	0.944s
➜  unittest git:(master) ✗ ls
area.go       area_test.go  cpu.log       unittest.test

# 之后会生成测试程序和cpu分析数据(unittest.test和cpu.log) 
# 使用pprof工具分析相关数据(-text用于指定输出格式;-nodecount=10限制只输出前10行结果)
$ go tool pprof -text -nodecount=10 ./unittest.test cpu.log
File: unittest.test
Type: cpu
Time: Nov 11, 2019 at 12:12pm (CST)
Duration: 938ms, Total samples = 680ms (72.49%)
Showing nodes accounting for 680ms, 100% of 680ms total
      flat  flat%   sum%        cum   cum%
     510ms 75.00% 75.00%      680ms   100%  _/Users/BGBiao/unittest.Benchmark_GetSomething
     170ms 25.00%   100%      170ms 25.00%  _/Users/BGBiao/unittest.(*box).GetVolume
         0     0%   100%      680ms   100%  testing.(*B).launch
         0     0%   100%      680ms   100%  testing.(*B).runN

# web可视化分析(会弹出web页面,可查看程序每个逻辑的cpu使用)
$ go tool pprof -http=:8080 -nodecount=10 ./unittest.test cpu.log

# 对应的,我们也可以使用-memprofile参数来获取内存分析数据,来查看处理逻辑对内存的消耗状况

cpu性能分析

mem分配

示例程序

Go性能测试、单元测试以及代码覆盖率

知识星球

公众号