Golang学习笔记(06-单元测试)

103 阅读10分钟

1. Go语言单元测试

1.1. 概述

Go语言中的测试依赖go test命令,在Goland这种IDE中有着更方便的鼠标操作,但是Go的命令行单元测试指令 go test 必须要掌握。
go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以 _test.go 为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。在 *_test.go 文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。

类型格式作用
测试函数函数名前缀为Test根据输入对比实际结果和预期结果来判断代码的逻辑行为是否正确
基准函数函数名前缀为Benchmark测试函数的性能,包括CPU和内存使用情况,调优必用
示例函数函数名前缀为Example为文档提供示例文档

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

1.2. 常用命令


2. 测试函数

测试函数根据输入对比实际结果和预期结果来判断代码的逻辑行为是否正确,重点是在于测试逻辑的正确性,即指定输入内容后,判断输出内容和预期内容的相符度!

2.1. 常用方法

1.  测试函数
    Syntax: func TestXxx(t *testing.T) { }
    Man:    其中 Xxx 可以是任何字母数字字符串(但第一个字母不能是 [a-z])

2.  测试类型 T
    Syntax: type T struct { }
    Man:    T 是传递给测试函数的一种类型,它用于管理测试状态并支持格式化测试日志。
            测试日志会在执行测试的过程中不断累积, 并在测试完成时转储至标准输出。
            当一个测试的测试函数返回时,又或者当一个测试函数调用FailNow,Fatal,Fatalf,SkipNow,Skip,
            Skipf 中的任意一个时,该测试即宣告结束。

3.  Log()
    Syntax: func (c *T) Log(args ...interface{})
    Man:    Log 使用与 Println 相同的格式化语法对它的参数进行格式化,然后将格式化后的文本记录到错误日志里面:
            1)对于测试来说,格式化文本只会在测试失败或者设置了 -test.v 标志的情况下被打印出来;
            2)对于基准测试来说,为了避免 -test.v 标志的值对测试的性能产生影响, 格式化文本总会被打印出来。

4.  Logf()
    Syntax: func (c *T) Logf(format string, args ...interface{})
    Man:    格式化的输出日志

5.  Fail()
    Syntax: func (c *T) Fail()
    Man:    将当前测试标识为失败,但是仍继续执行该测试

6.  FailNow()
    Syntax: func (c *T) FailNow()
    Man:    将当前测试标识为失败并停止执行该测试,在此之后,测试过程将在下一个测试或者下一个基准测试中继续。

7.  Fatal()
    Syntax: func (c *T) Fatal(args ...interface{})
    Man:    调用 Fatal 相当于在调用 Log 之后调用 FailNow 。

8.  Fatalf()
    Syntax: func (c *T) Fatalf(format string, args ...interface{})
    Man:    调用 Fatalf 相当于在调用 Logf 之后调用 FailNow 。

9.  Error()
    Syntax: func (c *T) Error(args ...interface{})
    Man:    调用 Error 相当于在调用 Log 之后调用 Fail 。

10. Errorf()
    Syntax: func (c *T) Errorf(format string, args ...interface{})
    Man:    调用 Errorf 相当于在调用 Logf 之后调用 Fail
    
11. Run()
    Syntax: func (t *T) Run(name string, f func(t *T)) bool
    Man:    执行名字为 name 的子测试 f ,并报告 f 在执行过程中是否出现了任何失败。
            Run 将一直阻塞直到 f 的所有并行测试执行完毕。

12. SkipNow()
    Syntax: func (c *T) SkipNow()
    Man:    将当前测试标识为“被跳过”并停止执行该测试。 
            如果一个测试在失败(参考Error、Errorf和Fail)之后被跳过了,那么它还是会被判断为是“失败的”。
            在停止当前测试之后,测试过程将在下一个测试或者下一个基准测试中继续

13. Skip()
    Syntax: func (c *T) Skip(args ...interface{})
    Man:    调用 Skip 相当于在调用 Log 之后调用 SkipNow 。
    
14. Skipf()
    Syntax: func (c *T) Skipf(format string, args ...interface{})
    Man:    调用 Skipf 相当于在调用 Logf 之后调用 SkipNow 。

2.2. 测试函数案例

// 待测试函数
package split

import "strings"

func Split(s string, sep string) []string  {
	var res []string
	n := strings.Index(s, sep)
	for n > -1 {
		res = append(res, s[0:n])
		s = s[n+1:]
		n = strings.Index(s, sep)
	}
	res=append(res, s)
	return res
}

2.2.1. 基础案例1

当前案例中,被测试函数和测试用例不再一个包中,此时需要而外引入被测试函数所在的包。

package testing

import (
	"learn/tests/utils/split"
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	wanted := []string{"a", "b", "c"}      				// 预期结果
	goted := split.Split("a:b:c", ":")     	// 实际结果
	if !reflect.DeepEqual(wanted, goted) { 				// 对比结果是否一致,slice 不能直接比较
		t.Fatalf("wanted:%v;goted:%v\n", wanted, goted)
		return
	}
}
[root@heyingsheng testing]# go test split_test.go
ok      command-line-arguments  0.004s

2.2.2. 基础案例2

下面的测试用例提示失败,原因在于被测试函数中,使用的索引是 +1,而中文单个字符长度为3。将测试函数的line11改为 s = s[n+len(sep):] 即可

package split

import (
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	got := Split("上海自来水来自海上","来")
	want := []string{"上海自","水","自海上"}
	if !reflect.DeepEqual(want, got) { // 对比结果是否一致,slice 不能直接比较
		t.Fatalf("wanted:%v;goted:%v\n", want, got)
		return
	}
}
[root@heyingsheng split]# go test
--- FAIL: TestSplit (0.00s)
    split_test.go:21: wanted:[上海自 水 自海上];goted:[上海自 ��水 ��自海上]
FAIL
exit status 1
FAIL    learn/tests/utils/split 0.008s

2.2.3. 测试组

一个待测试函数可能会有很多个测试用例,如果分成多个函数去写,会及其麻烦,一般考虑使用切片或者数组的方式结合for循环来实现:

package split

import (
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	type splitCase struct {
		input 	string
		sep 	string
		wanted	[]string
	}
	var splitCases = []splitCase{
		{input:"a:b:c:d",sep:":",wanted:[]string{"a","b","c","d"}},
		{input:"abbcbbd",sep:"bb",wanted:[]string{"a","c","d"}},
		{input:"上海自来水来自海上",sep:"上海",wanted:[]string{"自来水来自海上"}},
	}
	for _, c := range splitCases {
		if got := Split(c.input, c.sep); !reflect.DeepEqual(c.wanted, got) {
			t.Errorf("wanted:%#v;goted:%#v\n", c.wanted, got)
		}
	}
}
[root@heyingsheng split]# go test -v
=== RUN   TestSplit
--- FAIL: TestSplit (0.00s)
    split_test.go:21: wanted:[]string{"自来水来自海上"};goted:[]string{"", "自来水来自海上"}
FAIL
exit status 1
FAIL    learn/tests/utils/split 0.005s

2.2.4. 子测试

上述案例中,使用 go test -v  无法查看到具体某个测试的执行的情况,当然你可以使用map配合更加详细的打印方式来弥补。Go中提供的子测试可以更好的实现这种效果,代码如下:

package split

import (
	"reflect"
	"testing"
)

func TestSplit(t *testing.T) {
	type splitCase struct {
		input  string
		sep    string
		wanted []string
	}
	splitCases := map[string]splitCase{
		"EasyABCTest": {input: "a:b:c:d", sep: ":", wanted: []string{"a", "b", "c", "d"}},
		"ABCTest":     {input: "abbcbbd", sep: "bb", wanted: []string{"a", "c", "d"}},
		"Chinesetest": {input: "上海自来水来自海上", sep: "上海", wanted: []string{"自来水来自海上"}},
	}
	for name, c := range splitCases {
		t.Run(name, func(t *testing.T) {
			if got := Split(c.input, c.sep); !reflect.DeepEqual(c.wanted, got) {
				t.Errorf("wanted:%#v;goted:%#v\n", c.wanted, got)
			}
		})
	}
}
[root@heyingsheng split]# go test -v
=== RUN   TestSplit
=== RUN   TestSplit/EasyABCTest
=== RUN   TestSplit/ABCTest
=== RUN   TestSplit/Chinesetest
--- FAIL: TestSplit (0.00s)
    --- PASS: TestSplit/EasyABCTest (0.00s)
    --- PASS: TestSplit/ABCTest (0.00s)
    --- FAIL: TestSplit/Chinesetest (0.00s)
        split_test.go:40: wanted:[]string{"自来水来自海上"};goted:[]string{"", "自来水来自海上"}
FAIL
exit status 1
FAIL    learn/tests/utils/split 0.006s

2.3. 测试覆盖率

一般来说,被测试的函数、方法的覆盖率应该是100%,代码覆盖率应该在60%以上。代码覆盖率可以使用内置的go命令来输出结果,并优化展示效果!

[root@heyingsheng split]# go test -cover
PASS
coverage: 100.0% of statements
ok      learn/tests/utils/split 0.005s
[root@heyingsheng split]# go test -cover -coverprofile=/tmp/c.out
PASS
coverage: 100.0% of statements
ok      learn/tests/utils/split 0.013s
[root@heyingsheng split]# go  tool cover -html=/tmp/c.out
HTML output written to /tmp/cover193709225/coverage.html

image.png


3. 基准测试

基准测试就是性能测试,通过测试一段代码的执行时间、CPU和内存使用情况来判断代码是否需优化,以及如何优化!Benchmark函数会运行目标代码 b.N 次,在基准执行期间,会调整 b.N 直到基准测试函数持续足够长的时间。

3.1. 常用方法

1.	基准测试函数格式
    Syntax: func BenchmarkXxxx(t *testing.T) { }
    Man:    其中 Xxx 可以是任何字母数字字符串(但第一个字母不能是 [a-z])

2.	结构体
		Syntax:	type B struct { }
    Man:		B是传递给基准测试函数的一种类型它用于管理基准测试的计时行为,并指示应该迭代地运行测试多少次。

3.	常用方法(参考测试函数的方法)
    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) Run(name string, f func(b *B)) bool
    func (c *B) Skip(args ...interface{})
    func (c *B) SkipNow()
    func (c *B) Skipf(format string, args ...interface{})
    func (c *B) Skipped() bool
    
4.	特殊方法
    (1) ReportAllocs()
        Syntax: func (b *B) ReportAllocs()
        Man:    打开当前基准测试的内存统计功能,与使用 -test.benchmem 设置类似
                但 ReportAllocs 只影响那些调用了该函数的基准测试
        
    (3) SetBytes()
        Syntax: func (b *B) SetBytes(n int64)
        Man:    记录在单个操作中处理的字节数量。
                在调用了这个方法之后,基准测试将会报告 ns/op 以及 MB/s 
        
    (4) ResetTimer()
        Syntax: func (b *B) ResetTimer()
        Man:    对已经逝去的基准测试时间以及内存分配计数器进行清零。
                对于正在运行中的计时器,这个方法不会产生任何效果。
        
    (5) StartTimer()
        Syntax: func (b *B) StartTimer()
        Man:    开始对测试进行计时。 
                这个函数在基准测试开始时会自动被调用,它也可以在调用 StopTimer 之后恢复进行计时。
        
    (6) StopTimer()
        Syntax: func (b *B) StopTimer()
        Man:    停止对测试进行计时。 
                当需要执行一些复杂的初始化操作,且不想对这些操作进行测量时, 可以使用这个方法来暂停计时

3.2. 测试函数案例

3.2.1. 基本案例

package split

import (
	"reflect"
	"testing"
)

func BenchmarkSplit(b *testing.B) {
	for i:=0; i<b.N;i++ {
		Split("上海自来水来自海上","海")
	}
}
[root@heyingsheng split]# go test -bench=Split -benchmem
goos: linux
goarch: amd64
pkg: learn/tests/utils/split
BenchmarkSplit-12        6480142               182 ns/op             112 B/op          3 allocs/op
PASS
ok      learn/tests/utils/split 1.377s
结果分析:
6480142				执行的次数
182 ns/op			每次操作耗费的时间为 182 ns
112 B/op 			每次操作需要消耗的内存大小
3 allocs/op		每次操作需要分配的内存次数

针对以上分析结果,对代码进行优化,将line6改为: var res = make([]string, 0, strings.Count(s, sep)+1) ,性能提升非常明显。

[root@heyingsheng split]# go test -bench=Split -benchmem
goos: linux
goarch: amd64
pkg: learn/tests/utils/split
BenchmarkSplit-12       11175243               104 ns/op              48 B/op          1 allocs/op
PASS
ok      learn/tests/utils/split 1.276s

3.2.2. 性能比较函数

在对性能要求很高的场景中,需要对不同算法进行性能比较,以及某个参数在取不同值时对性能的影响程度,这种情况下会使用到性能比较函数。

package sequence

// 兔子数列问题  : F(1)=1,F(2)=1, F(n)=F(n - 1)+F(n - 2) , (n ≥ 3,n ∈ N*)
func ReRes(n uint64) uint64 {  // 递归方式求兔子数列
	if n == 0 {
		return 0
	}else if n == 1 {
		return 1
	} else if n == 2 {
		return 1
	} else {
		return ReRes(n-1) + ReRes(n-2)
	}
}

// 使用循环解决兔子数列问题
func LoopRes(n uint64) uint64 {
	var (
		x uint64 = 1
		y uint64 = 1
	)
	for i:=uint64(1); i <= n; i++ {
		if i > 2{
			y, x = y+x, y
		}
	}
	return y
}
package sequence

import "testing"

func benchmarkReRes(b *testing.B, fn func(n uint64) uint64, n uint64) {
	for i:=0; i<b.N; i++ {
		fn(n)
	}
}

func BenchmarkTest1(b *testing.B) {	benchmarkReRes(b, LoopRes, 30) }

func BenchmarkTest2(b *testing.B) {	benchmarkReRes(b, ReRes, 30) }

func BenchmarkTest21(b *testing.B) { benchmarkReRes(b, ReRes, 2) }
func BenchmarkTest22(b *testing.B) { benchmarkReRes(b, ReRes, 4) }
func BenchmarkTest23(b *testing.B) { benchmarkReRes(b, ReRes, 8) }
func BenchmarkTest24(b *testing.B) { benchmarkReRes(b, ReRes, 16) }
func BenchmarkTest25(b *testing.B) { benchmarkReRes(b, ReRes, 32) }
[root@heyingsheng sequence]# go test --bench=. -benchmem
goos: linux
goarch: amd64
pkg: learn/tests/utils/sequence
BenchmarkTest1-12       50868148                22.8 ns/op             0 B/op          0 allocs/op
BenchmarkTest2-12            387           3010651 ns/op               0 B/op          0 allocs/op
BenchmarkTest21-12      656601691                1.80 ns/op            0 B/op          0 allocs/op
BenchmarkTest22-12      146382860                8.21 ns/op            0 B/op          0 allocs/op
BenchmarkTest23-12      18137901                66.9 ns/op             0 B/op          0 allocs/op
BenchmarkTest24-12        332538              3550 ns/op               0 B/op          0 allocs/op
BenchmarkTest25-12           147           8019416 ns/op               0 B/op          0 allocs/op
PASS
ok      learn/tests/utils/sequence      10.577s