go test工具包使用Go进行单元测试

Go 语言从开发初期就注意了测试用例的编写。特别是静态语言,由于调试没有动态语言那么方便,所以能最快最方便地编写一个测试用例就显得非常重要了。

  1. testing 方便进行 Go 包的自动化单元测试、基准测试
  2. net/http/httptest 提供测试 HTTP 的工具

go test工具

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

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

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

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

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

单元测试

格式:Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。

func TestXxxx(t *testing.T){
    // ...
}

t参数拥有以下方法:

func (c *T) Cleanup(func())
func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Helper()
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool
func (c *T) TempDir() string

gotest 的变量有这些:
test.short : 一个快速测试的标记,在测试用例中可以使用 testing.Short() 来绕开一些测试
test.outputdir : 输出目录
test.coverprofile : 测试覆盖率参数,指定输出文件
test.run : 指定正则来运行某个 / 某些测试用例
test.memprofile : 内存分析参数,指定输出文件
test.memprofilerate : 内存分析参数,内存分析的抽样率
test.cpuprofile : cpu 分析输出参数,为空则不做 cpu 分析
test.blockprofile : 阻塞事件的分析参数,指定输出文件
test.blockprofilerate : 阻塞事件的分析参数,指定抽样频率
test.timeout : 超时时间
test.cpu : 指定 cpu 数量
test.parallel : 指定运行测试用例的并行数

测试代码

// main.go
func Fib(n int) int {
        if n < 2 {
                return n
        }
        return Fib(n-1) + Fib(n-2)
}

// fib_test.go
func TestFib(t *testing.T) {
    var (
        in       = 7
        expected = 13
    )
    actual := Fib(in)
    if actual != expected {
        t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
    }
}

执行 go test .,(可以为go test命令添加-v参数,让它输出完整的测试结果。)输出:
在这里插入图片描述
在这里插入图片描述

go test -run

  1. 在执行go test命令的时候可以添加-run参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被go test命令执行。

  2. 例如通过给go test添加-run=Sep参数来告诉它本次测试只运行TestFuckingSep这个测试用例。

跳过某些测试用例

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

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

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

子测试

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

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){...})
}
// 循环实现
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 {
		got := Split(tc.input, tc.sep)
		if !reflect.DeepEqual(got, tc.want) {
			t.Errorf("name:%s expected:%#v, got:%#v", name, tc.want, got) // 将测试用例的name格式化输出
		}
	}
}

// 用自测试方法实现
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("expected:%#v, got:%#v", tc.want, got)
			}
		})
	}
}

表格驱动测试

测试讲究 case 覆盖,按上面的方式,要覆盖更多 case 时,显然通过修改代码的方式很笨拙。这时可以采用 Table-Driven 的方式写测试,标准库中有很多测试是使用这种方式写的。

表格驱动测试的步骤通常是定义一个测试用例表格,然后遍历表格,并使用t.Run对每个条目执行必要的测试。

func TestFib(t *testing.T) {
    var fibTests = []struct {
        in       int // input
        expected int // expected result
    }{
        {1, 1},
        {2, 1},
        {3, 2},
        {4, 3},
        {5, 5},
        {6, 8},
        {7, 13},
    }

    for _, tt := range fibTests {
        actual := Fib(tt.in)
        if actual != tt.expected {
            t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
        }
    }
}

并行测试

表格驱动测试中通常会定义比较多的测试用例,而Go语言又天生支持并发,所以很容易发挥自身并发优势将表格驱动测试并行化。 想要在单元测试过程中使用并行测试,可以像下面的代码示例中那样通过添加t.Parallel()来实现。

func TestSplitAll(t *testing.T) {
	// 将 TLog 标记为能够与其他测试并行运行
	t.Parallel()  

	// 定义测试表格
	// 为每个测试用例设置了一个名称
	tests := []struct {
		name  string
		input string
		sep   string
		want  []string
	}{
		{"base case", "a:b:c", ":", []string{"a", "b", "c"}},
		{"wrong sep", "a:b:c", ",", []string{"a:b:c"}},
		{"more sep", "abcd", "bc", []string{"a", "d"}},
		{"leading sep", "沙河有沙又有河", "沙", []string{"", "河有", "又有河"}},
	}
	// 遍历测试用例
	for _, tt := range tests {
		tt := tt  // 注意这里重新声明tt变量(避免多个goroutine中使用了相同的变量)
		t.Run(tt.name, func(t *testing.T) { // 使用t.Run()执行子测试
			t.Parallel()  // 将每个测试用例标记为能够彼此并行运行
			got := Split(tt.input, tt.sep)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("expected:%#v, got:%#v", tt.want, got)
			}
		})
	}
}

报告方法

  1. 遇到一个断言错误的时候,标识这个测试失败:

    Fail() : 测试失败,测试继续
    FailNow() : 测试失败,测试中断
    
  2. 遇到一个断言错误,只希望跳过这个错误,但是不希望标识测试失败:

    SkipNow() : 跳过测试,测试中断
    
  3. 只希望打印信息

    Log : 输出信息
    Logf : 输出格式化的信息
    
  4. 希望跳过这个测试,并且打印出信息

    Skip : 相当于 Log + SkipNow
    Skipf : 相当于 Logf + SkipNow
    
  5. 希望断言失败的时候,标识测试失败,并打印出必要的信息,但是测试继续

    Error : 相当于 Log + Fail
    Errorf : 相当于 Logf + Fail
    
  6. 希望断言失败的时候,标识测试失败,打印出必要的信息,但中断测试

    Fatal : 相当于 Log + FailNow
    Fatalf : 相当于 Logf + FailNow
    

测试覆盖率

测试覆盖率是指代码被测试套件覆盖的百分比,也就是在测试中至少被运行一次的代码占总代码的比例。在公司内部一般会要求测试覆盖率达到80%左右。

使用go test -cover来查看测试覆盖率。

go test -cover
PASS
coverage: 100.0% of statements
ok      golang-unit-test-demo/base_demo 0.009s

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

go test -cover -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok      golang-unit-test-demo/base_demo 0.009s

然后执行go tool cover -html=c.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。
在这里插入图片描述

基准测试

基准测试就是在一定的工作负载之下检测程序性能的一种方法。

func BenchmarkName(b *testing.B){
    // ...
}

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

func (c *B) Error(args ...interface{})
func (c *B) Errorf(format string, args ...interface{})
func (c *B) Fail()
func (c *B) FailNow()
func (c *B) Failed() bool
func (c *B) Fatal(args ...interface{})
func (c *B) Fatalf(format string, args ...interface{})
func (c *B) Log(args ...interface{})
func (c *B) Logf(format string, args ...interface{})
func (c *B) Name() string
func (b *B) ReportAllocs()
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) Skip(args ...interface{})
func (c *B) SkipNow()
func (c *B) Skipf(format string, args ...interface{})
func (c *B) Skipped() bool
func (b *B) StartTimer()
func (b *B) StopTimer()

demo

func BenchmarkHello(b *testing.B) {
    for i := 0; i < b.N; i++ {
        fmt.Sprintf("hello")
    }
}

通过 go test 命令,加上 -bench 标志来执行。

结果输出:
在这里插入图片描述

  1. 意味着函数执行了 30542277 次,每次循环花费 33.73 纳秒 (ns)。
  2. 数字16表示GOMAXPROCS的值,这个对于并发基准测试很重要.

还可以为基准测试添加-benchmem参数,来获得内存分配的统计数据。

在这里插入图片描述
5 B/op表示每次操作内存分配了5字节,1 allocs/op则表示每次操作进行了1次内存分配

性能比较函数

通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。

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秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。

可以使用-benchtime标志增加最小基准时间,以产生更准确的结果:
go test -bench=Fib40 -benchtime=20s

计时方法

有三个方法用于计时:

  1. StartTimer:开始对测试进行计时。该方法会在基准测试开始时自动被调用,也可以在调用 StopTimer 之后恢复计时;
  2. StopTimer:停止对测试进行计时。当需要执行一些复杂的初始化操作,并且不想对这些操作进行测量时,就可以使用这个方法来暂时地停止计时;
    3/ ResetTimer:对已经逝去的基准测试时间以及内存分配计数器进行清零。对于正在运行中的计时器,这个方法不会产生任何效果。
func BenchmarkSplit(b *testing.B) {
	time.Sleep(5 * time.Second) // 假设需要做一些耗时的无关操作
	b.ResetTimer()              // 重置计时器
	for i := 0; i < b.N; i++ {
		Split("xx.xx.xx.xx", ".")
	}
}

并行测试

func (b *B) RunParallel(body func(*PB))会以并行的方式执行给定的基准测试。

通过 RunParallel 方法能够并行地执行给定的基准测试。RunParallel会创建出多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。用户如果想要增加非 CPU 受限(non-CPU-bound)基准测试的并行性,那么可以在 RunParallel 之前调用 SetParallelism(如 SetParallelism(2),则 goroutine 数量为 2*GOMAXPROCS)。RunParallel 通常会与 -cpu 标志一同使用。

body 函数将在每个 goroutine 中执行,这个函数需要设置所有 goroutine 本地的状态,并迭代直到 pb.Next 返回 false 值为止。因为 StartTimer、StopTime 和 ResetTimer 这三个方法都带有全局作用,所以 body 函数不应该调用这些方法; 除此之外,body 函数也不应该调用 Run 方法。

func BenchmarkSplitParallel(b *testing.B) {
	// b.SetParallelism(1) // 设置使用的CPU数
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Split("xx.xx.xx.xx", ".")
		}
	})
}

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

TestMain

通过在*_test.go文件中定义TestMain函数来可以在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)操作。

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

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)                           // 退出测试
}

Setup与Teardown

有时候可能需要为每个测试集设置Setup与Teardown,也有可能需要为每个子测试设置Setup与Teardown。

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

// 子测试的Setup与Teardown
func setupSubTest(t *testing.T) func(t *testing.T) {
	t.Log("如有需要在此执行:子测试之前的setup")
	// 返回Teardown
	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("expected:%#v, got:%#v", tc.want, got)
			}
		})
	}
}

httptest

由于 Go 标准库的强大支持,Go 可以很容易的进行 Web 开发。为此,Go 标准库专门提供了 net/http/httptest 包专门用于进行 http Web 开发测试。

简单的 Web 应用

// 保存 Topic,没有考虑并发问题
var TopicCache = make([]*Topic, 0, 16)

type Topic struct {
    Id        int       `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
}

func main() {
    http.HandleFunc("/topic/", handleRequest)
    http.ListenAndServe(":2017", nil)
}
/topic/ 开头的请求都交由 handleRequest 处理,它根据不同的 Method 执行相应的增删改查
...

测试

func TestHandlePost(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("/topic/", handleRequest)

    reader := strings.NewReader(`{"title":"The Go Standard Library","content":"It contains many packages."}`)
    r, _ := http.NewRequest(http.MethodPost, "/topic/", reader)
	
	// httptest.NewRecorder() 可以获得 httptest.ResponseRecorder 结构,而此结构实现了http.ResponseWriter 接口。
    w := httptest.NewRecorder()

    mux.ServeHTTP(w, r)

    resp := w.Result()
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Response code is %v", resp.StatusCode)
    }
}

通过 go test -v 运行测试。

pprof 性能分析

  1. benchmark(基准测试) 可以度量某个函数或方法的性能,也就是说,如果我们知道性能的瓶颈点在哪里,benchmark 是一个非常好的方式。
  2. 但是面对一个未知的程序,如何去分析这个程序的性能,并找到瓶颈点呢?

pprof 就是用来解决这个问题的,pprof 包含两部分:
3. 编译到程序中的 runtime/pprof 包
4. 性能剖析工具 go tool pprof

性能分析类型

记录性能数据会对程序的性能产生影响,建议一次只记录一类数据。

CPU

  1. 启动 CPU 分析时,运行时(runtime) 将每隔 10ms 中断一次,记录此时正在运行的协程(goroutines) 的堆栈信息。

  2. 程序运行结束后,可以分析记录的数据找到最热代码路径(hottest code paths)。

  3. 一个函数在性能分析数据中出现的次数越多,说明执行该函数的代码路径(code path)花费的时间占总运行时间的比重越大。

使用
import (
	"math/rand"
	"os"
	"runtime/pprof"
	"time"
)

func main() {
	pprof.StartCPUProfile(os.Stdout)
	defer pprof.StopCPUProfile()
	n := 10
	for i := 0; i < 5; i++ {
		nums := generate(n)
		bubbleSort(nums)
		n *= 10
	}
}

运行该程序,将输出定向到文件 cpu.pprof 中。
$ go run main.go > cpu.pprof

接下来,用 go tool pprof 分析这份数据(展示结果到网页)
$ go tool pprof -http=:9999 cpu.pprof

用控制台输出结果:
go tool pprof cpu.pprof

一般来说,不建议将结果直接输出到标准输出,因为如果程序本身有输出,则会相互干扰,直接记录到一个文件中是最好的方式。

func main() {
	f, _ := os.OpenFile("cpu.pprof", os.O_CREATE|os.O_RDWR, 0644)
	defer f.Close()
	pprof.StartCPUProfile(f)
	defer pprof.StopCPUProfile()
	n := 10
	for i := 0; i < 5; i++ {
		nums := generate(n)
		bubbleSort(nums)
		n *= 10
	}
}

运行go build 编译执行即可。

报错

如果出现报错:
在这里插入图片描述
官网下载graphviz即可:地址
在这里插入图片描述
此处勾选环境变量:

在这里插入图片描述
验证成功:cmd输入dot -version即可
在这里插入图片描述

内存

内存性能分析(Memory profiling) 记录堆内存分配时的堆栈信息,忽略栈内存分配信息。

阻塞性能分析

阻塞性能分析(block profiling) 是 Go 特有的。

阻塞性能分析用来记录一个协程等待一个共享资源花费的时间。在判断程序的并发瓶颈时会很有用。阻塞的场景包括:

  1. 在没有缓冲区的信道上发送或接收数据。
  2. 从空的信道上接收数据,或发送数据到满的信道上。
  3. 尝试获得一个已经被其他协程锁住的排它锁。

一般情况下,当所有的 CPU 和内存瓶颈解决后,才会考虑这一类分析。

锁性能分析

锁性能分析(mutex profiling) 与阻塞分析类似,但专注于因为锁竞争导致的等待或延时。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Generalzy

文章对您有帮助,倍感荣幸

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值