@@ -1,58 +0,0 @@ 34.单元测试了解 | 凤凰涅槃进阶之路

34.单元测试了解

Abel sun2023年1月6日约 2615 字大约 9 分钟

34.单元测试了解

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

1,go test

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

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

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

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

2,测试函数格式

每个测试函数必须导入testing包,测试函数基本格式如下:

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

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

func TestAdd(t *testing.T){...}
func TestSum(t *testing.T){...}

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

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) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool

3,测试函数示例

模拟日常开发过程, 我们先写一个功能单一的程序,这个程序可以分割字符串,代码如下:

package split

import "strings"

// Split 切割一个字符串
func Split(str string, sep string) []string {
 var tmp []string
 index := strings.Index(str, sep)
 for index >= 0 {
  tmp = append(tmp, str[:index])
  str = str[index+1:]
  index = strings.Index(str, sep)
 }
 tmp = append(tmp, str)
 return tmp
}

在同级目录下,可以创建一个 split_test.go的测试文件,并定义一个测试函数如下:

package split

import (
 "reflect"
 "testing"
)

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

func Test2Split(t *testing.T) {
 got := Split("abcdef", "c")
 want := []string{"ab", "def"}
 if !reflect.DeepEqual(want, got) {
  t.Errorf("test failed, want:%v but got:%v\n", want, got)
 }
}

然后在这个目录中,直接运行 go test可以看到输出结果:

$ go test 
PASS
ok      oldboy/day09/split      0.005s

使用 go test -v可以看到详细内容:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
=== RUN   Test2Split
--- PASS: Test2Split (0.00s)
PASS
ok      oldboy/day09/split      0.005s

如上测试用例当中写了两个测试用例,都验证通过了,现在再写一个如下内容的用例:

func Test3Split(t *testing.T) {
 got := Split("abcdef", "cd")
 want := []string{"ab", "ef"}
 if !reflect.DeepEqual(want, got) {
  t.Errorf("test failed, want:%v but got:%v\n", want, got)
 }
}

跑一下,发现报错了:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
=== RUN   Test2Split
--- PASS: Test2Split (0.00s)
=== RUN   Test3Split
--- FAIL: Test3Split (0.00s)
    split_test.go:33: test failed, want:[ab ef] but got:[ab def]
FAIL
exit status 1
FAIL    oldboy/day09/split      0.004s

看起来程序采用这个用例就跑不过去了,那么就需要调整程序:

package split

import (
 "fmt"
 "strings"
)

// Split 切割一个字符串
func Split(str string, sep string) []string {
 var tmp []string
 index := strings.Index(str, sep)
 fmt.Println(index)
 for index >= 0 {
  tmp = append(tmp, str[:index])
  str = str[index+len(sep):]  // 这里使用len(sep)获取sep的长度
  index = strings.Index(str, sep)
  fmt.Println(index)
 }
 tmp = append(tmp, str)
 return tmp
}

再跑一下,就通过。

4,测试组

我们现在还想要测试一下split函数对中文字符串的支持,这个时候我们可以再编写一个TestChineseSplit测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。

package split

import (
 "reflect"
 "testing"
)

func TestSplit(t *testing.T) {
 // 定义一个测试用例类型
 type testCase struct {
  str  string
  sep  string
  want []string
 }
 // 定义一个存储测试用例的切片
 testGroup := []testCase{
  testCase{"a:b:c", ":", []string{"a", "b", "c"}},
  testCase{"abcdef", "c", []string{"ab", "def"}},
  testCase{"你好吗", "好", []string{"你", "吗"}},
 }
 // 遍历切片,逐一执行测试用例
 for _, tc := range testGroup {
  got := Split(tc.str, tc.sep)
  if !reflect.DeepEqual(tc.want, got) {
   t.Fatalf("test failed,want:%v,got:%v\n", tc.want, got)
  }
 }
}

执行结果如下:

$ go test -v
=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok      oldboy/day09/split      0.004s

5,子测试

上边的定义方式大大简化了针对同一功能代码用例的编写,只需要在切片中新增一个testCase即可,但是有一个不足就是所有用例执行结果不能够清晰逐个看到。在Go1.7+中新增了子测试,可以通过定义map的方式,将每个用例名字打印出来:

package split

import (
 "reflect"
 "testing"
)

func TestSplit(t *testing.T) {
 // 定义一个测试用例类型
 type testCase struct {
  str  string
  sep  string
  want []string
 }
 // 定义一个存储测试用例的map
 testGroup := map[string]testCase{
  "case-1": testCase{"a:b:c", ":", []string{"a", "b", "c"}},
  "case-2": testCase{"abcdef", "c", []string{"ab", "def"}},
  "case-3": testCase{"你好吗", "好", []string{"你", "吗"}},
 }
 // 遍历map,逐一执行测试用例
 for name, tc := range testGroup {
  t.Run(name, func(t *testing.T) {
   got := Split(tc.str, tc.sep)
   if !reflect.DeepEqual(tc.want, got) {
    t.Fatalf("test failed,want:%#v,got:%#v\n", tc.want, got)
   }
  })

 }
}

执行效果如下:

$ go test -v
=== RUN   TestSplit
=== RUN   TestSplit/case-3
=== RUN   TestSplit/case-1
=== RUN   TestSplit/case-2
--- PASS: TestSplit (0.00s)
    --- PASS: TestSplit/case-3 (0.00s)
    --- PASS: TestSplit/case-1 (0.00s)
    --- PASS: TestSplit/case-2 (0.00s)
PASS
ok      oldboy/day09/split      0.005s

如果想要指定执行其中某个用例,可用如下方式进行:

$ go test -v -run=TestSplit/case-2
=== RUN   TestSplit
=== RUN   TestSplit/case-2
--- PASS: TestSplit (0.00s)
    --- PASS: TestSplit/case-2 (0.00s)
PASS
ok      oldboy/day09/split      0.004s

6,测试覆盖率

测试覆盖率是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。

Go提供内置功能来检查你的代码覆盖率。我们可以使用go test -cover来查看测试覆盖率。例如:

$ go test -cover
PASS
coverage: 100.0% of statements
ok      oldboy/day09/split      0.004s

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

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

$ go test -cover -coverprofile=c.out 
PASS
coverage: 100.0% of statements
ok      oldboy/day09/split      0.004s

上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中,然后我们执行go tool cover -html=c.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。

image-20200219225154550

图中每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。

日常开发当中:

函数覆盖率:应该达到100%

代码覆盖率:应该超过60%

7,基准测试函数

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

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()

1,基准测试示例

我们为split包中的Split函数编写基准测试如下:

$ cat split_test.go

package split

import (
 "testing"
)

func BenchmarkSplit(b *testing.B) {
 for i := 0; i < b.N; i++ {
  Split("a:b:c:d:e", ":")
 }
}

基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test -bench=Split命令执行基准测试,输出结果如下:

$ go test -bench=Split
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4         5000000               242 ns/op
PASS
ok      oldboy/day09/split      1.473s

其中BenchmarkSplit-4表示对Split函数进行基准测试,数字4表示GOMAXPROCS的值,这个对于并发基准测试很重要。5000000242ns/op表示每次调用Split函数耗时242ns,这个结果是5000000次调用的平均值。

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

$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4         5000000               250 ns/op             240 B/op          4 allocs/op
PASS
ok      oldboy/day09/split      1.515s

其中,240 B/op表示每次操作内存分配了240字节,4 allocs/op则表示每次操作进行了3次内存分配。 我们将我们的Split函数优化如下:

// Split 切割一个字符串
func Split(str string, sep string) []string {
 var tmp = make([]string, 0, strings.Count(str, sep)+1)  // 在声明变量的时候直接make分配内存空间
 index := strings.Index(str, sep)
 for index >= 0 {
  tmp = append(tmp, str[:index])
  str = str[index+len(sep):]
  index = strings.Index(str, sep)
 }
 tmp = append(tmp, str)
 return tmp
}

改进之后再次进行性能测试看看效果:

$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4        20000000               101 ns/op              80 B/op          1 allocs/op
PASS
ok      oldboy/day09/split      2.146s

这个使用make函数提前分配内存的改动,减少了2/3的内存分配次数,并且减少了一半的内存分配。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.9.1