Golang 13_Golang测试test

一、Golang的测试Testing

1.1 Golang的测试Testing简介

Golang 对单元测试的支持已经相当友好,它自带的原生 go 标准库 testing 测试包,就是专门用来进行单元测试而编写的。go test 用于对 Golang 项目代码进行自动化的测试并输出相关测试的验证结果。

go test 命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以 _test.go 为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中

Golang 测试规则 Golang 测试文件名必须以 _test.go 结尾,Golang 会通过文件名来识别哪些是测试文件,测试源码文件可以由多个测试用例(可以理解为函数)组成,每个测试用例(函数)的名称需要以特定前缀(Test 或 Benchmark 等)开头,执行 go test 的时候会加载这些测试文件 并执行其中的测试用例。

在 *_test.go 文件中有三种类型的函数:单元测试函数基准测试函数示例函数

类型 格式 作用
测试函数 函数名前缀为Test 测试程序的一些逻辑行为是否正确
基准函数 函数名前缀为Benchmark 测试函数的性能
示例函数 函数名前缀为Example 为文档提供示例文档

go test 命令会遍历所有的*_test.go文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

编写测试用例有以下几点需要注意:

  • 测试用例文件不会参与正常源码的编译,不会被包含到可执行文件中;
  • 测试用例的文件名必须以 _test.go 结尾;
  • _test.go测试文件通常和需要被测试的文件放在同一个包内;
  • _test.go测试文件中通常需要使用 import 导入 testing 包;
  • 测试函数(测试用例)的名称要以 Test 或 Benchmark 开头,后面可以跟任意字母组成的字符串(一般跟测试函数名),但第一个字母必须大写,例如 TestAbc(),一个测试用例文件中可以包含多个测试函数;
  • **单元测试以 (t testing.T) 作为参数,性能测试以 (t testing.B) 做为参数
  • 在测试用例函数体中编写测试代码,如果认为测试不通过,采用 t.Fatal("…") 的方式抛出异常;如果没有异常正常地结束了函数体的运行,则被视作测试已通过;
  • 测试用例文件使用 go test 命令来执行,源码中不需要 main() 函数作为入口,所有以 _test.go 结尾的源码文件内以 Test 开头的函数都会自动执行;
  • 执行过程中可以使用 t.Log(…) 等方式输出日志文本,类似地 t.Fatal 也会输出日志文件,以报错的形式;

Go语言的 testing 包提供了三种测试方式,分别是:单元(功能)测试(testing.T)性能(压力)测试(testing.B)覆盖率测试

Tips: https://www.topgoer.com/%E5%87%BD%E6%95%B0/%E5%8D%95%E5%85%83%E6%B5%8B%E8%AF%95.html https://www.zixuephp.com/webshouce/go/

1.2 go test的参数解读

Go语言中的测试依赖 go test 命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。

go test 命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go为后缀名的源代码文件都是 go test 测试的一部分,不会被go build编译到最终的可执行文件中。

1
go test [build/test flags] [packages] [build/test flags & test binary flags]

参数解读:

  • -c:

  • -args 传递命令行的剩余部分(-args之后的所有内容) 到测试二进制文件,未解释且未更改。 因为该标志消耗了命令行的剩余部分, 包列表(如果存在)必须出现在此标志之前。 Pass the remainder of the command line (everything after -args) to the test binary, uninterpreted and unchanged. Because this flag consumes the remainder of the command line, the package list (if present) must appear before this flag.

  • -c 把包下的所有测试用例(_test.go)编译成为一个名为 包名.test 的二进制可执行文件, 但是不运行测试,运行该可执行文件即可测试; Compile the test binary to pkg.test but do not run it (where pkg is the last element of the package’s import path). The file name can be changed with the -o flag.

  • -exec xprog Run the test binary using xprog. The behavior is the same as in ‘go run’. See ‘go help run’ for details.

  • -json Convert test output to JSON suitable for automated processing. See ‘go doc test2json’ for the encoding details.

  • -o file Compile the test binary to the named file. The test still runs (unless -c or -i is specified).

  • -test.bench regexp/patten 仅运行与 regexp 匹配的基准测试 run only benchmarks matching regexp

  • -test.benchmem 打印基准测试的内存分配信息 print memory allocations for benchmarks

  • -test.benchtime d 指定运行每个基准测试持续时间d(默认为1s),单位为秒(s) run each benchmark for duration d (default 1s)

  • -test.blockprofile file 将内部goroutine阻塞的性能分析信息写入文件 file write a goroutine blocking profile to file

  • -test.blockprofilerate rate 设置阻塞配置文件速率(请参阅runtime.SetBlockProfileRate)(默认值1) 基本与 -test.memprofilerate相同,控制的是goroutine阻塞时候打点的纳秒数。默认不设置就相当于-test.blockprofilerate=1,每一纳秒都打点记录一下 set blocking profile rate (see runtime.SetBlockProfileRate) (default 1)

  • -test.count n 运行单元测试 和 基准测试n次(默认为1次) run tests and benchmarks n times (default 1)

  • -test.coverprofile file 将覆盖范围信息文件写入文件 write a coverage profile to file

  • -test.cpu list 指定测试程序运行在哪些 cpu 上,使用二进制的1所在位代表(1,2,4) comma-separated list of cpu counts to run each test with

  • -test.cpuprofile file 输出cpu性能分析到文件 file write a cpu profile to file

  • -test.failfast 第一次测试失败后不要启动新的测试 do not start new tests after the first test failure

  • -test.fuzz regexp 运行与regexp匹配的模糊测试 run the fuzz test matching regexp

  • -test.fuzzcachedir string directory where interesting fuzzing inputs are stored (for use only by cmd/go)

  • -test.fuzzminimizetime value time to spend minimizing a value after finding a failing input (default 1m0s)

  • -test.fuzztime value time to spend fuzzing; default is to run indefinitely

  • -test.fuzzworker coordinate with the parent process to fuzz random values (for use only by cmd/go)

  • -test.gocoverdir string 将覆盖率中间文件写入该目录 write coverage intermediate files to this directory

  • -test.list regexp 列出与 regexp 匹配的 单元测试、示例 和 基准测试,然后退出 list tests, examples, and benchmarks matching regexp then exit

  • -test.memprofile file write an allocation profile to file

  • -test.memprofilerate rate 内存性能分析的时候有一个分配了多少的时候才打点记录的问题。这个参数就是设置打点的内存分配间隔,也就是profile中一个sample代表的内存大小。默认是设置为512 * 1024的。如果你将它设置为1,则每分配一个内存块就会在profile中有个打点,那么生成的profile的sample就会非常多。如果你设置为0,那就是不做打点了。你可以通过设置memprofilerate=1和GOGC=off来关闭内存回收,并且对每个内存块的分配进行观察。 set memory allocation profiling rate (see runtime.MemProfileRate)

  • -test.mutexprofile string write a mutex contention profile to the named file after execution

  • -test.mutexprofilefraction int if >= 0, calls runtime.SetMutexProfileFraction() (default 1)

  • -test.outputdir dir write profiles to dir

  • -test.paniconexit0 panic on call to os.Exit(0)

  • -test.parallel n 并行运行最多 n 个测试(默认为6个) run at most n tests in parallel(并行的) (default 6)

  • -test.run regexp 仅运行与 regexp 匹配的测试用例 run only tests and examples matching regexp

  • -test.short 运行较小的测试套件以节省时间 run smaller test suite to save time

  • -test.shuffle string 随机化 单元测试 和 基准测试 的执行顺序(默认为“关闭”) randomize the execution order of tests and benchmarks (default “off”)

  • -test.skip regexp 不要列出或运行与regexp匹配的测试 do not list or run tests matching regexp

  • -test.testlogfile file 将测试操作日志写入文件(仅限cmd/go使用) write test action log to file (for use only by cmd/go)

  • -test.timeout d 如果测试用例运行时间超过t,则抛出panic panic test binary after duration d (default 0, timeout disabled)

  • -test.trace file 将执行跟踪写入文件 file write an execution trace to file

  • -test.v 打印附加输出 verbose: print additional output

go test 命令会遍历所有的 *_test.go 文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

运行测试用例指令:

1
2
3
4
5
6
7
8
9
go test     # 运行整个项目的测试文件,如果运行正确,无日志;错误时,会输出日志
go test -v  # 运行正确或是错误,都输出日志,查看详细的结果
go test -v cal_test.go cal.go   # 测试单个文件,一定要带上被测试的源文件
go test -v cal.go cal_test.go   # 测试文件和被测试文件的顺序可以交换
go test -v -test.run TestAddUpper   # 测试单个方法
go test -v -run="TestAdd"       # 测试某个函数,-run支持正则,如果还有其它 TestAdd 开头的测试函数也会被运行
go test -v -run="TestAdd" -c    # 生成 test 的二进制文件:加 -c 参数
go test -v -o math.test         # 执行这个 test 测试文件:加 -o 参数
go test -i                      # 测试安装/重新安装 依赖包,而不运行代码:加 -i 参数,没有输出

测试输出结果中,PASS 表示测试运行成功(通过),FAIL表示测试失败(未通过)。

社区里有很多自动生成表格驱动测试函数的工具,比如gotests等,很多编辑器如Goland也支持快速生成测试文件。这里简单演示一下gotests的使用。

1
2
3
4
# 安装
go get -u github.com/cweill/gotests/...
# 执行
gotests -all -w split.go

上面的命令表示,为split.go文件的所有函数生成测试代码至split_test.go文件(目录下如果事先存在这个文件就不再生成)。

二、单元(功能)测试

2.1 单元(功能)测试简介

软件程序是由很多单元组件构成的。单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较,从而进行正确性检验的测试工作。如果单元测试不通过,要么代码有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。

单元测试一个最大的好处,就是确保一个程序模块的行为符合我们设计的预期,在将来对代码进行修改/重构时,还能最大限度地保证代码的行为仍然正确。

Golang 的单元测试主要是针对任意一个具体的函数而言,无论是一个可导出的函数接口,或者是一个不可导出的内部工具函数,都可以针对这个函数做一组测试,目的在于测试证明该函数的功用与其所宣称的相同。

单元测试的每个测试函数必须导入testing包,测试函数的基本格式(签名)如下:

1
2
3
func TestName(t *testing.T){
    // ...
}

测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,举几个例子:

1
2
3
func TestAdd(t *testing.T){ ... }
func TestSum(t *testing.T){ ... }
func TestLog(t *testing.T){ ... }

其中参数 t用 于报告测试失败和附加的日志信息。testing.T 的拥有的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// go doc testing.T
func (c *T) Cleanup(f func())
func (t *T) Deadline() (deadline time.Time, ok bool)
func (c *T) Error(args ...any)
func (c *T) Errorf(format string, args ...any)
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...any)
func (c *T) Fatalf(format string, args ...any)
func (c *T) Helper()
func (c *T) Log(args ...any)
func (c *T) Logf(format string, args ...any)
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (t *T) Setenv(key, value string)
func (c *T) Skip(args ...any)
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...any)
func (c *T) Skipped() bool
func (c *T) TempDir() string

比如有如下的一段待测试的代码(在word包目录下的word.go文件):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// split/split.go
package split

import "strings"

// split package with a single split function.

// Split slices s into all substrings separated by sep and
// returns a slice of the substrings between those separators.
func Split(s, sep string) (result []string) {
    i := strings.Index(s, sep)

    for i > -1 {
        result = append(result, s[:i])
        s = s[i+1:]
        i = strings.Index(s, sep)
    }
    result = append(result, s)
    return
}

在编写测试时,同样在split包目录下,创建一个split_test.go文件,单元测试的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// split/split_test.go
package split

import (
    "reflect"
    "testing"
)

func TestSplit(t *testing.T) {         // 测试函数名必须以Test开头,必须接收一个*testing.T类型参数
    got := Split("a:b:c", ":")         // 程序输出的结果
    want := []string{"a", "b", "c"}    // 期望的结果
    if !reflect.DeepEqual(want, got) { // 因为slice不能比较直接,借助反射包中的方法比较
        t.Errorf("excepted:%v, got:%v", want, got) // 测试失败输出错误提示
    }
}

func TestMoreSplit(t *testing.T) {
    got := Split("abcd", "bc")
    want := []string{"a", "d"}
    if !reflect.DeepEqual(want, got) {
        t.Errorf("excepted:%v, got:%v", want, got)
    }
}

执行测试:

 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
192:split mac$ ls -l
total 16
-rw-r--r--  1 mac  staff  441  1 27 15:48 split.go
-rw-r--r--  1 mac  staff  514  1 27 15:47 split_test.go

192:split mac$ go test  # 默认只输出失败的单元测试
PASS    # 测试通过
ok      SourceCodeTest/split    0.598s

192:split mac$ go test 
--- FAIL: TestMoreSplit (0.00s) # TestMoreSplit 测试用例未通过测试
    split_test.go:22: excepted:[a d], got:[a cd]    # 错误信息
FAIL    # 测试未通过
exit status 1
FAIL    SourceCodeTest/split    0.560s

192:split mac$ go test -v   # -v / -test.v 显示/查看测试函数名称和运行时间(不管成功或者失败)
=== RUN   TestSplit
--- PASS: TestSplit (0.00s) # TestSplit 测试通过
=== RUN   TestMoreSplit
    split_test.go:22: excepted:[a d], got:[a cd]
--- FAIL: TestMoreSplit (0.00s) # TestMoreSplit 测试未通过
FAIL     # 测试未通过
exit status 1
FAIL    SourceCodeTest/split    1.723s

# 添加 `-run=pattern` 参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被 `go test` 命令执行
192:split mac$ go test -v -run=More # 只执行以 TestMore 开头的 测试用例(函数)
=== RUN   TestMoreSplit
    split_test.go:22: excepted:[a d], got:[a cd]
--- FAIL: TestMoreSplit (0.00s)
FAIL
exit status 1
FAIL    SourceCodeTest/split    0.164s

跳过某些测试用例 为了节省时间支持在单元测试时跳过某些耗时的测试用例。

1
2
3
4
5
6
func TestTimeConsuming(t *testing.T) {
    if testing.Short() {
        t.Skip("short模式下会跳过该测试用例")
    }
    ...
}

当执行 go test -short 时就不会执行上面的 TestTimeConsuming 测试用例。

2.2 测试组

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func TestSplitByArr(t *testing.T) {
   // 定义一个测试用例类型
    type test struct {
        input string
        sep   string
        want  []string
    }
    // 定义一个存储测试用例的切片
    tests := []test{
        {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    // 遍历切片,逐一执行测试用例
    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("excepted:%v, got:%v", tc.want, got)
            // t.Errorf("name:%s excepted:%#v, got:%#v", name, tc.want, got) // 将测试用例的name格式化输出
        }
    }
}

通过上面的代码把多个测试用例合到一起,再次执行go test命令。

2.3 子测试

在上面的示例中我们为每一个测试数据编写了一个测试函数,或者提供了一组测试用例进行测试,通常单元测试中需要多组测试数据保证测试的效果。

在组测试时,如果测试用例比较多的时候,是没办法一眼看出来具体是哪个测试用例失败了。

Go1.7+中新增了子测试,支持在测试函数中使用 t.Run 执行一组测试用例,这样就不需要为不同的测试数据定义多个测试函数,或在组测试中找特定测试了。 子测试格式:

1
2
3
4
5
func TestXXX(t *testing.T){
  t.Run("case1", func(t *testing.T){...})
  t.Run("case2", func(t *testing.T){...})
  t.Run("case3", func(t *testing.T){...})
}

子测试示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestSplit(t *testing.T) {
    type test struct { // 定义test结构体
        input string
        sep   string
        want  []string
    }
    tests := map[string]test{ // 测试用例使用map存储
        "simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "leading sep": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    for name, tc := range tests {
        t.Run(name, func(t *testing.T) { // 使用t.Run()执行子测试
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("excepted:%#v, got:%#v", tc.want, got)
            }
        })
    }
}

可以通过 -run=RegExp 来指定运行的测试用例,还可以通过/来指定要运行的子测试用例,例如:go test -v -run=Split/simple 只会运行 simple 对应的子测试用例。

2.3 测试覆盖率

测试覆盖率 是指代码被测试套件覆盖的百分比,是对实现的代码中的一切分支都采用测试用例的方式遍历到,并期待测试的执行结果符合预期。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。

Tips: 在公司内部一般会要求测试覆盖率达到80%左右。

通常意义下,覆盖测试是单元测试的一种,对代码的测试覆盖率越高越好。

但在 Golang 中,覆盖测试可以被单列出来,原因在于实际上也可以将综合测试的用例写入常规范畴,所以综合测试与单元测试的界限未必明显,要做区分的意义也并不大。

测试函数覆盖率(也就是被测试函数有多少代码用于了执行) 在进行测试的时候,要保证被测函数覆盖率为100%,测试函数覆盖率为60%以上,否则写的代码大部分用不到,需要优化

  • 测试覆盖率可以使用go test -cover
  • go test -cover -coverprofile=c.out(将测试的结果存入文件c.out)
  • 然后使用go tool cover -html=c.out可以打开文件,显示哪些代码没有执行

在 Golang 中执行覆盖测试需要两个步骤:

1
2
go test -v . -coverprofile=coverage.txt -covermode=atomic
go tool cover -html=coverage.txt -o cover.html

Go提供内置功能来检查代码覆盖率,即使用 go test -cover 来查看测试覆盖率。

1
2
3
4
5
6
7
192:split mac$ go test -cover
--- FAIL: TestMoreSplit (0.00s)
    split_test.go:22: excepted:[a d], got:[a cd]
FAIL
    SourceCodeTest/split    coverage: 100.0% of statements
exit status 1
FAIL    SourceCodeTest/split    0.594s

从上面的结果可以看到测试用例覆盖了100%的代码。

Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
192:split mac$ go test -cover -coverprofile=c.out
--- FAIL: TestMoreSplit (0.00s)
    split_test.go:22: excepted:[a d], got:[a cd]
FAIL
    SourceCodeTest/split    coverage: 100.0% of statements
exit status 1
FAIL    SourceCodeTest/split    0.342s
192:split mac$ ls
c.out         split.go      split.test    split_test.go
192:split mac$ cat c.out 
mode: set
SourceCodeTest/split/split.go:9.45,12.16 2 1
SourceCodeTest/split/split.go:12.16,16.6 3 1
SourceCodeTest/split/split.go:17.5,18.11 2 1

上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中,然后执行 go tool cover -html=c.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。页面每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。

三、基准(性能/压力)测试

3.1 基准测试简介

基准测试 又称为 性能测试 或 压力测试,是在一定的工作负载之下检测程序(算法)性能的一种方法。基准测试的基本格式如下:

1
2
3
4
5
6
7
8
9
// 导入测试包
import "testing"
// 功能测试函数名
func BenchmarkName(b *testing.B){
   // 被测试代码放到循环内 
    for i:=0;i<b.N;i++{
     // 具体测试业务函数
   }
}

规则如下:

  • 每个测试函数必须导入 testing包;
  • 测试函数的名字必须以 Benchmark 开头,可选的后缀名必须以大写字母开头;
  • 测试函数的参数为 *testing.B 类型, 如:b *testing.B
  • b.N 是基准测试框架提供的,表示循环的次数,也就是基准测试运行的次数;
  • 函数没有返回参数;

基准测试以 Benchmark 为前缀,需要一个 *testing.B 类型的参数 b,基准测试必须要执行 b.N 次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。testing.B 拥有的方法如下:

 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
// go doc testing.B
func (c *B) Cleanup(f func())
func (b *B) Elapsed() time.Duration
func (c *B) Error(args ...any)
func (c *B) Errorf(format string, args ...any)
func (c *B) Fail()
func (c *B) FailNow()
func (c *B) Failed() bool
func (c *B) Fatal(args ...any)
func (c *B) Fatalf(format string, args ...any)
func (c *B) Helper()
func (c *B) Log(args ...any)
func (c *B) Logf(format string, args ...any)
func (c *B) Name() string
func (b *B) ReportAllocs()
func (b *B) ReportMetric(n float64, unit string)
func (b *B) ResetTimer()
func (b *B) Run(name string, f func(b *B)) bool
func (b *B) RunParallel(body func(*PB))
func (b *B) SetBytes(n int64)
func (b *B) SetParallelism(p int)
func (c *B) Setenv(key, value string)
func (c *B) Skip(args ...any)
func (c *B) SkipNow()
func (c *B) Skipf(format string, args ...any)
func (c *B) Skipped() bool
func (b *B) StartTimer()
func (b *B) StopTimer()
func (c *B) TempDir() string

基准测试运行格式:

1
2
# 执行命令
go test -bench=? [flag] 文件[目录]

基准测试也是通过 go test 来执行测试的,区别是需要加上参数 -bench=? [flag] ,其中的 ? 代表匹配函数名的正则表达式,匹配规则如下:

=? 说明
-bench=. 代表执行所有函数
-bench=Sub 代表执行所有 BenchmarkSub* 开头的函数

基准测试示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package tmp

import (
    "testing"
    "strconv"
)

// 变量赋值
func BenchmarkVar(b *testing.B) {
    strSlice := make([]string,10)
    for i := 0; i < b.N; i++ {
        strSlice = append(strSlice,"go")
    }
}
// 字符串拼接
func BenchmarkMulti(b *testing.B) {
    str := ""
    for i := 0; i < b.N; i++ {
        str = str + strconv.Itoa(i)
    }
}

默认情况下,每个基准测试至少运行1秒,如果在Benchmark函数返回时没有到1秒,则b.N的值会增加,并且函数会再次运行

运行全部基准测试函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 执行 bench_test.go 文件中的所有Benchmark*函数
192:word mac$ go test -bench=. ./bench_test.go 
goos: darwin    # 执行系统,常用的值:linux, windows, drawin (macOS)
goarch: amd64   # CPU 架构,常用的值 amd64, arm64, i386, armhf
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz    # CPU信息
# -6 代表对应的 GOMAXPROCS 的值 
BenchmarkVar-6         18199516(执行次数)        70.87 ns/op # (每次耗时70.87ns)
BenchmarkMulti-6         352514(执行次数)        128225 ns/op
PASS
ok      command-line-arguments    47.813s

其中 BenchmarkVar-6 表示对 append 函数进行基准测试,数字 6 表示 GOMAXPROCS 的值,这个对于并发基准测试很重要。18199516 和70.87 ns/op 表示每次调用 append 函数耗时 70.87ns,这个结果是 18199516 次调用的平均值。

运行指定函数:

1
2
3
4
5
6
7
8
# 只匹配 BenchmarkVar* 的函数
192:word mac$ go test -bench=Var ./bench_test.go   # 运行 BenchmarkVar 开头的基准测试函数
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
BenchmarkVar-6       19148853            60.23 ns/op
PASS
ok      command-line-arguments    2.311s

可以通过添加参数 -benchmem 来获得内存分配的统计数据,执行如下:

1
2
3
4
5
6
7
8
192:word mac$ go test -bench=. ./bench_test.go  -benchmem
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
BenchmarkVar-6         14383327            71.63 ns/op          89 B/op           0 allocs/op
BenchmarkMulti-6         427363           166938 ns/op     1186850 B/op           2 allocs/op
PASS
ok      command-line-arguments    73.077s
指标 说明
BenchmarXxxx 基准测试函数
numberX 运行次数(b.N)
x ns/op 每次执行耗时x ns(纳秒)
x B/op 每次操作内存分配了 x 字节
x allocs/op 每次操作进行了 x 次内存分配

基准测试运行时间设置: 默认情况下,每个基准测试至少运行1秒。如果在 Benchmark函数 返回时没有到1秒,则 b.N 的值会自增加,并且函数再次运行。如果想运行更长时间,可以通过参数 -benchtime 设置,如 -benchtime=5s 代表最少运行5秒,下面是两种情况的使用方法。

1
2
3
4
5
6
7
192:word mac$ go test -bench=Var ./bench_test.go  -benchmem -benchtime=5s
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
BenchmarkVar-6       73097536            78.05 ns/op          84 B/op           0 allocs/op
PASS
ok      command-line-arguments    6.304s

基准测试运行次数设置: 默认每次go test 都是运行一次 Benchmark* 基准测试函数获得一次运行的结果,但是可以通过参数 -count 来设置获取运行多次 Benchmark* 函数的结果,具体使用如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
192:word mac$ go test -bench=Var ./bench_test.go  -benchmem -count=5
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
BenchmarkVar-6       14739295            73.51 ns/op          87 B/op           0 allocs/op
BenchmarkVar-6       22084975            58.95 ns/op          91 B/op           0 allocs/op
BenchmarkVar-6       25135641            44.37 ns/op          80 B/op           0 allocs/op
BenchmarkVar-6       22860529            46.79 ns/op          88 B/op           0 allocs/op
BenchmarkVar-6       28524022            46.25 ns/op          88 B/op           0 allocs/op
PASS
ok      command-line-arguments    10.776s

3.2 基准测试性能比较函数

上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理1万甚至100万个元素的耗时的差别是多少?再或者对于同一个任务究竟使用哪种算法性能最佳?通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。

性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举个例子如下:

1
2
3
4
func benchmark(b *testing.B, size int){/* ... */}
func Benchmark10(b *testing.B){ benchmark(b, 10) }
func Benchmark100(b *testing.B){ benchmark(b, 100) }
func Benchmark1000(b *testing.B){ benchmark(b, 1000) }

编写了一个计算斐波那契数列的函数示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fib.go

// Fib 是一个计算第n个斐波那契数的函数
func Fib(n int) int {
    if n < 2 {
        return n
    }
    return Fib(n-1) + Fib(n-2)
}

// fib_test.go
func benchmarkFib(b *testing.B, n int) {
    for i := 0; i < b.N; i++ {
        Fib(n)
    }
}

func BenchmarkFib1(b *testing.B)  { benchmarkFib(b, 1) }
func BenchmarkFib2(b *testing.B)  { benchmarkFib(b, 2) }
func BenchmarkFib3(b *testing.B)  { benchmarkFib(b, 3) }
func BenchmarkFib10(b *testing.B) { benchmarkFib(b, 10) }
func BenchmarkFib20(b *testing.B) { benchmarkFib(b, 20) }
func BenchmarkFib40(b *testing.B) { benchmarkFib(b, 40) }

基准测试结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
192:fib mac$ go test -bench=.
    goos: darwin
    goarch: amd64
    pkg: github.com/pprof/studygo/code_demo/test_demo/fib
    BenchmarkFib1-8         1000000000               2.03 ns/op
    BenchmarkFib2-8         300000000                5.39 ns/op
    BenchmarkFib3-8         200000000                9.71 ns/op
    BenchmarkFib10-8         5000000               325 ns/op
    BenchmarkFib20-8           30000             42460 ns/op
    BenchmarkFib40-8               2         638524980 ns/op
    PASS
    ok      github.com/pprof/studygo/code_demo/test_demo/fib 12.944s

这里需要注意的是,默认情况下,每个基准测试至少运行1秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。

最终的BenchmarkFib40只运行了两次,每次运行的平均值只有不到一秒。像这种情况下我们应该可以使用-benchtime标志增加最小基准时间,以产生更准确的结果。例如:

1
2
3
4
5
6
7
192:fib mac$ go test -bench=Fib40 -benchtime=20s
    goos: darwin
    goarch: amd64
    pkg: github.com/pprof/studygo/code_demo/test_demo/fib
    BenchmarkFib40-8              50         663205114 ns/op
    PASS
    ok      github.com/pprof/studygo/code_demo/test_demo/fib 33.849s

这一次BenchmarkFib40函数运行了50次,结果就会更准确一些了。

使用性能比较函数做测试的时候一个容易犯的错误就是把 b.N 作为输入的大小,例如以下两个例子都是错误的示范:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 错误示范1
func BenchmarkFibWrong(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(n)
    }
}

// 错误示范2
func BenchmarkFibWrong2(b *testing.B) {
    Fib(b.N)
}

3.3 基准测试的计时方法(重置时间)

进行基准测试之前可能会做一些准备工作,比如构建测试数据等,这些准备也需要消耗时间,如果需要把这部分时间排除在外,这时候可以使用 ResetTimer 方法来重置计时器,避免准备数据的耗时对测试数据造成干扰

函数列表:

方法 描述
ResetTimer 重置计时器
StartTimer 控制开始计时
StopTimer 控制停止计时

使用示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 重置时间
func BenchmarkTime(b *testing.B) {
    // 准备工作
    time.Sleep(time.Second * 3)
    
    b.ResetTimer()  // 重置计时器, 基准测试从这里开始计时
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("hello:%v","word")
    }
}

3.4 基准测试的并行测试

在基准测试中可以使用 RunParallel 函数,来运行并行测试,它会创建多个goroutine,并将b.N分配给这些goroutine执行, 其中goroutine数量的默认值为GOMAXPROCS。 要想增加非CPU基准测试的并行度,可以在调用 RunParallel 之前调用 SetParallelism,也可以通过 -cpu= 来设置使用。函数签名具体如下:

1
func (b *B) RunParallel(body func(*PB))

body 将在每个goroutine中运行。它应该设置任何goroutine-local状态,然后迭代直到pb.Next返回false。它不应使用StartTimer,StopTimer或ResetTimer函数,因为它们具有全局作用。它也不应调用运行。

并行测试函数格式:

1
2
3
4
5
6
7
8
func BenchmarkXXX(b *testing.B) {
    // b.SetParallelism(1) // 设置使用的CPU数
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            // 调用具体函数
        }
    })
}

代码示例:

1
2
3
4
5
6
7
8
// 并行测试
func BenchmarkParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Split("枯藤老树昏鸦", "老")
        }
    })
}

运行测试:

1
2
3
4
5
6
7
8
9
192:split mac$ go test -bench=. -v -benchmem -benchtime=5s
goos: darwin
goarch: amd64
pkg: SourceCodeTest/split
cpu: Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
BenchmarkSplitParallel
BenchmarkSplitParallel-6       92433790            69.42 ns/op         240 B/op           4 allocs/op
PASS
ok      SourceCodeTest/split    6.685s

还可以通过在测试命令后添加-cpu参数如 go test -bench=. -cpu 1 来指定使用的CPU数量。

3.5 Setup(设置)与TearDown(拆卸)

测试程序有时需要在测试之前进行额外的设置(setup)或 在测试之后进行拆卸(teardown)工作。

1、TestMain 通过在 *_test.go 文件中定义 TestMain 函数来可以在测试之前进行额外的设置(setup)或 在测试之后进行拆卸(teardown)操作。有时,测试还需要控制在主线程上运行的代码。

如果测试文件包含 func TestMain(m *testing.M) 函数,那么生成的测试会先调用 TestMain(m),然后再运行具体测试。TestMain 运行在 主goroutine中, 可以在调用 m.Run 前后做任何设置(setup)和 拆卸(teardown),退出测试的时候应该使用 m.Run 的返回值作为参数调用os.Exit。

使用TestMain来设置Setup和TearDown的示例如下:

1
2
3
4
5
6
7
func TestMain(m *testing.M) {
    fmt.Println("write setup code here...") // 测试之前的做一些设置
    // 如果 TestMain 使用了 flags,这里应该加上flag.Parse()
    retCode := m.Run()                         // 执行测试
    fmt.Println("write teardown code here...") // 测试之后做一些拆卸工作
    os.Exit(retCode)                           // 退出测试
}

需要注意的是:在调用TestMain时, flag.Parse并没有被调用。所以如果TestMain 依赖于command-line标志 (包括 testing 包的标记), 则应该显示的调用flag.Parse。

2、子测试的Setup与Teardown 有时候可能需要为每个测试集设置Setup与Teardown,也有可能需要为每个子测试设置Setup与Teardown。下面我们定义两个函数工具函数如下:

 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
// 测试集的Setup与Teardown
func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("如有需要在此执行:测试之前的setup")
    return func(t *testing.T) {
        t.Log("如有需要在此执行:测试之后的teardown")
    }
}

// 子测试的Setup与Teardown
func setupSubTest(t *testing.T) func(t *testing.T) {
    t.Log("如有需要在此执行:子测试之前的setup")
    return func(t *testing.T) {
        t.Log("如有需要在此执行:子测试之后的teardown")
    }
}

func TestSplit(t *testing.T) {
    type test struct { // 定义test结构体
        input string
        sep   string
        want  []string
    }
    tests := map[string]test{ // 测试用例使用map存储
        "simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "leading sep": {input: "枯藤老树昏鸦", sep: "老", want: []string{"", "枯藤", "树昏鸦"}},
    }
    teardownTestCase := setupTestCase(t) // 测试之前执行setup操作
    defer teardownTestCase(t)            // 测试之后执行testdoen操作

    for name, tc := range tests {
        t.Run(name, func(t *testing.T) { // 使用t.Run()执行子测试
            teardownSubTest := setupSubTest(t) // 子测试之前执行setup操作
            defer teardownSubTest(t)           // 测试之后执行testdoen操作
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("excepted:%#v, got:%#v", tc.want, got)
            }
        })
    }
}

测试结果如下:

 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
192:split mac$ go test -v
    === RUN   TestSplit
    === RUN   TestSplit/simple
    === RUN   TestSplit/wrong_sep
    === RUN   TestSplit/more_sep
    === RUN   TestSplit/leading_sep
    --- PASS: TestSplit (0.00s)
        split_test.go:71: 如有需要在此执行:测试之前的setup
        --- PASS: TestSplit/simple (0.00s)
            split_test.go:79: 如有需要在此执行:子测试之前的setup
            split_test.go:81: 如有需要在此执行:子测试之后的teardown
        --- PASS: TestSplit/wrong_sep (0.00s)
            split_test.go:79: 如有需要在此执行:子测试之前的setup
            split_test.go:81: 如有需要在此执行:子测试之后的teardown
        --- PASS: TestSplit/more_sep (0.00s)
            split_test.go:79: 如有需要在此执行:子测试之前的setup
            split_test.go:81: 如有需要在此执行:子测试之后的teardown
        --- PASS: TestSplit/leading_sep (0.00s)
            split_test.go:79: 如有需要在此执行:子测试之前的setup
            split_test.go:81: 如有需要在此执行:子测试之后的teardown
        split_test.go:73: 如有需要在此执行:测试之后的teardown
    === RUN   ExampleSplit
    --- PASS: ExampleSplit (0.00s)
    PASS
    ok      github.com/Q1mi/studygo/code_demo/test_demo/split       0.006s

3.6 sync.Map 和 原生 map + Mutex 的基准测试对比示例:

 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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package split

import (
    "sync"
    "testing"
    "math/rand"
)

func BenchmarkSyncMapWrite(b *testing.B) {
    var mp sync.Map
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            a := rand.Intn(10000) + 1
            b := rand.Intn(a)
            mp.Store(a,b)
        }
    })
}

func BenchmarkSyncMapRead(b *testing.B) {
    var mp sync.Map

    for i:=1; i < 10000; i++ {
        a := rand.Intn(10000) + 1
        b := rand.Intn(a)
        mp.Store(a,b)
    }

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            //b.StopTimer()  
            idx := rand.Intn(10000) + 1
            //b.StartTimer() 
            _, _ = mp.Load(idx)
        }
    })
}

func BenchmarkMapWrite(b *testing.B) {
    var mp = make(map[int]int)
    var lock sync.Mutex

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            a := rand.Intn(10000) + 1
            b := rand.Intn(a)
            lock.Lock()
            mp[a] = b
            lock.Unlock()
        }
    })
}

func BenchmarkMapRead(b *testing.B) {
    var mp = make(map[int]int)
    var lock sync.Mutex
    for i:=1; i < 10000; i++ {
        a := rand.Intn(10000) + 1
        b := rand.Intn(a)
        mp[a] = b
    }

    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            //b.StopTimer()  
            idx := rand.Intn(10000) + 1
            //b.StartTimer()  
            lock.Lock()
            _ = mp[idx]
            lock.Unlock()
        }
    })
}

// 子基准测试
// go test -bench=Group map_test.go -v -benchmem
func BenchmarkMap(b *testing.B) {
    var mapTestcases = []struct {
        name string
        benchFunc func (b *testing.B)
    } {
        {"syncMapWrite", BenchmarkSyncMapWrite},
        {"syncMapRead", BenchmarkSyncMapRead},
        {"mapWrite", BenchmarkMapWrite},
        {"mapRead", BenchmarkMapRead},
    }

    for _, t_case : range mapTestcases {
        b.Run(t_case.name, t_case.benchFunc)
    }
}

四、示例函数

4.1 示例函数的格式

被go test特殊对待的第三种函数就是示例函数,它们的函数名以Example为前缀。它们既没有参数也没有返回值。标准格式如下:

1
2
3
func ExampleName() {
    // ...
}

4.2 示例函数示例

下面的代码是我们为Split函数编写的一个示例函数:

1
2
3
4
5
6
7
func ExampleSplit() {
    fmt.Println(split.Split("a:b:c", ":"))
    fmt.Println(split.Split("枯藤老树昏鸦", "老"))
    // Output:
    // [a b c]
    // [ 枯藤 树昏鸦]
}

为你的代码编写示例代码有如下三个用处:

  • 示例函数能够作为文档直接使用,例如基于web的godoc中能把示例函数与对应的函数或包相关联。
  • 示例函数只要包含了// Output:也是可以通过go test运行的可执行测试。
1
2
3
split $ go test -run Example
PASS
ok      github.com/pprof/studygo/code_demo/test_demo/split       0.006s
  • 示例函数提供了可以直接运行的示例代码,可以直接在golang.org的godoc文档服务器上使用Go Playground运行示例代码。下图为strings.ToUpper函数在Playground的示例函数效果。