这是《Go语言简易入门》系列内容第
7
篇,所有内容列表见:https://yishulun.com/books/go-easy/目录.html。所有源码及资料在“程序员LIYI”公号回复“Go语言简易入门”获取。
四类测试三种方式
Go语言提供了testing基础类库和go test
指令,不使用第三方类库就可以完成常见的测试工作。软件研发中的测试工作一般分为四类,范围从小到大排列依次是:单元测试、集合测试、链路测试和UI测试。其中链路测试、UI测试位于测试金字塔的顶端,一般划分为黑盒测试范畴,用QA人力保证;单元测试与集合测试属于白盒测试,繁杂而精细,可以依靠测试代码自动完成。
图:测试金字塔之单元测试、集合测试、端到端测试(链路测试)、UI测试
我们先看一下单元测试怎么搞。单元测试是最基本的测试,就是对软件中最基础的功能,对某个函数、某个接口、某个配置等代码进行测试。测试方法有三种:
- 基本的单元测试:TestXxx
- 基准测试:BenchmarkXxx
- 示例测试:ExampleXxx
接下来分别看一下这三种方法在Go语言中怎么用,先看基本的单元测试TestXxx。
单元测试:TestXxx
在Go语言的测试哲学中,大量使用了基于命名的约定俗成的规则。例如单元测试,就是以“TestXxx”这样的格式编写,前缀是Test,后面是一个大写的单词,一般是名词。如果后面还需要附加其它说明,一般是添加一个“_Xxx”这样的后缀。
看一个示例:
代码语言:javascript复制// go-easy/7/case/fibonacci_test.go
package fibonacci_test
import (
. "gitee.com/rxyk/go-easy/rixingyike/str"
"testing"
)
func TestFibonacci(t *testing.T) {
// 0,1,1,2,3,5,8,13
for _, v := range []struct{
in,expected int
}{
{1,1},
{2,1},
{3,2},
{5,5},
{7,13},
} {
if res := Fibonacci(v.in); res != v.expected {
t.Errorf("Fibonacci(%d) == %d, want %d", v.in, res, v.expected)
}
}
}
源码见:go-easy/7/case/fibonacci_test.go
执行指令:
代码语言:javascript复制go test case/fibonacci_test.go
将输出:
代码语言:javascript复制ok command-line-arguments 0.051s
使用Table-Driven技巧
在这个示例中,使用了一种被称之为Table-Driven的编程技巧:
代码语言:javascript复制for _, v := range []struct{
in,expected int
}{
{1,1},
{2,1},
{3,2},
{5,5},
{7,13},
} {...}
这里匿名声明了一个结构体,并马上实体化,得到了一个结构体数组,然后再循环这个数组,依次测试。在结构体中定义了每次测试所需的输入条件和输出结果。
点引入
这个示例中还使用了一种点引入的包操作:
代码语言:javascript复制. "gitee.com/rxyk/go-easy/rixingyike/str"
将点放在包名前面,代表此包内的方法允许不带包名访问。例如str包中的Fibonacci函数,此时就可以直接访问了:
代码语言:javascript复制Fibonacci(v.in)
fibonacci_test.go这个文件没有main函数,它内部只有TestXxx这样格式的测试函数,这样的函数在执行go test
指令时会自动被执行。此处,这个文件中的包名是fibonacci_test,它与我们测试的目标包名str是不一致的,这是被充许的,并且一般也这样处理。这样既可以避免相互循环引用,还方便在独立的目录中编写模块测试代码。
在我们的str包中,一共有两个实现斐波那契数列的函数:
代码语言:javascript复制// Fibonacci 此函数计算斐波那契数列中第 N 个数字
func Fibonacci(n int) int {
switch n<2 {
case true:
return n
default:
return Fibonacci(n-1) Fibonacci(n-2)
}
}
// Fibonacci2 ...
func Fibonacci2() func() int {
a, b := 0, 1
return func() int {
a, b = b, a b
return a
}
}
第一个测试单元测试函数TestFibonacci,测试的是Fibonacci函数。接下来我们再于fibonacci_test.go文件中添加另一个测试函数:
代码语言:javascript复制func TestFibonacci2(t *testing.T) {
// 0,1,1,2,3,5,8,13
for _, v := range []struct{
in,expected int
}{
{1,1},
{2,1},
{3,2},
{5,5},
{7,13},
} {
var f = Fibonacci2()
var res = 0
for j:=0;j<v.in;j {
res = f()
}
if res != v.expected {
t.Errorf("Fibonacci2(%d) == %d, want %d", v.in, res, v.expected)
}
}
}
同样执行指令:
代码语言:javascript复制go test case/fibonacci_test.go
输出:
代码语言:javascript复制ok command-line-arguments 0.083s
原来是0.051s,现在是0.083s,时间变长了,为什么?
因为在go test指令启动的测试中,各个文件之间是并发的,但每个文件中的TestXxx函数是串行的。
对于没有相互依赖关系的测试函数,能不能让它们并发?
并发执行单元测试
答案是可以的。除了把它们编写在不同的文件中,还有一种更为简单直接的方法,就是使用testing.Parallel()方法。
现在将fibonacci_test.go文件复制一份,命名为fibonacci_test2.go,修改其代码添加Parallel方法:
代码语言:javascript复制// go-easy/7/case/fibonacci2_test.go
package fibonacci_test
import (
. "gitee.com/rxyk/go-easy/rixingyike/str"
"testing"
)
func TestFibonacci_2(t *testing.T) {
t.Parallel()
...
}
func TestFibonacci2_2(t *testing.T) {
t.Parallel()
...
}
现在执行并发测试指令:
代码语言:javascript复制go test -parallel=2 case/fibonacci2_test.go
输出:
代码语言:javascript复制ok command-line-arguments 0.039s
指令中的参数-parallel=2,代表同时执行2个用于测试的Go程。这个设置还受限于GOMAXPROCS,可以使用runtime.GOMAXPROCS(runtime.NumCPU())
修改最大可同时执行的Go程数,让电脑中的所有CPU最大限度同时干活。
除了在不同测试函数中标注Parallel,开启开发测试,还有没有其它更简单的方法?
如何执行子测试?如何以树状次序执行测试
答案也是有的。可以使用子测试。子测试允许在一个单元测试启动后,后续并发执行一单元测试。我们看一下示例:
代码语言:javascript复制// go-easy/7/case/fibonacci3_test.go
func TestFibonacci_3(t *testing.T) {
t.Parallel()
// 0,1,1,2,3,5,8,13
for _, v := range []struct{
in,expected int
}{
{1,1},
{2,1},
{3,2},
{5,5},
{7,13},
} {
t.Run(fmt.Sprintf("name%d",v.in), func(t *testing.T) {
t.Parallel()
res := Fibonacci(v.in)
t.Logf("in:%d,res=%dn",v.in, res)
assert.Equal(t, v.expected, res)
})
}
}
执行如下指令:
代码语言:javascript复制go test -parallel=5 case/fibonacci3_test.go
得到输出:
代码语言:javascript复制ok command-line-arguments 0.077s
加了t.Parallel(),是并发执行;不加,是串发依次执行。
看一个伪代码:
代码语言:javascript复制t.Run("group", func(t *testing.T) {
t.Run("Test1", Test1Handler)
t.Run("Test2", Test2Handler)
t.Run("Test3", Test3Handler)
})
这是定义一个群单元测试,每个子测试又可以分化出一个组,每个组都可以串发或并发,这样就实现了树状的测试次序,对于编写有先决执行条件的测试,这个机制可以利用上。
在并发执行测试的时候,有一个问题必须注意。
一个关于并发引起的堆、栈内存的问题
我们知道,Go程序中的内存分配有堆与栈之分。一般情况下,在主程或子程中启用一个子Go程,这个子Go程里的变量是在栈上分配的。等子Go程执行完成后,栈里的变量就自动释放了。但有时间并不是这样的,规则没有这么简单。我们看一个简单的示例:
代码语言:javascript复制// go-easy/7/mem.go
package main
import ("fmt")
type Cursor struct{
X int
}
func f() *Cursor {
var c Cursor
c.X = 500
return &c
}
func main() {
v := f()
fmt.Printf("c=%vn",v)
}
输出:
代码语言:javascript复制c=&{500}
在函数f()中,c本来是一个函数内的局部变量,是分配在栈上的,但因为f()返回了它的内存指针,并在main()中使用了,所以它实际上又逃逸到了堆上。有一种分析变量是在堆上、还是在栈上的技术,叫逃逸分析。我们看一下如何分析,在终端执行如下指令:
代码语言:javascript复制go build -gcflags=-m mem.go
在这个指令中,-gcflags
是给编译器传递参数,-m
代表输出内存分配提示。
输出:
代码语言:javascript复制# command-line-arguments
./mem.go:9:6: can inline f
./mem.go:16:8: inlining call to f
./mem.go:17:12: inlining call to fmt.Printf
./mem.go:10:13: new(Cursor) escapes to heap
./mem.go:16:8: new(Cursor) escapes to heap
./mem.go:17:12: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
输出结果第5、6行有“escapes to heap”的提示,这就是“逃逸到堆”。
Go语言编译器比较聪明,它知道何时该在栈上分配内存,何时该在堆上分配,以期将执行效果发挥到更大。关于堆、栈内存我们先了解到这里,接下来看关键问题,并发是如何引起堆、栈内存问题的。先看一个示例:
代码语言:javascript复制// go-easy/7/case/fibonacci4_test.go
package fibonacci_test
import (
. "gitee.com/rxyk/go-easy/rixingyike/str"
"testing"
"fmt"
"github.com/stretchr/testify/assert"
)
func TestFibonacci_4(t *testing.T) {
t.Parallel()
// 0,1,1,2,3,5,8,13
for _, v := range []struct{
in,expected int
}{
{1,10},
{2,10},
{3,20},
{5,50},
{7,13},
} {
// v := v
t.Run(fmt.Sprintf("name%d",v.in), func(t *testing.T) {
t.Parallel()
res := Fibonacci(v.in)
t.Logf("in:%d,res=%dn",v.in, res)
assert.Equal(t, v.expected, res)
})
}
}
执行:
代码语言:javascript复制go test case/fibonacci4_test.go
输出:
代码语言:javascript复制ok command-line-arguments 0.049s
输出显示测试成功。这是Table-Driven的数据是无效的:
代码语言:javascript复制{1,10},
{2,10},
{3,20},
{5,50},
{7,13},
这个数列根本不是斐波那契数列。事实上在这个数组中,只要最后一组数组对,前面的expected是几根本无关紧要。
为什么会这样?
因为所有在第24行并发执行子单元测试,取到的v全部是{7,13}
这一行。
而如果我们将第23行代码注释掉,在这里“脱裤子放屁”,将变量v重新声明一下,问题就解决了,该暴露的错误就会暴露出来了。
为什么?
回想一下前面我们讲的关于堆、栈内存分配的问题。如果没有第23行看以多余的代码,变量v是分配在堆上的;而有了这行代码后,临时变量v重新分配到了栈上。当变量在堆上时,每个并发的单元测试取到的都是同一个内存数据的数据,也就是for最后的循环值;而当变量在栈上时,每个Go程(一个单元测试是一个独立的Go程)都有自己的栈,相互之间不会影响。
我们可以用一个并发的Go程示例验证这个问题:
代码语言:javascript复制// go-easy/7/multi/multigoroutine.go
package main
import (
"gitee.com/rxyk/go-easy/rixingyike/str"
"fmt"
"time"
)
func main() {
for _, v := range []struct{
in,expected int
}{
{1,10},
{2,10},
{3,20},
{5,50},
{7,13},
} {
// v := v
go func(){
time.Sleep(time.Millisecond)
res := str.Fibonacci(v.in)
fmt.Printf("in:%d,res=%dn",v.in, res)
}()
}
// fmt.Printf("%vn",v)
// v是for循环退出后,被gc回收了,所以不能访问了
time.Sleep(time.Second)
}
执行程序,将输出:
代码语言:javascript复制in:7,res=13
in:7,res=13
in:7,res=13
in:7,res=13
in:7,res=13
看到了吧,输出的都是结构体数组中的最后一组值。
看完了查验程序功能性的基本单元测试,再看一下另外两种测试类似:基准测试与示例测试。
使用基准测试(BenchmarkXxx)调试算法
现在我们的程序中有两个斐[fěi]波那契数列算法,到底哪个算法更好,可以使用查验代码性能的基准测试方法。
看一下基准测试示例:
代码语言:javascript复制//
package fibonacci_test
import (
. "gitee.com/rxyk/go-easy/rixingyike/str"
"testing"
)
// 基准测试
func BenchmarkFibonacci_10(b *testing.B) {
for n := 0; n < b.N; n {
Fibonacci(10) // 运行 Fibonacci 函数 N 次
}
}
// 基准测试2
func BenchmarkFibonacci2_10(b *testing.B) {
for n := 0; n < b.N; n {
var f = Fibonacci2()
for j := 0; j < 10; j {
f()
}
}
}
...
执行指令:
代码语言:javascript复制go test -bench=Fibonacci* ./case
输出:
代码语言:javascript复制goos: darwin
goarch: amd64
pkg: str/case
BenchmarkFibonacci_10-4 2576298 458 ns/op
BenchmarkFibonacci2_10-4 7319109 152 ns/op
BenchmarkFibonacci_20-4 20602 57007 ns/op
BenchmarkFibonacci2_20-4 6624660 191 ns/op
PASS
ok str/case 6.266s
ns代表纳秒。从测试结果来看,使用了Go语言双赋值特征的Fibonacci2算法效果更佳。
基准测试函数的参数类型是*testing.B
,数字属性b.N并不是我们决定的。默认情况下,每个基准测试最少运行 1 秒。如果基准测试函数返回时还不到 1 秒钟,b.N
的值会按照序列 1,2,5,10,20,50,... 增加,然后再次运行基准测试函数。
基准测试是我们调试算法的一个很不错的工具。接下来我们再看一下示例测试。
示例测试:ExampleXxx
示例测试是基于名称定义规则的典范,看一个示例:
代码语言:javascript复制// go-easy/7/case/example_test.go
package fibonacci_test
import (
. "gitee.com/rxyk/go-easy/rixingyike/str"
"fmt"
)
...
func ExampleFibonacci2() {
var f = Fibonacci2()
var res = 0
for j := 0; j < 5; j {
res = f()
}
fmt.Println(res)
// output: 5
}
示例测试函数以ExampleXxx这样的格式编写,在函数尾部使用// output:xxx
这样的格式定义输出内容。如果使用fmt类库打印的内容与定义的不一致,测试便会报错。
运行测试指令:
代码语言:javascript复制go test case/example_test.go
输出:
代码语言:javascript复制ok command-line-arguments 0.037s
现在在我们的子目录7下,已经有了单元测试、基准测试和示例测试。使用一个指令可以启动所有:
代码语言:javascript复制go test -bench=Fibonacci* ./...
参数-bench代表类包,支持正则表达式,如果不限制可以写“.”。
关于TestMain
现在我们了解了所有基本的测试技巧,也可以以并发、串发的方式组合进行复杂的测试了。还有一种情况需要了解,假设我们需要在一个单元测试启动之前做一些事情,以及在完成之后做一些事情,这种情况怎么处理?
当然这种情况也可以使用子测试解决,但Go语言提供了一种更方便的方法:TestMain。TestMain是测试文件中默认先测试的函数,函数中间要显式调用m.Run(),这时候才正式执行测试。测试之后的事情也可以在这里设置。m.Run()之前的代码Setup代码,之后的代码是Teardown代码。具体代码如下:
代码语言:javascript复制// go-easy/7/case/testmain_test.go
func TestMain(m *testing.M) {
flag.Parse() // 解析可能需要的参数
go func(){
StartServer2()
}()
exitCode := m.Run()
// 退出
os.Exit(exitCode)
}
func ExampleGetUser123() {
res, _ := http.Get("http://localhost:8080/user/123")
resBody, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
fmt.Printf("%s", resBody)
// output:123
}
这种方式是基于Go语言提供的http包进行测试。Go语言还提供了一种httptest测试包,但这个包与iris框架不是契合的。iris另提供了一个httptest,使用这个包方便测试使用iris编写的Web代码。看一个示例:
代码语言:javascript复制// go-easy/7/case/testmain_test.go
// 依托iris的httptest测试
func TestServerUser(t *testing.T) {
app := NewWebServer2()
e := httptest.New(t, app)
e.GET("/user/123").Expect().Status(httptest.StatusOK).Body().Equal("123n")
e.POST("/user/123").WithBytes([]byte(`{"name":"ly","city":"bj"}`)).Expect().Status(httptest.StatusOK).Body().Equal("{n "ID": 123,n "Name": "ly",n "City": "bj"n}n")
}
这个测试函数不需要TestMain协助,可以独立运行。
以上大概就是所有测试相关的技巧了,现在所有测试代码仍然可以通过一条指令统一执行:
代码语言:javascript复制go test -cover -bench=Fibonacci* ./...
更多相关的问题
T类型中方法
除了已经用过的Errorf,testing.T类型还有许多实用的方法:
- Fail : 测试失败,测试继续,也就是之后的代码依然会执行 FailNow : 测试失败,测试中断
- Log : 输出信息 Logf : 输出格式化的信息
- SkipNow : 跳过测试,测试中断 Skip : 相当于 Log SkipNow,跳过这个测试,并且打印出信息 Skipf : 相当于 Logf SkipNow
- Error : 相当于 Log Fail,标识测试失败,并打印出必要的信息,但是测试继续 Errorf : 相当于 Logf Fail
- Fatal : 相当于 Log FailNow,标识测试失败,打印出必要的信息,但中断测试 Fatalf : 相当于 Logf FailNow
关于逃逸分析(Escape analysis)
所以逃逸分析(Escape analysis)就是识别出变量需要在堆上分配,还是在栈上分配。如果内存分配在栈中,则函数执行结束可自动将内存回收;如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理。
在使用指令go build -gcflags=-m *.go
编译源码时,如何看到:
./main.go:20: moved to heap: c
./main.go:23: &c escapes to heap
类似“moved to heap”、“escapes to heap”这样的描述,表示变量发生逃逸了,变量已到堆中。
关于-gcflags编译参数
go build指令用-gcflags是给go编译器传入参数,也就是传给go tool compile的参数。值-m可以检查代码的编译优化情况,包括逃逸情况和函数是否内联等。
go build用-ldflags给go链接器传入参数,实际是给go tool link的参数。
关于覆盖率
在go test
指令中添加参数-cover
,可以查看测试覆盖率。但这种方式会修改源码,如果没有权限修改,覆盖率是不显示的。
如何查看Go语言程序的汇编代码?
最简单的办法是分两部分走。第一步先编译成目标文件:
代码语言:javascript复制go tool compile -N -l 文件.go
生成一个文件.o
文件,第二步查看指定函数的汇编代码:
go tool objdump -s 函数 文件.o
汇编代码难于阅读,指定函数方便查看。
什么是Go语言中的闭包?举个例子
闭包是函数式语言中的概念,Go语言是支持闭包的,看一个例子:
代码语言:javascript复制func f(i int) func() int {
return func() int {
i
return i
}
}
c1 := f(0)
c2 := f(0)
println(c1()) // output: 1
println(c2()) // output: 1
示例中函数f(int)返回了一个函数,返回的函数就是一个闭包。这个函数中本身是没有定义变量i的,而是引用了它所在的环境(函数f)中的变量i。
变量i是函数f中的局部变量,假设这个变量是在函数f的栈中分配的,是不可以的。因为函数f返回以后,对应的栈就失效了,f返回的那个函数中变量i就引用一个失效的位置了。所以闭包的环境中引用的变量不能够在栈上分配。
关于测试的内容有点多,我讲明白没有,欢迎留言讨论。
2021年1月26日
---
配套视频在视频号“程序员LIYI”同名标签下:
本文写作过程中参考了以下链接,一并致谢:
- https://go-zh.org/doc/code.html
- https://blog.csdn.net/chydn/article/details/78111248
- https://studygolang.com/articles/12587
- https://studygolang.com/articles/12135
- https://www.cnblogs.com/yjf512/p/10905352.html
- http://www.noteanddata.com/golang-learning-note-23-test-parallel-issues.html
- https://colobu.com/2018/12/29/get-assembly-output-for-go-programs/
- http://www.xiaot123.com/go-e5tbb
- https://studygolang.com/articles/20602