# 测试

测试驱动开发(Test-Driven Development, TDD)是一种软件开发过程中的应用方法。

实现:先写测试,然后编码快速实现。
重构:在测试保护下,去除冗余代码,提高代码质量。

优点:

测试是对需求设计的翻译,比文档更易理解。
从使用角度考虑算法设计,避免过度和无效设计。
为保证易测试和独立测试,促使松耦合接口设计。
持续完善测试逻辑和数据,有效减少后期返工。
持续回归测试,让重构更容易,不必担心破坏。
专门为错误创建测试,有效避免重复出现。
自动化构建和监控,有效评估整体状态和质量。

缺点:

用例不完善,导致实现结果不理想。
影响开发速度,比如快速原型开发。

类别:

单元测试(unit testing):对程序模块进行正确性检验。
基准测试(benchmark):对某项性能指标进行测量和评估。
模糊测试(fuzz testing):输入随机数据,发现潜在错误。

黑盒测试(black box):无视内部构造,测试外部接口是否符合功能设计。
白盒测试(white box):深入内部构造,验证内部逻辑是否符合设计规格。

# 单元测试

为测试非导出成员,测试文件也放在目标包内。

测试文件以 _test.go 结尾。

  • 通常与测试目标主文件名相同,如 sort_test.go
  • 构建命令( go build )忽略测试文件。

测试命令(go test):

  • 忽略以 _. 开头的文件。
  • 忽略 testdata 子目录。
  • 执行 go vet 检查。

测试函数名 Test<Name>

  • Test 为识别标记。
  • <Name> 为测试名称,首字母大写。如: TestSort

测试函数内以 ErrorFail 等方法标记失败:

  • Fail :失败,继续当前函数。
  • FailNow : 失败,终止当前函数。
  • SkipNow : 跳过,终止当前函数。
  • Log : 输出信息,仅失败或 -v 时有效。
  • Error : Fail + Log
  • Fatal : FailNow + Log
  • SkipSkipNow + Log
  • os.Exit :失败,测试进程终止。
// main_test.go
package main
import (
    "testing"
)
func TestAdd(t *testing.T) {
    z := add(1, 2)
    if z != 3 { t.FailNow() }
}
$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok  	test	0.004s

模式

本地模式(local directory mode): go test , go test -v
不缓存测试结果。

列表模式(package list mode): go test mathgo test . , go test ./...

缓存结果,直接输出。避免不必要的重复运行。
缓存输出有 ( cached ) 标记。
某些参数( -count )导致缓存失效。
执行 go clean -testcache 清除缓存。

$ go test .
ok  	test	(cached)
$ go test -count 2 .
ok  	test	0.004s

执行

$ go test             # 测试当前包。
$ go test math        # 测试指定包。
$ go test ./mylib     # 使用相对路径。
$ go test ./...       # 测试当前及所有子包。

执行参数:

$ go help test
$ go help testflag

-args : 命令行参数。(包列表必须在此参数前)
-c : 仅编译,不执行。(用 -o 修改默认测试可执行文件名)
-json : 输出 JSON 格式。
-count : 执行次数。(默认 1)
-list : 用正则表达式列出测试函数名,不执行。
-run :用正则表达式执行要执行的测试函数。
-skip : 需要跳过的测试函数。
-timeout : 超时 panic!(默认 10m)
-v : 输出详细信息。
-x : 输出构建信息。

参数可添加 test. 前缀,比如 -test.v ,以便区分 benchmark 参数。

$ go test -v -run "Add" -count 2 -timeout 1m20s ./...
=== RUN   TestAdd
    add_test.go:10: abc
--- PASS: TestAdd (0.00s)
=== RUN   TestAdd
    add_test.go:10: abc
--- PASS: TestAdd (0.00s)
PASS
ok  	test/mylib	0.003s

并行
默认情况下,包内串行,多包并行。

// main_test.go
func TestA(t *testing.T) {
    time.Sleep(time.Second * 10)
}
func TestB(t *testing.T) {
    time.Sleep(time.Second * 10)
}
// ./mylib/demo_test.go
func TestX(t *testing.T) {
	time.Sleep(time.Second * 10)
}
# 单个包,串行。
$ go clean -cache -testcache
$ time go test -v
=== RUN   TestA
--- PASS: TestA (10.01s)
=== RUN   TestB
--- PASS: TestB (10.01s)
PASS
ok  	test	20.022s
real	0m21.960s  # <------
user	0m1.917s
sys		0m1.381s
# 多个包,并行。
$ go clean -cache -testcache; 
$ time go test -v -run "Test[AX]" ./...
=== RUN   TestA
--- PASS: TestA (10.00s)
PASS
ok  	test	10.006s
=== RUN   TestX
--- PASS: TestX (10.00s)
PASS
ok  	test/mylib	10.006s
real	0m11.956s  # <------- 
user	0m1.915s
sys		0m1.587s

参数 -p , -parallel 用于设置 GOMAXPROCS ,这会影响并发执行。

$ go clean -cache -testcache
$ time go test -v -run "Test[AX]" -p 1 ./...
=== RUN   TestA
--- PASS: TestA (10.00s)
PASS
ok  	test	10.023s
=== RUN   TestX
--- PASS: TestX (10.00s)
PASS
ok  	test/mylib	10.008s
real	0m23.227s   # <------ 
user	0m2.371s
sys		0m2.082s

调用 t.Parallel() 后,并发测试启动后暂停(PAUSE)。
等所有串行测试结束后,并发再恢复( CONT , continue )执行。

func TestA(t *testing.T) {
    t.Parallel()                    // 通常放在第一行。
    time.Sleep(time.Second * 10)
}
func TestB(t *testing.T) {
    t.Parallel()
    time.Sleep(time.Second * 10)
}
func TestC(t *testing.T) {
    time.Sleep(time.Second * 10)
}
# AB 并行。
$ go clean -cache -testcache; go test -v -run "[AB]"
=== RUN   TestA
=== PAUSE TestA
=== RUN   TestB
=== PAUSE TestB
=== CONT  TestA
=== CONT  TestB
--- PASS: TestB (10.00s)
--- PASS: TestA (10.00s)
PASS
ok  	test	10.011s
# AB 并行,C 串行。
$ go clean -cache -testcache; go test -v
=== RUN   TestA
=== PAUSE TestA
=== RUN   TestB
=== PAUSE TestB
=== RUN   TestC
--- PASS: TestC (10.01s)  // 等待串行结束。
=== CONT  TestA           // A 恢复。(并发)
=== CONT  TestB           // B 恢复。(并发)
--- PASS: TestB (10.01s)
--- PASS: TestA (10.01s)
PASS
ok  	test	20.029s

如设置 -cpu-count 参数,那么同一测试函数依旧串行执行多次。

$ go clean -cache -testcache; go test -v -count 2 -run "TestA"
=== RUN   TestA
=== PAUSE TestA
=== CONT  TestA
--- PASS: TestA (10.01s)
=== RUN   TestA
=== PAUSE TestA
=== CONT  TestA
--- PASS: TestA (10.00s)
PASS
ok  	test	20.017s

并行内部实现
每个测试函数都在独立 goroutine 内运行。
正常情况下,执行器会阻塞,等待 test goroutine 结束。

// src/testing/testing.go
func runTests(...) {
    for _, test := range tests {
        t.Run(test.Name, test.F)   // 串行。
    }
}
func (t *T) Run(...) {
    go tRunner(t, f)
	if !<-t.signal { runtime.Goexit() }    
}
func tRunner(t *T, fn func(t *T)) {
    signal := true
    
	defer func() {
		t.signal <- signal
	}()
    
	defer func() {
		t.runCleanup()
	}()
    
	fn(t)    
}

调用 t.Parallel ,该方法会立即发回信号,让外部阻塞( Run )结束,继续下一个测试。
自身阻塞,等待串行测试结束后发回信息,恢复执行。

func (t *T) Parallel() {
	t.chatty.Updatef(t.name, "=== PAUSE %s\n", t.name)
	t.signal <- true   // Release calling test.
	t.context.waitParallel()
	t.chatty.Updatef(t.name, "=== CONT  %s\n", t.name)
}

测试助手

将调用 Helper 的函数标记为测试助手。
输出测试信息时跳过助手函数,直接显示测试函数文件名、行号。

直接在测试函数中调用无效。
测试助手可用作断言。

package main
import (
	"testing"
)
func assert(t *testing.T, b bool) {
	t.Helper()
	if !b { t.Fatal("assert fatal") }
}
func TestA(t *testing.T) {
	assert(t, false)
}
$ go test -v -run "A"
=== RUN   TestA
main_test.go:13: assert fatal    # 不会显示助手函数信息。
--- FAIL: TestA (0.00s)
FAIL
exit status 1
FAIL	test	0.011s

清除
为测试函数注册清理函数,在测试结束时执行。

如注册多个,则按 FILO 顺序执行。
即便发生 panic ,也能确保清理函数执行。

func TestA(t *testing.T) {
	t.Cleanup(func(){ println("1 cleanup.") });
	t.Cleanup(func(){ println("2 cleanup.") });
	t.Cleanup(func(){ println("3 cleanup.") });
	t.Log("body.")
}
$ go test -v -run "A"
=== RUN   TestA
body.
3 cleanup.
2 cleanup.
1 cleanup.
--- PASS: TestA (0.00s)
PASS
ok  	test	0.004s

和 defer 的区别:即便在其他函数内注册,也会等测试结束后再执行。

func TestA(t *testing.T) {
	func() {
		t.Cleanup(func(){ println("cleanup.") });
	}()
	func() {
		defer println("defer.")
	}()
	t.Log("body.")
}
$ go test -v -run "A"
 
=== RUN   TestA
defer.
body.
cleanup.
--- PASS: TestA (0.00s)
PASS
ok  	test	0.008s

可用来写 Helper 函数。

func newDatabase(t *testing.T) *DB {
	t.Helper()
	d := Database.Open()
	t.Cleanup(func(){
		d.close()
	})
	return &d
}
func TestSelect(t *testing.T) {
    db = newDatabase(t)
    ...
}

# 子测试

将测试函数拆分为子测试,更符合套件(suite)模式。

便于编写初始化(setup)和清理(teardown)逻辑。
表驱动(table driven)时,拆分成多个并发测试。
便于观察子测试时间,不用考虑外部环境影响。

func TestA(t *testing.T) {
	time.Sleep(time.Second)
}
func TestB(t *testing.T) {
	time.Sleep(time.Second)
}
func TestC(t *testing.T) {
	time.Sleep(time.Second)
}
func TestSuite(t *testing.T) {
	t.Log("setup")
	defer t.Log("teardown")
	t.Run("A", TestA)
	t.Run("B", TestB)
	t.Run("C", TestC)
}
$ go test -v -run "Suite"
=== RUN   TestSuite
    main_test.go:21: setup
    
=== RUN   TestSuite/A
=== RUN   TestSuite/B
=== RUN   TestSuite/C
=== CONT  TestSuite
    main_test.go:27: teardown
    
--- PASS: TestSuite (3.02s)
    --- PASS: TestSuite/A (1.01s)
    --- PASS: TestSuite/B (1.00s)
    --- PASS: TestSuite/C (1.00s)
    
PASS
ok  	test	3.028s
# 按名称单独执行子测试。
$ go test -v -run "Suite/[AB]"
$ go test -v -run "Suite/B"

支持子测试并行。

func TestSuite(t *testing.T) {
	tests := []int{1, 2}
    
    for v := range tests {
        
        // 避开闭包延迟。
        x := v
        
        t.Run("", func(t *testing.T) {
            t.Parallel()
            time.Sleep(time.Second * 5)
            println(x)
        })
    }
}

Parallel 挂起当前测试,让 Run 提前退出。
上例中有 teardown 的话,会在子测试结束前执行。

func TestSuite(t *testing.T) {
	tests := []int{1, 2}
	defer t.Log("teardown")
    
    for v := range tests {
		x := v
        
		t.Run("", func(t *testing.T) {
			t.Parallel()
			time.Sleep(time.Second * 5)
			println(x)
		})
	}
}
$ go test -v -run "Suite"
=== RUN   TestSuite
=== RUN   TestSuite/#00
=== PAUSE TestSuite/#00
=== RUN   TestSuite/#01
=== PAUSE TestSuite/#01
=== CONT  TestSuite
main_test.go:31: teardown
=== CONT  TestSuite/#00
=== CONT  TestSuite/#01
1
0
--- PASS: TestSuite (0.00s)
--- PASS: TestSuite/#01 (5.01s)
--- PASS: TestSuite/#00 (5.01s)
PASS
ok  	test	5.014s

解决办法,就是在外面再套一个 Run 调用。

func TestSuite(t *testing.T) {
	tests := []int{1, 2}
    defer t.Log("teardown")
    
    // 不受影响。
    t.Run("group", func(t *testing.T) {
        for v := range tests {
            x := v
            
            // 因 Parallel 提前退出。
            t.Run("", func(t *testing.T) {
                t.Parallel()
                time.Sleep(time.Second * 5)
                println(x)
            })
        }
    })
}
$ go test -v -run "Suite"
=== RUN   TestSuite
=== RUN   TestSuite/group
=== RUN   TestSuite/group/#00
=== PAUSE TestSuite/group/#00
=== RUN   TestSuite/group/#01
=== PAUSE TestSuite/group/#01
=== CONT  TestSuite/group/#00
=== CONT  TestSuite/group/#01
0
1
=== CONT  TestSuite
    main_test.go:21: teardown
    
--- PASS: TestSuite (5.01s)
    --- PASS: TestSuite/group (0.01s)
        --- PASS: TestSuite/group/#00 (5.00s)
        --- PASS: TestSuite/group/#01 (5.00s)
        
PASS
ok  	test	5.019s

# 表驱动

表驱动( table driven )将数据和逻辑分离,便于维护和扩展。

子测试,确保所有数据被测试。(不会因错误中断)
并行子测试,提高效率。

建议用相同命名,规范化。(让维护数据的人更易读)
使用短名 want/got 要比 expected/actual 易读。
输出信息应该面向自然阅读。

func add(x, y int) int {
	return x + y
}
func TestAdd(t *testing.T) {
    
    // 数据表
	var tests = []struct {
		x    int
		y    int
		want int
	}{
		{1, 1, 2},
		{2, 2, 6},
		{3, 2, 5},
	}
    // 测试
	for _, tt := range tests {
        
        // 规避闭包延迟。
		o := tt 
        
        // 并发子测试。
		t.Run("", func(t *testing.T) {
			t.Parallel()
            // 测试逻辑,匹配参数和结果。
			got := add(o.x, o.y)
			if got != o.want {
				t.Errorf("add(%d, %d): want %d, got %d", o.x, o.y, o.want, got)
			}
		})
	}
}
$ go test -run "Add"
--- FAIL: TestAdd (0.00s)
    --- FAIL: TestAdd/#01 (0.00s)
    
        main_test.go:29: add(2, 2): want 6, got 4
        
FAIL
exit status 1
FAIL	test	0.005s

# 覆盖率

代码覆盖率(code coverage)是度量测试完整和有效性的一种手段。

通过覆盖率值,分析测试代码编写质量。
检测是否提供完备测试条件,是否执行了全部目标代码。
量化测试,让白盒测试真正起到应有的质量保障作用。

并非追求形式上的数字百分比,而是为改进测试提供一个发现缺陷的机会。
只有测试本身质量得到保障,才能让它免于成为形式主义摆设。

代码覆盖率也常被用来发现死代码(dead code)。

$ go test -cover ./mylib
ok      test/mylib      0.005s  coverage: 66.7% of statements

获取更详细信息,可指定 covermode 和 coverprofile 参数。

set : 检测语句是否执行。(默认)
count : 检测语句执行次数。
atomic : 同 count ,但支持并发模式。

$ go test -cover -covermode count -coverprofile cover.out ./mylib
ok      test/mylib      0.003s  coverage: 66.7% of statements
$ cat cover.out
mode: count
test/mylib/add.go:3.24,6.2 2 1
test/mylib/add.go:8.14,10.2 1 0

使用 go tool cover 解读 cover.out 文件。

$ go tool cover -func cover.out
test/mylib/add.go:3:    add             100.0%
test/mylib/add.go:8:    hello           0.0%
total:                  (statements)    66.7%

可以 HTML 方式在浏览器查看,或存储为文件。

$ go tool cover -html cover.out
$ go tool cover -html cover.out -o cover.html
$ go tool cover -func cover.out -o cover.txt

# 技巧

示例

示例代码最大用途不是测试,而是导入到 GoDoc 等工具生成的帮助文档。
比对输出( stdout )结果和内部 output 注释是否一致来判断是否成功。

不能使用内置函数 print/println ,因为它们输出到 stderr
没有输出注释的示例被编译,但不执行。

func ExampleAdd() {
    fmt.Println(add(1, 2))
    fmt.Println(add(2, 2))
    
    // Output:
    // 3
    // 4
}
$ go test -v -run "Example"
=== RUN   ExampleAdd
--- PASS: ExampleAdd (0.00s)
PASS
ok      test/mylib      0.005s

失败,输出如下信息。

$ go test -v -run "Example"
=== RUN   ExampleAdd
--- FAIL: ExampleAdd (0.00s)
got:
3
4
want:
5
4
FAIL
FAIL    test/mylib      0.003s

支持无序匹配。

func ExampleAdd() {
	fmt.Println(add(1, 2))
	fmt.Println(add(2, 2))
	// Unordered output:
	// 4
	// 3
}

入口
像 main.main 那样,为测试提供一个入口函数。

同样放在 _test.go 文件内。
为整个测试过程提供 setup/teardown 机制。
在 main goroutine 中执行。

func TestMain(m *testing.M) {
    
    // setup
    code := m.Run()			// 调用测试函数。
    // teardown
    
    os.Exit(code)			// 注意:os.Exit 不会执行 defer。
}

# 性能测试

有时也称作基准测试。目的是获知算法执行时间,及内存开销。

保存在 _test.go 文件中。
函数以 Benchmark 为前缀,
类型 BT 方法类似,省略。

go test -bench 执行。
仅执行性能测试,可用 -run NONE 忽略单元测试。

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        add(1, 2)
    }
}
$ go test -v -bench . -run None ./mylib
goos: linux
goarch: amd64
pkg: test/mylib
cpu: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
BenchmarkAdd
BenchmarkAdd-2          1000000000               0.3534 ns/op
PASS
ok      test/mylib      0.417s

-bench :指定要运行的测试。(正则表达式, -bench .)
-benchtime :单次测试运行时间或循环次数。(默认 1s,1m20s,100x)
-count :执行几轮测试。(benchtime * count)
-cpu :测试所用 CPU 核心数。( -cpu 1,2,4 执行三轮测试)
-list : 列出测试函数,不执行。
-benchmem :显示内存分配(堆)信息。

$ go test -v -run None -bench Add -count 2 ./mylib
BenchmarkAdd-2          1000000000               0.3591 ns/op
BenchmarkAdd-2          1000000000               0.3588 ns/op
$ go test -v -run None -bench Add -cpu 1,2,4 ./mylib
BenchmarkAdd            1000000000               0.3583 ns/op
BenchmarkAdd-2          1000000000               0.3594 ns/op
BenchmarkAdd-4          1000000000               0.3552 ns/op

如果执行次数足够多,则 benchtime 设置的时长无效。
对于某些耗时的目标,设置足够长的时间或次数,以便有足够取样获取平均值。

func BenchmarkSleep(b *testing.B) {
 	for i := 0; i < b.N; i++ {
 		time.Sleep(time.Second)
 	}
}
$ go test -v -run None -bench Add -benchtime 10s ./mylib
BenchmarkAdd
BenchmarkAdd-2          1000000000               0.3609 ns/op
PASS
ok      test/mylib      0.440s
$ go test -v -run None -bench Sleep -benchtime 10s ./mylib
BenchmarkSleep
BenchmarkSleep-2              10        1003276590 ns/op
PASS
ok      test/mylib      11.048s

内部实现

内部通过增加循环次数,直到取样(时间或次数上限)足够,以获得最佳平均值。

func BenchmarkAdd(b *testing.B) {
	println("b.N = ", b.N)
	for i := 0; i < b.N; i++ {
		add(1, 2)
	}
}
$ go test -v -run None -bench Add ./mylib
BenchmarkAdd
b.N =  1
b.N =  100
b.N =  10000
b.N =  1000000
b.N =  100000000
b.N =  1000000000
BenchmarkAdd-2          1000000000               0.3560 ns/op
PASS
ok      test/mylib      0.425s

决定循环次数( b.N )的因素,按优先级次序:

手工指定次数( -benchtime 10x )。
内部次数上限( 1e9, 1000000000 )。
手工指定时长( -benchtime 10s )。

另外,性能测试会执行 runtime.GC 清理现场,以确保测试结果不受干扰。

// benchmark.go
func (b *B) run() {
	b.doBench()
}
func (b *B) doBench() BenchmarkResult {
	go b.launch()
	<-b.signal
	return b.result
}
func (b *B) launch() {
    defer func() {
		b.signal <- true
	}()
	// 指定次数。
	if b.benchTime.n > 0 {
		if b.benchTime.n > 1 {
			b.runN(b.benchTime.n)
		}
	} else {
        // 指定时长,默认 1 秒。
		d := b.benchTime.d
        
        // 时间不够,且次数没有超出上限,增加循环次数重来。
        // 提示,b.duration 由 StopTimer 更新。
		for n := int64(1); !b.failed && b.duration < d && n < 1e9; {
			last := n
            
			// 重新计算循环次数。
            n = goalns * prevIters / prevns
            n += n / 5
            n = min(n, 100*last)
            n = max(n, last+1)
            ...
            
            // 次数上限。
			n = min(n, 1e9)
			b.runN(int(n))
		}
	}
    
	b.result = BenchmarkResult{b.N, b.duration, ...}
}
func (b *B) runN(n int) {
    // Try to get a comparable environment for each run
	// by clearing garbage from previous runs.
	runtime.GC()
    
	b.ResetTimer()
	b.StartTimer()
    
	b.benchFunc(b)   // 测试函数。
    
	b.StopTimer()
}
// benchmark.go
func (b *B) StartTimer() {
	b.start = time.Now()
}
func (b *B) StopTimer() {
	b.duration += time.Since(b.start)
}
// benchmark.go
type BenchmarkResult struct {
	N         int           // The number of iterations.
	T         time.Duration // The total time taken.
}
func (r BenchmarkResult) NsPerOp() int64 {
	return r.T.Nanoseconds() / int64(r.N)
}

# 子测试

操作与 T 基本一致。但没有 Parallel ,而是 RunParallel

每次执行都会调用 runtime.GC 清理现场,以减少外部干扰,更别说多个子测试并发了。

func BenchmarkSubs(b *testing.B) {
	b.Log("setup")
	b.Cleanup(func(){ b.Log("cleanup") })
	b.Run("A", BenchmarkA)
	b.Run("B", BenchmarkB)
	b.Run("C", BenchmarkC)
}
$ go test -v -run None -bench Subs ./mylib
BenchmarkSubs
    add_test.go:33: setup
    
BenchmarkSubs/A
BenchmarkSubs/A-2              1        1000442000 ns/op
BenchmarkSubs/B
BenchmarkSubs/B-2              1        1002897400 ns/op
BenchmarkSubs/C
BenchmarkSubs/C-2              1        1003405600 ns/op
    add_test.go:34: cleanup
    
PASS
ok      test/mylib      3.036s
$ go test -v -run None -bench Subs/B ./mylib
BenchmarkSubs
    add_test.go:33: setup
    
BenchmarkSubs/B
BenchmarkSubs/B-2              1        1001120200 ns/op
    add_test.go:34: cleanup
    
PASS
ok      test/mylib      1.029s

计时器

计时器默认自动处理。如测试逻辑中有需要排除的因素,可手工调用。

func BenchmarkTimer(b *testing.B) {
	// setup
	time.Sleep(time.Second)
	// teardown
	defer time.Sleep(time.Second)
	// 重置计时器,避免 setup 干扰。
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		add(1, 2)
	}
	// 停止计时器,避免 teardown 干扰。
	b.StopTimer()
}

# 并行

方法 b.RunParallel 创建多个 goroutine 并发测试单个目标。

不能操作计时器。( StartTimerStopTimerResetTimer
不能执行子测试。( Run

func BenchmarkA(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			time.Sleep(time.Microsecond)
		}
	})
 }
$ go test -v -bench . -cpu 1,2,4
=== RUN   TestA
--- PASS: TestA (0.00s)
=== RUN   TestA
--- PASS: TestA (0.00s)
=== RUN   TestA
--- PASS: TestA (0.00s)
BenchmarkA
BenchmarkA         54733             21102 ns/op
BenchmarkA-2      112624             10242 ns/op
BenchmarkA-4      108907             10186 ns/op
PASS
ok      test    3.910s

内部实现

将总次数( b.N , bN )按粒度( grain , ~100µs )分成多个段。
每个 goroutine 每次取一个分段执行。如总任务未完成,则再取一个分段。

使用计数器( cache )记录当前分段任务内部进度。每次调用 PB.Next ,计数器递减。
归零时,将此次分段计入总进度( globalN )。检查总进度,判断是否要再取分段。

|-------------|--------------|-------------|-----//------|
0      g1            g2             g1           g2      bN
type PB struct {
	globalN *uint64 // 总进度(多个 G 进度相加)。
	grain   uint64  // 分段粒度。
	cache   uint64  // 当前分段进度。
	bN      uint64  // 任务总次数(b.N)
}
// benchmark.go
func (b *B) RunParallel(body func(*PB)) {
    
    // 计算分段粒度(~100µs)。
	grain := uint64(0)
	if b.previousN > 0 && b.previousDuration > 0 {
		grain = 1e5 * uint64(b.previousN) / uint64(b.previousDuration)
	}
	n := uint64(0)
	numProcs := b.parallelism * runtime.GOMAXPROCS(0)
    
	var wg sync.WaitGroup
	wg.Add(numProcs)
    // 启动多个 G 并发执行任务。
	for p := 0; p < numProcs; p++ {
		go func() {
			defer wg.Done()
			pb := &PB{
				globalN: &n,
				grain:   grain,
				bN:      uint64(b.N),
			}
			body(pb)
		}()
	}
    
    // 等待。
	wg.Wait()
}
func (pb *PB) Next() bool {
    
    // 刚开始,或当前分段(pb.cache)结束。
	if pb.cache == 0 {
        
        // 累加总进度(pb.globalN)。
		n := atomic.AddUint64(pb.globalN, pb.grain)
        
        // 如果总任务(pb.bN)未结束,则获取下一分段。
		if n <= pb.bN {
			pb.cache = pb.grain
		} else if n < pb.bN+pb.grain {
			pb.cache = pb.bN + pb.grain - n
		} else {
			return false
		}
	}
    
    // 每次执行,递减当前分段计数。
	pb.cache--
    
	return true
}

内存
除执行时间外,还应关注堆内存分配。因为内存分配和垃圾回收都属于重点性能问题。

func BenchmarkMem(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = make([]byte, 1<<20)
	}
}
$ go test -v -run None -bench Mem -benchmem ./mylib
BenchmarkMem
BenchmarkMem-2   3542    288269 ns/op   1048576 B/op   1 allocs/op
PASS
ok      test/mylib      1.076s

调用 ReportAllocs ,无论是否有 -benchmem 参数都会输出内存信息。

func BenchmarkMem(b *testing.B) {
	b.ReportAllocs()
    
	for i := 0; i < b.N; i++ {
		_ = make([]byte, 1<<20)
	}
}

# 模糊测试

又称随机测试,一种基于随机输入发现代码缺陷的自动化测试技术。

对单元测预定数据的补充,查找未预料错误。
并非验证逻辑正确,而是发现输入处理缺陷。

保存在 _test.go 内。
函数名以 Fuzz 为前缀。
类型 FT 方法类似,省略。

初始数据,称作 种子语料(seed corpus)。
基于种子构造的随机数据,称作 随机语料(random corpus)。

func FuzzAdd(f *testing.F) {
    
    // 添加种子。(可选)
	f.Add(1, 1)
	f.Add(1, 2)
	f.Add(1, 3)
    // 随机测试。(第一参数为 *T,后续和测试目标函数相同)
	f.Fuzz(func(t *testing.T, x, y int) {
		add(x, y)
	})
}

默认被当作普通单元测试运行。测试数据就是种子,不会引入随机语料。

$ go test -v ./mylib
=== RUN   FuzzAdd
=== RUN   FuzzAdd/seed#0
=== RUN   FuzzAdd/seed#1
=== RUN   FuzzAdd/seed#2
--- PASS: FuzzAdd (0.01s)
    --- PASS: FuzzAdd/seed#0 (0.00s)
    --- PASS: FuzzAdd/seed#1 (0.00s)
    --- PASS: FuzzAdd/seed#2 (0.00s)
    
PASS
ok      test/mylib      0.011s

只有添加 -fuzz 参数才会进行随机测试。
默认无限期执行,直到失败或被用户中断( CTRL + C )。

-fuzz : 测试目标。(regex)
-fuzztime : 时长或次数。( 1m20s , 100x

$ go test -v -fuzz Add -fuzztime 20s ./mylib
=== FUZZ  FuzzAdd
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, 2 workers
fuzz: elapsed:  3s, execs:  28786 (9594/sec),  new interesting: 0 (total: 3)
fuzz: elapsed:  6s, execs:  62406 (11205/sec), new interesting: 0 (total: 3)
fuzz: elapsed:  9s, execs:  93140 (10245/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 12s, execs: 125159 (10671/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 15s, execs: 158390 (11079/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 18s, execs: 191887 (11164/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 20s, execs: 214470 (10858/sec), new interesting: 0 (total: 3)
--- PASS: FuzzAdd (20.09s)
PASS
ok      test/mylib      20.092s

模糊测试基于 “覆盖引导”(coverage guidance)。

start with some (potentially empty) corpus of inputs
for {
    choose a random input from the corpus
    mutate the input
    execute the mutated input and collect code coverage
    if the input gives new coverage, add it to the corpus
}

基线覆盖率(baseline coverage)是对现有语料(种子等)的测试结果,提供基准指标。
执行期间,如某条随机输入导致语料库之外的覆盖率变化,那么可称作 “new interesting”。

new interesting 保存在 GOCACHE/fuzz 目录下,可被 go clean -fuzzcache 清除。

elapsed :启动时间。
execs :模糊输入总数。
new interesting :引发覆盖率变化的输入数,以及语料库总数。

引发失败的原因:
panic
t.Fail ...
os.Exit , stack overflow ...
目标执行时间过长(默认 1 秒)。

语料库

除在代码中添加种子外,还可以将其存储在文件内,自动载入。

路径: testdata/fuzz/<FuzzName>/
文件:每个文件保存一组语料,也就是一次 Add 调用的参数

$ cat mylib/testdata/fuzz/FuzzAdd/a
go test fuzz v1
int(1)
int(1)
$ cat mylib/testdata/fuzz/FuzzAdd/b
go test fuzz v1
int(1)
int(2)
$ cat mylib/testdata/fuzz/FuzzAdd/c
go test fuzz v1
int(1)
int(3)

测试出错时,也会将随机输入存储到该路径下。
作为回归测试的基准语料,以检查目标是否被修复。

Failing input written to testdata/fuzz/FuzzAdd/3cb146f923...828c
    To re-run:
    go test -run=FuzzAdd/3cb146f923...828c

# 性能监控

采集测试或运行数据,分析问题,针对性改进代码。

尽可能排除外在干扰,比如硬件和系统被抢用。
开启 profile 会导致性能损失。
不同 profile 可能存在干扰,每次采集一种。

目标类型:
cpu
alloc
heap
threadcreate
goroutine
block
mutex

采样方式:
测试: go test -memprofile mem.out
在线: import _ "net/http/pprof"
手工: runtime/pprof

# 测试采样

$ go test -run NONE -bench . -memprofile mem.out net/http

-cpuprofile :执行时间。
-memprofile :内存分配。
-blockprofile :阻塞。
-mutexprofile :锁争用。

-memprofilerateruntime.MemProfileRate
-blockprofilerateruntime.SetBlockProfileRate
-mutexprofilefractionruntime.SetMutexProfileFraction

命令行、服务、交互三种模式查看采样结果。

$ go tool pprof -top mem.out                  # 命令行参数。
$ go tool pprof -http 0.0.0.0:8080 mem.out    # 服务。推荐!
$ go tool pprof http.test mem.out             # 交互。
Type: alloc_space
(pprof) top 5
Showing nodes accounting for 4249.95MB, 68.54% of 6200.73MB total
Dropped 299 nodes (cum <= 31MB)
Showing top 5 nodes out of 72
      flat  flat%   sum%        cum   cum%
 1770.01MB 28.55% 28.55%  1770.01MB 28.55%  net/textproto.(*Reader)...
  811.70MB 13.09% 41.64%  3086.27MB 49.77%  net/http.readRequest
  701.10MB 11.31% 52.94%   701.10MB 11.31%  net/http.copyValues
  497.58MB  8.02% 60.97%   497.58MB  8.02%  net/http.readCookies
  469.56MB  7.57% 68.54%   469.56MB  7.57%  net/url.parse

flat : 仅当前函数,不包括它调用的其他函数。
sum : 列表前几行所占百分比总和。
cum : 当前函数调用堆栈累计。

找出目标,用 peek 命令列出调用来源。

(pprof) peek malg
Showing nodes accounting for 6200.73MB, 100% of 6200.73MB total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context 	 	 
----------------------------------------------------------+-------------
                                            1.50MB   100% |   runtime.newproc1
    1.50MB 0.024% 0.024%     1.50MB 0.024%                | runtime.malg
----------------------------------------------------------+-------------

也可用 list 输出源码行样式,更直观定位。

(pprof) list malg
ROUTINE ======================== runtime.malg in proc.go
    1.50MB     1.50MB (flat, cum) 0.024% of Total
         .          .   4029:	execLock.unlock()
         .          .   4030:}
         .          .   4031:
         .          .   4032:// Allocate a new g, with a stack.
         .          .   4033:func malg(stacksize int32) *g {
    1.50MB     1.50MB   4034:	newg := new(g)
         .          .   4035:	if stacksize >= 0 {
         .          .   4036:		stacksize = round2(_StackSyst...
         .          .   4037:		systemstack(func() {
         .          .   4038:			newg.stack = stackalloc(uint...
         .          .   4039:		})

# 在线采样

向目标程序注入 net/http/pprof 包。

package main
import (
	"net/http"
	_ "net/http/pprof"
)
func main() {
	http.ListenAndServe(":6060", http.DefaultServeMux)
}
$ go tool pprof http://localhost:6060/debug/pprof/heap
Saved profile in /root/pprof/pprof.objects.space.001.pb.gz
Type: space
(pprof) top
Showing nodes accounting for 1536.76kB, 100% of 1536.76kB total
Showing top 10 nodes out of 23
      flat  flat%   sum%        cum   cum%
  512.50kB 33.35% 33.35%   512.50kB 33.35%  runtime.allocm
  512.20kB 33.33% 66.68%   512.20kB 33.33%  runtime.malg
$ curl http://localhost:6060/debug/pprof/heap -o mem.out
$ go tool pprof mem.out

# 手工采集

在代码中调用相关函数。

package main
import (
	"runtime/pprof"
	"os"
)
 func main() {
 	pprof.StartCPUProfile(os.Stdout)
 	defer pprof.StopCPUProfile()
 	// ...
 }

# 执行跟踪

相比 profile 采样统计, trace 捕获一个时段内的执行事件。

G 如何执行。
GC 等核心事件。
不良的并行化。

$ go test -trace trace.out net/http             # 采样
$ go tool trace -http 0.0.0.0:8080 trace.out    # 服务

注入方式,指定采样时长。

$ curl http://localhost:8080/debug/pprof/trace?seconds=5 -o trace.out

手工方式

package main
import (
	"os"
	"runtime/trace"
)
func main() {
	out, _ := os.Create("trace.out")
	defer out.Close()
	trace.Start(out)
	defer trace.Stop()
	test()
}
更新于
-->