前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【7/30】测试:小心并发测试中的测试陷阱

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

作者头像
LIYI
发布2021-02-23 16:04:12
1.7K0
发布2021-02-23 16:04:12
举报
文章被收录于专栏:艺述论专栏艺述论专栏

这是《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=%d\n",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=%v\n",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=%d\n",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=%d\n",v.in, res)
        }()
    }
    // fmt.Printf("%v\n",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("123\n")
    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
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-01-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 艺述论 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 四类测试三种方式
  • 单元测试:TestXxx
  • 并发执行单元测试
  • 如何执行子测试?如何以树状次序执行测试
  • 一个关于并发引起的堆、栈内存的问题
  • 使用基准测试(BenchmarkXxx)调试算法
  • 示例测试:ExampleXxx
  • 关于TestMain
  • 更多相关的问题
    • T类型中方法
      • 关于逃逸分析(Escape analysis)
        • 关于-gcflags编译参数
          • 关于覆盖率
            • 如何查看Go语言程序的汇编代码?
              • 什么是Go语言中的闭包?举个例子
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档