测试

寒江蓑笠翁大约 56 分钟

测试

对于开发者而言,良好的测试可以提前发现程序的中错误,避免后续因维护不及时产生Bug而造成的心智负担,所以写好测试非常有必要。Go在测试这一方面提供了非常简便实用的命令行工具go test,在标准库和许多开源框架都能看到测试的身影,该工具使用起来十分方便,目前支持以下几种测试:

  • 示例测试
  • 单元测试
  • 基准测试
  • 模糊测试

在Go中大部分的API都是由标准库testing提供。

提示

在命令行中执行go help testfunc命令,可看Go官方对于上面四种测试类型的解释。


编写规范

在开始编写测试之前,首先需要注意几点规范,这样在后续的学习中会更加方便。

  • 测试包:测试文件最好单独放在一个包中,这个包通常命名为test
  • 测试文件:测试文件通常以_test.go结尾,例如要测试某一个功能,就将其命名为function_test.go,如果想根据测试类型再划分的更细一些也可以将测试类型为作为文件前缀,例如benchmark_marshaling_test.go,或者example_marshaling_test.go
  • 测试函数:每一个测试文件中都会有若干个测试函数用于不同的测试。对于不同的测试类型,测试函数的命名的风格也不同。例如示例测试是ExampleXXXX,单元测试是TestXXXX,基准测试是BenchmarkXXXX,模糊测试是FuzzXXXX,这样一来即便不需要注释也可以知晓这是什么类型的测试。

提示

当包名为testdata时,该包通常是为了存储用于测试的辅助数据,在执行测试时,Go会忽略名为testdata的包。

遵循上述的规范,养成良好的测试风格,可以为日后的维护省去不少的麻烦。


执行测试

执行测试主要会用到go test命令,下面拿实际的代码举例,现在有待测试文件/say/hello.go代码如下

package say

import "fmt"

func Hello() {
	fmt.Println("hello")
}

func GoodBye() {
	fmt.Println("bye")
}

和测试文件/test/example_test.go代码如下

package test

import (
	"golearn/say"
)

func ExampleHello() {
	say.Hello()
	// Output:
	// hello
}

func ExampleGoodBye() {
	say.GoodBye()
	// Output:
	// bye
}

func ExampleSay() {
	say.Hello()
	say.GoodBye()
	// Output:
	// hello
	// bye
}

执行这些测试有多种方法,比如想要执行test包下所有的测试用例,就可以直接在test目录下执行如下命令

$ go test ./
PASS
ok      golearn/test    0.422s

./表示当前目录,Go会将test目录下的所有测试文件重新编译后,然后再将所有测试用例全都执行,从结果可以看出所有的测试用例都通过了。其后的参数也可以跟多个目录,例如下方的命令,显然项目的主目录并没有测试文件可供执行。

$ go test ./ ../
ok      golearn/test
?       golearn [no test files]

提示

当执行的参数有多个包时,Go并不会再次执行已经成功通过的测试用例,在执行时会行尾添加(cached)以表示输出结果是上一次的缓存。当测试的标志参数位于以下集合中时,Go就会缓存测试结果,否则就不会。

-benchtime, -cpu,-list, -parallel, -run, -short, -timeout, -failfast, -v

如果想要禁用缓存,可以加上参数 -count=1

当然也可以单独指定某一个测试文件来执行。

$ go test example_test.go
ok      command-line-arguments  0.457s

或者可以单独指定某一个测试文件的某一个测试用例,例如

$ go test -run ExampleSay
PASS
ok      golearn/test    0.038s

上面三种情况虽然都完成了测试,但是输出结果太简介了,这时可以加上参数-v,来使输出结果更加详细一点,例如

$ go test ./ -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.040s

这下可以很清楚的看到每一个测试用例的执行顺序,耗时,执行情况,以及总体的耗时。

提示

go test命令默认运行所有的单元测试,示例测试,模糊测试,如果加上了-bench参数则会运行所有类型的测试,例如下方的命令

$ go test -bench .

所以需要使用-run参数来指定,例如只运行所有的基准测试的命令如下

$ go test -bench . -run ^$

常用参数

Go 测试有着非常多的标志参数,下面只会介绍常用的参数,想要了解更多细节建议使用go help testflag命令自行查阅。

参数释义
-o file指定编译后的二进制文件名称
-c只编译测试文件,但不运行
-json以json格式输出测试日志
-exec xprog使用xprog运行测试,等价于go run
-bench regexp选中regexp匹配的基准测试
-fuzz regexp选中regexp匹配的模糊测试
-fuzztime t模糊测试自动结束的时间,t为时间间隔,当单位为x时,表示次数,例如200x
-fuzzminimizetime t模式测试运行的最小时间,规则同上
-count n运行测试n次,默认1次
-cover开启测试覆盖率分析
-covermode set,count,atomic设置覆盖率分析的模式
-cpu为测试执行GOMAXPROCS
-failfast第一次测试失败后,不会开始新的测试
-list regexp列出regexp匹配的测试用例
-parallel n允许调用了t.Parallel的测试用例并行运行,n值为并行的最大数量
-run regexp只运行regexp匹配的测试用例
-skip regexp跳过regexp匹配的测试用例
-timeout d如果单次测试执行时间超过了时间间隔d,就会panicd为时间间隔,例1s,1ms,1ns等
-shuffle off,on,N打乱测试的执行顺序,N为随机种子,默认种子为系统时间
-v输出更详细的测试日志
-benchmem统计基准测试的内存分配
-blockprofile block.out统计测试中协程阻塞情况并写入文件
-blockprofilerate n控制协程阻塞统计频率,通过命令go doc runtime.SetBlockProfileRate查看更多细节
-coverprofile cover.out统计覆盖率测试的情况并写入文件
-cpuprofile cpu.out统计cpu情况并写入文件
-memprofile mem.out统计内存分配情况并写入文件
-memprofilerate n控制内存分配统计的频率,通过命令go doc runtime.MemProfileRate查看更多细节
-mutexprofile mutex.out统计锁竞争情况并写入文件
-mutexprofilefraction n设置统计n个协程竞争一个互斥锁的情况
-trace trace.out将执行追踪情况写入文件
-outputdir directory指定上述的统计文件的输出目录,默认为go test的运行目录

示例测试

示例测试并不像其他三种测试一样是为了发现程序的问题所在,它更多的是为了展示某一个功能的使用方法,起到文档作用。示例测试并不是一个官方定义的概念,也不是一个硬性的规范,更像是一种工程上的约定俗成,是否遵守只取决于开发者。示例测试在标准库中出现的非常多,通常是官方所编写的标准库代码示例,例如标准库context/example_test.go中的ExampleWithDeadline测试函数,该函数中展现了DeadlineContext的基本使用方法:

// This example passes a context with an arbitrary deadline to tell a blocking
// function that it should abandon its work as soon as it gets to it.
func ExampleWithDeadline() {
   d := time.Now().Add(shortDuration)
   ctx, cancel := context.WithDeadline(context.Background(), d)

   // Even though ctx will be expired, it is good practice to call its
   // cancellation function in any case. Failure to do so may keep the
   // context and its parent alive longer than necessary.
   defer cancel()

   select {
   case <-time.After(1 * time.Second):
      fmt.Println("overslept")
   case <-ctx.Done():
      fmt.Println(ctx.Err())
   }

   // Output:
   // context deadline exceeded
}

表面上看该测试函数就是一个普通的函数,不过示例测试主要是由Output注释来体现的,待测试函数只有一行输出时,使用Output注释来检测输出。首先创建一个hello.go的文化,写入如下代码

package say

import "fmt"

func Hello() {
	fmt.Println("hello")
}

func GoodBye() {
	fmt.Println("bye")
}

SayHello函数就是待测函数,然后创建测试文件example_test.go,写入如下代码

package test

import (
	"golearn/say"
)

func ExampleHello() {
	say.Hello()
	// Output:
	// hello
}

func ExampleGoodBye() {
	say.GoodBye()
	// Output:
	// bye
}

func ExampleSay() {
	say.Hello()
	say.GoodBye()
	// Output:
	// hello
	// bye
}

函数中Output注释表明了检测函数输出是否为hello,接下来执行测试命令看看结果。

$ go test -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.448s

从结果可以看出全部测试都已经通过,关于Output有以下几种写法,第一种是只有一行输出,意为检测该函数的输出是不是hello

// Output:
// hello

第二种是多行输出,即按顺序检测输出是否匹配

// Output:
// hello
// bye

第三种是无序输出,即不按照顺序多行输出匹配

// Unordered output:
// bye
// hello

需要注意的是,对于测试函数而言,仅当最后几行为Output注释才会被视为示例测试,否则就只是一个普通的函数,不会被Go执行。


单元测试

单元测试就是对软件中的最小可测试单元进行测试,单元的大小定义取决于开发者,可能是一个结构体,或者是一个包,也可能是一个函数,或者是一个类型。下面依旧通过例子来演示,首先创建/tool/math.go文件,写入如下代码

package tool

type Number interface {
	~int8 | ~int16 | ~int32 | ~int64 | ~int
}

func SumInt[T Number](a, b T) T {
	return a + b
}

func Equal[T Number](a, b T) bool {
	return a == b
}

然后创建测试文件/tool_test/unit_test.go,对于单元测试而言,命名可以为unit_test或者是想要测试的包或者功能作为文件前缀。

package test_test

import (
	"golearn/tool"
	"testing"
)

func TestSum(t *testing.T) {
	a, b := 10, 101
	expected := 111

	actual := tool.SumInt(a, b)
	if actual != expected {
		t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
	}
}

func TestEqual(t *testing.T) {
	a, b := 10, 101
	expected := false

	actual := tool.Equal(a, b)
	if actual != expected {
		t.Errorf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
	}
}

对于单元测试而言,每一个测试用例的命名风格为TestXXXX,且函数的入参必须是t *testing.Ttesting.Ttesting包提供的用于方便测试的结构体,提供了许多可用的方法,例子中的t.Errorf等同于t.Logf,用于格式化输出测试失败的日志信息,其他常用的还有t.Fail用于将当前用例标记为测试失败,功能类似的还有t.FailNow同样会标记为测试失败,但是前者失败后还会继续执行,后者则会直接停止执行,如下方的例子,将预期结果修改为错误的结果:

package tool_test

import (
	"golearn/tool"
	"testing"
)

func TestSum(t *testing.T) {
	a, b := 10, 101
	expected := 110

	actual := tool.SumInt(a, b)
	if actual != expected {
        // Errorf内部使用的是t.Fail()
		t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
	}
	t.Log("test finished")
}

func TestEqual(t *testing.T) {
	a, b := 10, 101
	expected := true

	actual := tool.Equal(a, b)
	if actual != expected {
        // Fatalf内部使用的是t.FailNow()
		t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
	}
	t.Log("test finished")
}

执行上述测试输出如下

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
    tool_test.go:16: test finished
--- FAIL: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.037s

从测试日志中可以看出TestSum用例尽管失败了还是输出了test finished,而TestEqual则没有,同样的还有t.SkipNow,会将当前用例标记为SKIP,然后停止执行,在下一轮测试中会继续执行。

package tool_test

import (
   "golearn/tool"
   "testing"
)

func TestSum(t *testing.T) {
   a, b := 10, 101
   expected := 110

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }
   t.Log("test finished")
}

func TestEqual(t *testing.T) {
   a, b := 10, 101
   expected := true

   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
   t.Log("test finished")
}

在执行测试时,修改测试次数为2

$ go test tool_test.go -v -count=2
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.468s

上数的例子中在最后一行输出了test finished,用于表示测试完毕,其实可以使用t.Cleanup来注册一个收尾函数专门做此事,该函数会在测试用例结束时执行,如下。

package tool_test

import (
	"golearn/tool"
	"testing"
)

func finished(t *testing.T) {
	t.Log("test finished")
}

func TestSum(t *testing.T) {
	t.Cleanup(func() {
		finished(t)
	})

	a, b := 10, 101
	expected := 111

	actual := tool.SumInt(a, b)
	if actual != expected {
		t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
	}

}

func TestEqual(t *testing.T) {
	t.Cleanup(func() {
		finished(t)
	})

	a, b := 10, 101
	expected := false

	actual := tool.Equal(a, b)
	if actual != expected {
		t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
	}
}

执行测试后输出如下

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:9: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:9: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.462s

Helper

通过t.Helper()可以将当前函数标记为帮助函数,帮助函数不会单独作为一个测试用例用于执行,在记录日志时输出的行号也是帮助函数的调用者的行号,这样可以使得分析日志时定位更准确,避免的冗杂的其他信息。比如将上述t.Cleanup的例子就可以修改为帮助函数,如下。

package tool_test

import (
   "golearn/tool"
   "testing"
)

func CleanupHelper(t *testing.T) {
   t.Helper()
   t.Log("test finished")
}

func TestSum(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := 111

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }

}

func TestEqual(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := false

   t.Helper()
   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
}

执行测试后输出信息如下,与之前的区别在于test finished的行号变成了调用者的行号。

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:15: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:30: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.464s

提示

上述操作都只能在主测试中进行,即直接执行的测试用例,如果是子测试中使用将会panic

子测试

在一些情况下,会需要用到在一个测试用例中测试另外测试用例,这种嵌套的测试用例一般称为子测试,通过t.Run()方法,该方法签名如下

// Run方法会开启一个新的协程用于运行子测试,阻塞等待函数f执行完毕后才会返回
// 返回值为是否通过测试
func (t *T) Run(name string, f func(t *T)) bool 

下面是一个例子

func TestTool(t *testing.T) {
	t.Run("tool.Sum(10,101)", TestSum)
	t.Run("tool.Equal(10,101)", TestEqual)
}

执行后结果如下

$ go test -run TestTool -v
=== RUN   TestTool
=== RUN   TestTool/tool.Sum(10,101)
    tool_test.go:15: test finished
=== RUN   TestTool/tool.Equal(10,101)
    tool_test.go:30: test finished
--- PASS: TestTool (0.00s)
    --- PASS: TestTool/tool.Sum(10,101) (0.00s)
    --- PASS: TestTool/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.449s

通过输出可以很清晰的看到父子的层级结构,在上述的例子中第一个子测试未执行完毕第二个子测试是不会执行的,可以使用t.Parallel()将测试用例标记为可并行运行,如此一来输出的顺序将会无法确定。

package tool_test

import (
	"golearn/tool"
	"testing"
)

func CleanupHelper(t *testing.T) {
	t.Helper()
	t.Log("test finished")
}

func TestSum(t *testing.T) {
	t.Parallel()
	t.Cleanup(func() {
		CleanupHelper(t)
	})

	a, b := 10, 101
	expected := 111

	actual := tool.SumInt(a, b)
	if actual != expected {
		t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
	}

}

func TestEqual(t *testing.T) {
	t.Parallel()
	t.Cleanup(func() {
		CleanupHelper(t)
	})

	a, b := 10, 101
	expected := false

	actual := tool.Equal(a, b)
	if actual != expected {
		t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
	}
}

func TestToolParallel(t *testing.T) {
	t.Log("setup")
	t.Run("tool.Sum(10,101)", TestSum)
	t.Run("tool.Equal(10,101)", TestEqual)
	t.Log("teardown")
}

执行测试后输出如下

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/tool.Sum(10,101)
=== PAUSE TestToolParallel/tool.Sum(10,101)
=== RUN   TestToolParallel/tool.Equal(10,101)
=== PAUSE TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel
    tool_test.go:49: teardown
=== CONT  TestToolParallel/tool.Sum(10,101)
=== CONT  TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/tool.Equal(10,101)
    tool_test.go:32: test finished
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/tool.Sum(10,101) (0.00s)
    --- PASS: TestToolParallel/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.444s

从测试结果中就可以很明显的看出有一个阻塞等待的过程,在并发执行测试用例时,像上述的例子肯定是无法正常进行的,因为后续的代码无法保证同步运行,这时可以选择再嵌套一层t.Run(),如下

func TestToolParallel(t *testing.T) {
	t.Log("setup")
	t.Run("process", func(t *testing.T) {
		t.Run("tool.Sum(10,101)", TestSum)
		t.Run("tool.Equal(10,101)", TestEqual)
	})
	t.Log("teardown")
}

再次执行,就可以看到正常的执行结果了。

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/process
=== RUN   TestToolParallel/process/tool.Sum(10,101)
=== PAUSE TestToolParallel/process/tool.Sum(10,101)
=== RUN   TestToolParallel/process/tool.Equal(10,101)
=== PAUSE TestToolParallel/process/tool.Equal(10,101)
=== CONT  TestToolParallel/process/tool.Sum(10,101)
=== CONT  TestToolParallel/process/tool.Equal(10,101)
=== NAME  TestToolParallel/process/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/process/tool.Equal(10,101)
    tool_test.go:32: test finished
=== NAME  TestToolParallel
    tool_test.go:51: teardown
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/process (0.00s)
        --- PASS: TestToolParallel/process/tool.Sum(10,101) (0.00s)
        --- PASS: TestToolParallel/process/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.450s

表格风格

在上述的单元测试中,测试的输入数据都是手动声明的一个个变量,当数据量小的时候无伤大雅,但如果想要测试多组数据时,就不太可能再去声明变量来创建测试数据,所以一般情况下都是尽量采用结构体切片的形式,结构体是临时声明的匿名结构体,因为这样的编码风格看起来就跟表格一样,所以称为table-driven,下面举个例子,这是一个手动声明多个变量来创建测试数据的例子,如果有多组数据狠起来就不是很直观,所以将其修改为表格风格

func TestEqual(t *testing.T) {
	t.Cleanup(func() {
		CleanupHelper(t)
	})

	a, b := 10, 101
	expected := false
	actual := tool.Equal(a, b)
	if actual != expected {
		t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
	}
}

修改后的代码如下

func TestEqual(t *testing.T) {
	t.Cleanup(func() {
		CleanupHelper(t)
	})

	// table driven style
	testData := []struct {
		a, b int
		exp  bool
	}{
		{10, 101, false},
		{5, 5, true},
		{30, 32, false},
		{100, 101, false},
		{2, 3, false},
		{4, 4, true},
	}

	for _, data := range testData {
		if actual := tool.Equal(data.a, data.b); actual != data.exp {
			t.Fatalf("Sum(%d,%d) expected %t,actual is %t", data.a, data.b, data.exp, actual)
		}
	}
}

这样的测试数据看起来就要直观很多。

基准测试

基准测试又称为性能测试,通常用于测试程序的内存占用,CPU使用情况,执行耗时等等性能指标。对于基准测试而言,测试文件通常以bench_test.go结尾,而测试用例的函数必须为BenchmarkXXXX格式。

下面以一个字符串拼接的例子的性能比较来当作基准测试的例子。首先创建文件/tool/strConcat.go文件,众所周知直接使用字符串进行+拼接性能是很低的,而使用strings.Builder则要好很多,在/tool/strings.go文件分别创建两个函数进行两种方式的字符串拼接。

package tool

import "strings"


func ConcatStringDirect(longString string) {
   res := ""
   for i := 0; i < 100_000.; i++ {
      res += longString
   }
}

func ConcatStringWithBuilder(longString string) {
   var res strings.Builder
   for i := 0; i < 100_000.; i++ {
      res.WriteString(longString)
   }
}

然后创建测试文件/tool_test/bench_tool_test.go ,代码如下

package tool_test

import (
	"golearn/tool"
	"testing"
)

var longString = "longStringlongStringlongStringlongStringlongStringlongStringlongStringlongString"

func BenchmarkConcatDirect(b *testing.B) {
	for i := 0; i < b.N; i++ {
		tool.ConcatStringDirect(longString)
	}
}

func BenchmarkConcatBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		tool.ConcatStringWithBuilder(longString)
	}
}

执行测试命令,命令中开启了详细日志和内存分析,指定了使用的CPU核数列表,且每个测试用例执行两轮,输出如下

$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=2
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         277771375 ns/op        4040056736 B/op    10000 allocs/op
BenchmarkConcatDirect-2                4         278500125 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                1        1153796000 ns/op        4040068784 B/op    10126 allocs/op
BenchmarkConcatDirect-4                1        1211017600 ns/op        4040073104 B/op    10171 allocs/op
BenchmarkConcatDirect-8                2         665460800 ns/op        4040077760 B/op    10219 allocs/op
BenchmarkConcatDirect-8                2         679774450 ns/op        4040080064 B/op    10243 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            3428            344530 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3579            351858 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            2448            736177 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1688            662993 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1958            550333 ns/op         4128199 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2174            552113 ns/op         4128196 B/op         29 allocs/op
PASS
ok      golearn/tool_test       21.381s

下面解释一下基准测试的输出结果,goos代表是运行的操作系统,goarh代表的是CPU架构,pkg为测试所在的包,cpu是一些关于CPU的信息。下面的每一个测试用例的结果由每一个基准测试的名称分隔,第一列BenchmarkConcatDirect-2中的2代表了使用的CPU核数,第二列的4代表了代码中b.N的大小,也就是基准测试中的循环次数,第三列277771375 ns/op代表了每一次循环所消耗的时间,ns为纳秒,第四列4040056736 B/op表示每一次循环所分配内存的字节大小,第五列10000 allocs/op表示每一次循环内存分配的次数。

很显然,根据测试的结果看来,使用strings.Builder的性能要远远高于使用+拼接字符串,通过直观的数据对比性能正是基准测试的目的所在。

benchstat

benchstat是一个开源的性能测试分析工具,上述性能测试的样本数只有两组,一旦样本多了起来人工分析就会十分的费时费力,该工具便是为了解决性能分析问题而生。

首先需要下载该工具

$ go install golang.org/x/perf/benchstat

分两次执行基准测试,这次将样本数修改为5个,并且分别输出到old.txtnew.txt文件以做对比,第一次执行结果

$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a old.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         290535650 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         298974625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         299637800 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         276487000 ns/op        4040056784 B/op    10001 allocs/op
BenchmarkConcatDirect-2                4         356465275 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                2         894723200 ns/op        4040077424 B/op    10216 allocs/op
BenchmarkConcatDirect-4                2         785830400 ns/op        4040078288 B/op    10225 allocs/op
BenchmarkConcatDirect-4                2         743634000 ns/op        4040077568 B/op    10217 allocs/op
BenchmarkConcatDirect-4                2         953802700 ns/op        4040075408 B/op    10195 allocs/op
BenchmarkConcatDirect-4                2         953028750 ns/op        4040077520 B/op    10217 allocs/op
BenchmarkConcatDirect-8                2         684023150 ns/op        4040086784 B/op    10313 allocs/op
BenchmarkConcatDirect-8                2         634380250 ns/op        4040090528 B/op    10352 allocs/op
BenchmarkConcatDirect-8                2         685030600 ns/op        4040090768 B/op    10355 allocs/op
BenchmarkConcatDirect-8                2         817909650 ns/op        4040089808 B/op    10345 allocs/op
BenchmarkConcatDirect-8                2         600078100 ns/op        4040095664 B/op    10406 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2925            419651 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2961            423899 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2714            422275 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2848            452255 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2612            454452 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4             974           1158000 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1098           1068682 ns/op         4128192 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1042           1056570 ns/op         4128194 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1280            978213 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1538           1162108 ns/op         4128190 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1744            700824 ns/op         4128203 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2235            759537 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1556            736455 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1592            825794 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2263            717285 ns/op         4128203 B/op         29 allocs/op
PASS
ok      golearn/tool_test       56.742s

第二次执行结果

$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         285074900 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         291517150 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         281901975 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         292320625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         286723000 ns/op        4040056952 B/op    10002 allocs/op
BenchmarkConcatDirect-4                1        1188983000 ns/op        4040071856 B/op    10158 allocs/op
BenchmarkConcatDirect-4                1        1080713900 ns/op        4040070800 B/op    10147 allocs/op
BenchmarkConcatDirect-4                1        1203622300 ns/op        4040067344 B/op    10111 allocs/op
BenchmarkConcatDirect-4                1        1045291300 ns/op        4040070224 B/op    10141 allocs/op
BenchmarkConcatDirect-4                1        1123163300 ns/op        4040070032 B/op    10139 allocs/op
BenchmarkConcatDirect-8                2         790421300 ns/op        4040076656 B/op    10208 allocs/op
BenchmarkConcatDirect-8                2         659047300 ns/op        4040079488 B/op    10237 allocs/op
BenchmarkConcatDirect-8                2         712991800 ns/op        4040077184 B/op    10213 allocs/op
BenchmarkConcatDirect-8                2         706605350 ns/op        4040078000 B/op    10222 allocs/op
BenchmarkConcatDirect-8                2         656195700 ns/op        4040085248 B/op    10297 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2726            386412 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3439            335358 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3376            338957 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3870            326301 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            4285            339596 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1663            671535 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1507            744885 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1353           1097800 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1388           1006019 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1635            993764 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1332            783599 ns/op         4128198 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1818            729821 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1398            780614 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1526            750513 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2164            704798 ns/op         4128204 B/op         29 allocs/op
PASS
ok      golearn/tool_test       50.387s

再使用benchstat进行对比

$ benchstat old.txt new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
                │    old.txt    │               new.txt                │
                │    sec/op     │    sec/op      vs base               │
ConcatDirect-2     299.0m ± ∞ ¹    286.7m ± ∞ ¹        ~ (p=0.310 n=5)
ConcatDirect-4     894.7m ± ∞ ¹   1123.2m ± ∞ ¹  +25.53% (p=0.008 n=5)
ConcatDirect-8     684.0m ± ∞ ¹    706.6m ± ∞ ¹        ~ (p=0.548 n=5)
ConcatBuilder-2    423.9µ ± ∞ ¹    339.0µ ± ∞ ¹  -20.04% (p=0.008 n=5)
ConcatBuilder-4   1068.7µ ± ∞ ¹    993.8µ ± ∞ ¹        ~ (p=0.151 n=5)
ConcatBuilder-8    736.5µ ± ∞ ¹    750.5µ ± ∞ ¹        ~ (p=0.841 n=5)
geomean            19.84m          19.65m         -0.98%
¹ need >= 6 samples for confidence interval at level 0.95

                │    old.txt    │                new.txt                │
                │     B/op      │     B/op       vs base                │
ConcatDirect-2    3.763Gi ± ∞ ¹   3.763Gi ± ∞ ¹       ~ (p=1.000 n=5)
ConcatDirect-4    3.763Gi ± ∞ ¹   3.763Gi ± ∞ ¹  -0.00% (p=0.008 n=5)
ConcatDirect-8    3.763Gi ± ∞ ¹   3.763Gi ± ∞ ¹  -0.00% (p=0.008 n=5)
ConcatBuilder-2   3.937Mi ± ∞ ¹   3.937Mi ± ∞ ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4   3.937Mi ± ∞ ¹   3.937Mi ± ∞ ¹       ~ (p=0.079 n=5)
ConcatBuilder-8   3.937Mi ± ∞ ¹   3.937Mi ± ∞ ¹       ~ (p=0.952 n=5)
geomean           123.2Mi         123.2Mi        -0.00%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

                │   old.txt    │               new.txt                │
                │  allocs/op   │  allocs/op    vs base                │
ConcatDirect-2    9.999k ± ∞ ¹   9.999k ± ∞ ¹       ~ (p=1.000 n=5)
ConcatDirect-4    10.22k ± ∞ ¹   10.14k ± ∞ ¹  -0.74% (p=0.008 n=5)
ConcatDirect-8    10.35k ± ∞ ¹   10.22k ± ∞ ¹  -1.26% (p=0.008 n=5)
ConcatBuilder-2    29.00 ± ∞ ¹    29.00 ± ∞ ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4    29.00 ± ∞ ¹    29.00 ± ∞ ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-8    29.00 ± ∞ ¹    29.00 ± ∞ ¹       ~ (p=1.000 n=5) ²
geomean            543.6          541.7        -0.33%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

从结果中可以看出benchstat将其分为了三组,分别是耗时,内存占用和内存分配次数,其中geomoean为平均值,p为 样本的显著性水平,临界区间通常为0.05,高于0.05就不太可信,取其中一条数据如下:

					│    sec/op     │    sec/op      vs base               │
ConcatDirect-4     894.7m ± ∞ ¹   1123.2m ± ∞ ¹  +25.53% (p=0.008 n=5)

可以看到old执行耗时为894.7ms,new执行耗时1123.2ms,相比之下还增加了25.53%的耗时。

模糊测试

模糊测试是GO1.18推出的一个新功能,属于是单元测试和基准测试的一种增强,区别在于前两者的测试数据都需要开发者手动编写,而模糊测试可以通过语料库来生成随机的测试数据,关于Go中的模糊测试可以前往Go Fuzzingopen in new window来了解更多概念。模糊测试的好处在于,相比于固定的测试数据,随机数据可以更好的测试程序的边界条件。下面拿官方教程的例子来讲解,这次需要测试的是一个反转字符串的函数,首先创建文件/tool/strings.go,写入如下代码

package tool

func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

创建模糊测试文件/tool_test/fuzz_tool_test.go,写入如下代码

package tool

import (
	"golearn/tool"
	"testing"
	"unicode/utf8"
)

func FuzzReverse(f *testing.F) {
	testdata := []string{"hello world!", "nice to meet you", "good bye!"}
	for _, data := range testdata {
		f.Add(data)
	}

	f.Fuzz(func(t *testing.T, str string) {
		first := tool.Reverse(str)
		second := tool.Reverse(first)
		t.Logf("str:%q,first:%q,second:%q", str, first, second)
		if str != second {
			t.Errorf("before: %q, after: %q", str, second)
		}
		if utf8.ValidString(str) && !utf8.ValidString(first) {
			t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
		}
	})
}

在模糊测试中,首先需要给语料种子库添加数据,示例中使用f.Add()来添加,有助于后续生成随机的测试数据。然后使用f.Fuzz(fn)来进行测试,函数签名如下:

func (f *F) Fuzz(ff any)

func (f *F) Add(args ...any)

fn就类似于一个单元测试函数的逻辑,函数的第一个入参必须是t *testing.T,其后跟想要生成的参数。由于传入的字符串是不可预知的,这里采用反转两次的方法来进行验证。执行如下命令

$ go test -run Fuzz -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/seed#0
    fuzz_tool_test.go:18: str:"hello world!",first:"!dlrow olleh",second:"hello world!"
=== RUN   FuzzReverse/seed#1
    fuzz_tool_test.go:18: str:"nice to meet you",first:"uoy teem ot ecin",second:"nice to meet you"
=== RUN   FuzzReverse/seed#2
    fuzz_tool_test.go:18: str:"good bye!",first:"!eyb doog",second:"good bye!"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/seed#0 (0.00s)
    --- PASS: FuzzReverse/seed#1 (0.00s)
    --- PASS: FuzzReverse/seed#2 (0.00s)
PASS
ok      golearn/tool_test       0.539s

当参数不带-fuzz时,将不会生成随机的测试数据,只会给测试函数传入语料库中的数据,可以从结果中看到测试全部通过了,这样使用就等同于单元测试,但其实是有问题的,下面加上-fuzz参数再次执行。

$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/217 completed
fuzz: minimizing 91-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 15/217 completed
--- FAIL: FuzzReverse (0.13s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"𐑄",first:"\x84\x91\x90\xf0",second:"𐑄"
        fuzz_tool_test.go:23: Reverse produced invalid UTF-8 string "𐑄" "\x84\x91\x90\xf0"

    Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
    To re-run:
    go test -run=FuzzReverse/d856c981b6266ba2
=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.697s

提示

模糊测试中失败的用例会输出到当前测试文件夹下的testdata目录下的某个语料文件中,例如上述例子中的

Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
To re-run:
go test -run=FuzzReverse/d856c981b6266ba2

testdata\fuzz\FuzzReverse\d856c981b6266ba2便是输出的语料文件路径,文件的内容如下

go test fuzz v1
string("𐑄")

可以看到这一次并没有通过,原因是字符串反转后变成了非utf8格式,所以通过模糊测试就发现了这个问题所在。由于一些字符占用并不止一个字节,如果将其以字节为单位反转后肯定是乱码,所以将待测试的源代码修改为如下,将字符串转换为[]rune,这样就可以避免出现上述问题。

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

接下来直接运行根据上次模糊测试失败的用例

$ go test -run=FuzzReverse/d856c981b6266ba2 -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/d856c981b6266ba2
    fuzz_tool_test.go:18: str:"𐑄",first:"𐑄",second:"𐑄"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/d856c981b6266ba2 (0.00s)
PASS
ok      golearn/tool_test       0.033s

可以看到这一次通过了测试,再次执行模糊测试看看还有没有问题

$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: minimizing 70-byte failing input file
failure while testing seed corpus entry: FuzzReverse/d97214ce235bfcf5
fuzz: elapsed: 0s, gathering baseline coverage: 2/219 completed
--- FAIL: FuzzReverse (0.15s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"\xe4",first:"�",second:"�"
        fuzz_tool_test.go:20: before: "\xe4", after: "�"

=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.184s

可以发现又出错了,这次的问题是对字符串做了两次反转后不相等,原字符为\xe4,期望的结果是4ex\ ,但结果是乱码,如下

func main() {
	fmt.Println("\xe4")
	fmt.Println([]byte("\xe4"))
	fmt.Println([]rune("\xe4"))
	fmt.Printf("%q\n", "\xe4")
	fmt.Printf("%x\n", "\xe4")
}

它的执行结果是

�
[65533]
"\xe4" 
e4   

究其原因在于Go在字符串单位是字节,而不是字符。所以再次修改待测源代码,如果传入的是非utf8字符串,直接返回错误。

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

测试代码也需要稍作修改

func FuzzReverse(f *testing.F) {
	testdata := []string{"hello world!", "nice to meet you", "good bye!"}
	for _, data := range testdata {
		f.Add(data)
	}

	f.Fuzz(func(t *testing.T, str string) {
		first, err := tool.Reverse(str)
		if err != nil {
			t.Skip()
		}
		second, err := tool.Reverse(first)
		if err != nil {
			t.Skip()
		}
		t.Logf("str:%q,first:%q,second:%q", str, first, second)
		if str != second {
			t.Errorf("before: %q, after: %q", str, second)
		}
		if utf8.ValidString(str) && !utf8.ValidString(first) {
			t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
		}
	})
}

当反转函数返回error时,就跳过测试,再来进行模糊测试

$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: elapsed: 0s, gathering baseline coverage: 219/219 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 895571 (297796/sec), new interesting: 32 (total: 251)
fuzz: elapsed: 6s, execs: 1985543 (363120/sec), new interesting: 37 (total: 256)
fuzz: elapsed: 9s, execs: 3087837 (367225/sec), new interesting: 38 (total: 257)
fuzz: elapsed: 12s, execs: 4090817 (335167/sec), new interesting: 40 (total: 259)
fuzz: elapsed: 15s, execs: 5132580 (346408/sec), new interesting: 44 (total: 263)
fuzz: elapsed: 18s, execs: 6248486 (372185/sec), new interesting: 45 (total: 264)
fuzz: elapsed: 21s, execs: 7366827 (373305/sec), new interesting: 46 (total: 265)
fuzz: elapsed: 24s, execs: 8439803 (358059/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 27s, execs: 9527671 (361408/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 30s, execs: 10569473 (348056/sec), new interesting: 48 (total: 267)
fuzz: elapsed: 30s, execs: 10569473 (0/sec), new interesting: 48 (total: 267)
--- PASS: FuzzReverse (30.16s)
=== NAME
PASS
ok      golearn/tool_test       30.789s

然后这次就可以得到一个比较完整的模糊测试输出日志,其中一些概念的解释如下:

  • elapsed: 一个轮次完成后已经流逝的时间
  • execs: 运行的输入总数,297796/sec表示多少个输入每秒
  • new interesting: 在测试中,已经添加语料库中的”有趣“输入的总数。(有趣的输入指的是该输入能够将代码覆盖率扩大到现有语料库所能覆盖的范围之外,随着覆盖范围的不断扩大,它的增长趋势总体上而言会持续变缓)

提示

如果没有-fuzztime参数限制时间,模糊测试将会永远的运行下去。

类型支持

Go Fuzz中的支持的类型如下:

  • string, []byte
  • int, int8, int16, int32/rune, int64
  • uint, uint8/byte, uint16, uint32, uint64
  • float32, float64
  • bool