这个世界上没有一个人敢打包票说自己写的程序没有一个BUG,正因为如此,尽量早的提前发现BUG,并把BUG控制在可控数量和影响范围之内,就成为了更加务实且可以实际操作的事情。
随着现代程序的复杂性越来越庞大且复杂,为了让软件程序的复杂性得到控制,有两项技术越来越在实践中被证明是比较有效的。第一种是代码在被正式部署之前需要进行代码评审,第二种就是测试。
这一章节我们讨论的主要是自动化测试,暂时不包含专业的测试同学进行的业务测试。自动化测试,就是写一些小的程序用来检测被测试代码(产品代码)的行为和预期的一样,这些通常都是精心设计的执行某些特定的功能或者是通过随机性的输入待验证边界的处理。
软件测试是一个巨大的领域,每一种编程语言,都会有各种各样的测试包,也有大量的测试理论,更有很多领域专家再进一步研究和发展。
背景知识交代了这么多,我们就正式进入Go语言中的自动化测试吧!
一、go test命令
Go语言的测试技术是相对低级的。它依赖一个go test测试命令和一组按照约定方式编写的测试函数,测试命令可以运行这些测试函数。编写相对轻量级的纯测试代码是有效的,而且它很容易延伸到基准测试和示例文档。
go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以_test.go为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test测试的一部分。在*_test.go文件中,有三种类型的函数:
- 测试函数:以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确。go test命令会调用这些测试函数并报告测试结果是PASS或FAIL
- 基准测试(benchmark)函数:以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能。go test命令会多次运行基准测试函数以计算一个平均的执行时间
- 示例函数:以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档
go test命令的执行过程如下:命令会遍历所有的*_test.go文件中符合上述命名规则的函数,生成一个临时的main包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。
接下来分别总结下三类测试函数。
二、测试函数
要编写测试函数,需要导入testing包。测试函数必须以Test开头,可选的后缀名必须以大写字母开头,其中t参数用于报告测试失败和附加的日志信息。示例如下:
import "testing"
func TestSin(t *testing.T) {
if haveError {
t.Error(`have error,f(x) = y, want z`)
t.Errorf(`have error(%q)`, message)
}
}
编写好测试函数之后,在包目录下执行to test命令(如果没有参数指定包那么将默认采用当前目录对应的包),即可启动测试。示例中的Errorf是 类似Printf格式化功能的函数。
t.Error和t.Errorf并不会因为出错而停止后面的执行,所以一次测试中可以多次产生失败。如果需要停止测试,比如初始化失败或者早先的错误导致后续错误,我们可以使用使用t.Fatal或t.Fatalf停止当前测试函数。它们必须在和测试函数同一个goroutine内调用。
go test命令有很多可选的参数,列出如下:
- -v :可用于打印每个测试函数的名字和运行时间
- -run:对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行
随机测试
上面的表格测试,适用于构造基于精心挑选的测试数据的测试用例。而另外一种测试思路则是随机测试,即通过构造更加广泛的数据输入来探索函数行为,以获得更多可能出现的错误和失败测试的日志信息。示例如下:
import "math/rand"
// randomPalindrome returns a palindrome whose length and contents
// are derived from the pseudo-random number generator rng.
func randomPalindrome(rng *rand.Rand) string {
n := rng.Intn(25) // random length up to 24
runes := make([]rune, n)
for i := 0; i < (n+1)/2; i++ {
r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
runes[i] = r
runes[n-1-i] = r
}
return string(runes)
}
func TestRandomPalindromes(t *testing.T) {
// Initialize a pseudo-random number generator.
seed := time.Now().UTC().UnixNano()
t.Logf("Random seed: %d", seed)
rng := rand.New(rand.NewSource(seed))
for i := 0; i < 1000; i++ {
p := randomPalindrome(rng)
if !IsPalindrome(p) {
t.Errorf("IsPalindrome(%q) = false", p)
}
}
}
三、基准测试
基准测试是测量一个程序在固定工作负载下的性能。
和普通测试函数写法类似,但是以Benchmark为前缀名,并且带有一个*testing.B类型的参数;*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。示例代码如下:
import "math/rand"
func BenchmarkIsPalindrome(b *testing.B) {
for i := 0; i < b.N; i++ {
IsPalindrome("A man, a plan, a canal: Panama")
}
}
我们需要通过-bench命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数。
$ go test -bench=.
PASS
//这里的 -8,表示运行时对应的GOMAXPROCS的值
BenchmarkIsPalindrome-8 1000000 1035 ns/op
ok gopl.io/ch11/word2 2.179s
我们拿到基准测试的结果时,当然跟多的是为了进行性能优化。但有时候,我们可能并不知道具体应该从哪里开始着手优化,这个时候,一份详细的剖析数据就很有必要了!
剖析
剖析技术是基于程序执行期间一些自动抽样,然后在收尾时进行推断;最后产生的统计结果就称为剖析数据。Go语言支持多种类型的剖析性能分析,每一种关注不同的方面,但它们都涉及到每个采样记录的感兴趣的一系列事件消息,每个事件都包含函数调用时函数调用堆栈的信息。内建的go test工具对几种分析方式都提供了支持。主要有以下三种:
- CPU剖析数据:标识了最耗CPU时间的函数。在每个CPU上运行的线程在每隔几毫秒都会遇到操作系统的中断事件,每次中断时都会记录一个剖析数据然后恢复正常的运行。
$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out
但尽量开启时分别开启,因为同事开启可能会影响其他项的分析结果。收集到了这些采样数据,就可以使用pprof来分析这些数据。对应于go tool pprof命令。该命令有许多特性和选项,但是最基本的是两个参数:生成这个概要文件的可执行程序和对应的剖析数据。
虽然go test通常在测试完成后就丢弃临时用的测试程序,但是在启用分析的时候会将测试程序保存为foo.test文件,其中foo部分对应待测包的名字。下面是一个例子:
$ go test -run=NONE -bench=ClientServerParallelTLS64 \
-cpuprofile=cpu.log net/http
PASS
BenchmarkClientServerParallelTLS64-8 1000
3141325 ns/op 143010 B/op 1747 allocs/op
ok net/http 3.395s
$ go tool pprof -text -nodecount=10 ./http.test cpu.log
2570ms of 3590ms total (71.59%)
Dropped 129 nodes (cum <= 17.95ms)
Showing top 10 nodes out of 166 (cum >= 60ms)
flat flat% sum% cum cum%
1730ms 48.19% 48.19% 1750ms 48.75% crypto/elliptic.p256ReduceDegree
230ms 6.41% 54.60% 250ms 6.96% crypto/elliptic.p256Diff
120ms 3.34% 57.94% 120ms 3.34% math/big.addMulVVW
110ms 3.06% 61.00% 110ms 3.06% syscall.Syscall
90ms 2.51% 63.51% 1130ms 31.48% crypto/elliptic.p256Square
70ms 1.95% 65.46% 120ms 3.34% runtime.scanobject
60ms 1.67% 67.13% 830ms 23.12% crypto/elliptic.p256Mul
60ms 1.67% 68.80% 190ms 5.29% math/big.nat.montgomery
50ms 1.39% 70.19% 50ms 1.39% crypto/elliptic.p256ReduceCarry
50ms 1.39% 71.59% 60ms 1.67% crypto/elliptic.p256Sum
参数-text用于指定输出格式,在这里每行是一个函数,根据使用CPU的时间长短来排序。其中-nodecount=10参数限制了只输出前10行的结果。对于严重的性能问题,这个文本格式基本可以帮助查明原因了。
四、示例函数
以Example为函数名开头。示例函数没有函数参数和返回值。下面是IsPalindrome函数对应的示例函数:
func ExampleIsPalindrome() {
fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
fmt.Println(IsPalindrome("palindrome"))
// Output:
// true
// false
}
示例函数作用有三:
- 作为文档,一个包的例子可以更简洁直观的方式来演示函数的用法,比文字描述更直接易懂。示例函数和注释并不一样,示例函数是真实的Go代码,需要接受编译器的编译时检查,这样可以保证源代码更新时,示例代码不会脱节
- 在go test执行测试的时候也会运行示例函数测试。如果示例函数内含有类似上面例子中的// Output:格式的注释,那么测试工具会执行这个示例函数,然后检查示例函数的标准输出与注释是否匹配
- 提供一个真实的演练场。示例代码被godoc关联为函数文档的一部分,然后就可以使用Go Playground让用户可以在浏览器中在线编辑和运行每个示例函数,时学习Go语言的函数使用和特性理解更有帮助
关于go test命令和三种测试函数就总结到这里,你都学废了吗?