【7/30】测试:小心并发测试中的测试陷阱

2021-02-23 16:04:12 浏览数 (2)

这是《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编译源码时,如何看到:

代码语言:javascript复制
./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文件,第二步查看指定函数的汇编代码:

代码语言:javascript复制
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

0 人点赞