大家好,我是 “潇洒哥老苗”。

该系列上篇讲解了 《并发》,今天我们学学 Go 语言中的单元测试。

依赖 Go 版本:1.16.4。

源码地址:

https://github.com/miaogaolin/gobasic

学到什么

  1. 什么是单元测试?

  2. 如何编写单元测试?

  3. 什么是代码覆盖率?

  4. 如何使用 testify 包?

引入

先不讲解 “单元测试” 的概念,在不使用 “单元测试” 的情况下,我们如何测试一个函数或方法的正确性。

例如,如下函数:

  
// gobasic/unittest/add.go
  
func Add(num1, num2 int) int {
  
	return num1 + num2
  
}
  

这个函数逻辑很简单,只进行 num1 和 num2 两数的相加。在实际开发中对这样的逻辑没必要进行单元测试,现在咱就假设这个函数逻辑很复杂,需要测试才知道对不对。

测试如下:

  
package main
  

  
import "fmt"
  

  
func main() {
  
	excepted := 5
  
	actual := Add(2, 3)
  
	if excepted == actual {
  
		fmt.Println("成功")
  
	} else {
  
		fmt.Println("失败")
  
	}
  
}
  

对于这样的测试方式,它有如下问题:

  • 测试代码和业务代码混乱、不分离;

  • 测试完后,测试代码必须删除;

  • 如果不删除,会参与编译。

你可能会说,可以使用 debug 方式测试,但这样,没有任何测试过程,后期如果修改了代码,如何确定当时什么样的结果是正确的。

下来,引入 “单元测试” 的概念,以解决上述所说的问题。

什么是单元测试

根据维基百科的定义,单元测试又称为模块测试,是针对程序模块(软件设计的最小单元)来进行正确性检验的测试工作。

在 Go 语言中,测试的最小单元常常是函数和方法。

测试文件

简单了解了概念后,现在就开始创建一个单元测试文件。

在很多语言中,常常把测试文件放在一个独立的目录下进行管理,而在 Go 语言中会和源文件放置在一块,即同一目录下。

例如,对于上面的 Add 函数,所在文件是 add.go,那创建的测试文件也和它放在一块,如下:

  • unitest 目录

    • add.go

    • add_test.go 单元测试

假如源文件的命名是 xxx.go, 那单元测试文件的命名则为 xxx_test.go。如果在编译阶段 xxx_test.go 文件会被忽略。

写单元测试

下来我们一块在 add_test.go 文件中给 Add 函数写一个单元测试。

1. 基本结构

先看看基本结构,具体的测试内容没写,如下:

  
// gobasic/unittest/add_test.go
  
package unittest
  

  
import "testing"
  

  
func TestAdd(t *testing.T) {
  
	// ...
  
}
  
  • 导入 testing 标准包;

  • 创建一个 Test 开头的函数名 TestAdd,Test 是固定写法,后面的 Add 一般和你要测试的函数名对应,当然不对应也没有问题;

  • 参数类型 *tesing.T 用于打印测试结果,参数中也必须跟上。

所有的单元测试函数都要按照该要求定义,定义好后,下来看看如何编写测试内容。

2. 测试内容

测试 Add 函数的计算结果是否正确。

  
// gobasic/unittest/add_test.go
  
package unittest
  

  
import "testing"
  

  
func TestAdd(t *testing.T) {
  
	excepted := 4
  
	actual := Add(2, 3)
  
	if excepted != actual {
  
		t.Errorf("excepted:%d, actual:%d", excepted, actual)
  
	}
  
}
  
  • excepted 函数期待的结果;

  • actual 函数真实计算的结果;

  • 如果不相等,打印出错误。

在 unittest 目录下运行 go test (或 go test ./)命令,表示运行 unittest 目录下的单元测试,不会再往下递归。如果想往下递归,即当前目录下还有目录,则运行 go test ./... 命令。

运行结果:

  
$ go test
  
--- FAIL: TestAdd (0.00s)
  
    add_test.go:11: excepted:4, actual:5
  
FAIL
  
FAIL    github.com/miaogaolin/gobasic/unittest  0.228s
  
FAIL
  

结果中看出 TestAdd 函数运行失败,并打印出了错误行数 11 和 组装的日志。

假如你使用了 Goland 工具,直接点击下图的红框位置即可。

*testing.T

现在对参数类型 T 中的几个方法展开说说,如下:

  • Error 打印错误日志、标记为失败 FAIL,并继续往下执行。

  • Errorf 格式化打印错误日志、标记为失败 FAIL,并继续往下执行。

  • Fail 不打印日志,结果中只标记为失败 FAIL,并继续往下执行。

  • FailNow 不打印日志,结果中只标记为失败 FAIL,但在当前测试函数中不继续往下执行。

  • Fatal 打印日志、标记为失败,并且内部调用了 FaileNow 函数,也不往下执行。

  • Fatalf 格式化打印错误日志、标记为失败,并且内部调用了 FaileNow 函数,也不往下执行。

你可能发现,没有成功的方法,不过确实也没有,只要没有通知错误,那就说明是正确的。正确的测试结果是下面这个样子:

  
$ go test
  
ok      github.com/miaogaolin/gobasic/unittest  0.244s
  

测试资源

有时候在你写单元测试时,可能需要读取文件,那这些相关的资源文件就放置在 testdata 目录下。

示例:

  • unittest 目录

    • xxx.go

    • xxx_test.go

    • testdata 目录

go test 和 go vet

在运行 go test 命令后,go vet 命令也会自动运行。

简单说下 go vet 命令,本篇不过多描述。它用于代码的静态分析,检查编译器检查不出的错误,例如:

  
// gobasic/vet/main.go
  
package main
  

  
import "fmt"
  

  
func main() {
  
	fmt.Printf("%d", "miao")
  
}
  

  
// 输出
  
%!d(string=miao)
  

看结果是不是很奇怪,是因为占位符 %d 需要的是整数,但给的是字符串。不熟悉占位符的朋友,直接前往 《详解 20 个占位符》

对于这种类似的错误,编译器是不会报错的,这时候就用到了 go vet 命令,运行如下:

  
$ go vet
  
# github.com/miaogaolin/gobasic/vet
  
.\main.go:6:2: Printf format %d has arg "miao" of wrong type string
  

所以在测试时无需单独运行 go vet 命令,一个 go test 命令就包含了。

表格驱动测试

在对于一个函数或方法进行测试时,很多时候要测试多种情况,那对于多种情况如何进行测试呢?下来看看。

  
// gobasic/unittest/add_test.go
  
package unittest
  

  
import "testing"
  

  
func TestAdd1(t *testing.T) {
  
	excepted := 5
  
	actual := Add(2, 3)
  
	if excepted != actual {
  
		t.Errorf("case1:excepted:%d, actual:%d", excepted, actual)
  
	}
  

  
	excepted = 10
  
	actual = Add(0, 10)
  
	if excepted != actual {
  
		t.Errorf("case2:excepted:%d, actual:%d", excepted, actual)
  
	}
  
}
  

通过上述代码,我们可以看出,如果遇到多种情况时,再使用 if 语句判断即可。你可能心里会嘀咕: “这还用你说,不是废话吗!”。

下来开始我真正想说的,如果我们想要测试的情况比较多,按照上面这种写法看起来就会很冗余,所以我们改为下面的写法:

  
// gobasic/unittest/add_test.go
  
package unittest
  

  
import "testing"
  

  
func TestAddTable(t *testing.T) {
  
	type param struct {
  
		name                 string
  
		num1, num2, excepted int
  
	}
  

  
	testCases := []param{
  
		{name: "case1", num1: 2, num2: 3, excepted: 5},
  
		{name: "case2", num1: 0, num2: 10, excepted: 10},
  
	}
  
	for _, v := range testCases {
  
		t.Run(v.name, func(t *testing.T) {
  
			actual := Add(v.num1, v.num2)
  
			if v.excepted != actual {
  
				t.Errorf("excepted:%d, actual:%d", v.excepted, actual)
  
			}
  
		})
  
	}
  
}
  
  • 通过切片保存每种想要测试的情况(测试用例),下来只需要通过循环判断即可;

  • t.Run 方法,第一个参数是当前测试的名称,第二个是个匿名函数,用来写判断逻辑。

运行结果:

  
$  go test add.go add_test.go -test.run TestAddTable -v
  
=== RUN   TestAddTable
  
=== RUN   TestAddTable/case1
  
=== RUN   TestAddTable/case2
  
--- PASS: TestAddTable (0.00s)
  
    --- PASS: TestAddTable/case1 (0.00s)
  
    --- PASS: TestAddTable/case2 (0.00s)
  
PASS
  
ok      command-line-arguments  0.041s
  
  • go test 命令后的 add.go 和 add_test.go 文件是特意指定需要测试和依赖的文件;

  • -test.run 指明测试的函数名;

  • -v 展示详细的过程,如果不写,测试成功时,不会打印详细过程。

缓存

当运行单元测试时,测试的结果会被缓存下来。如果更改了测试代码或源文件,则会重新运行测试,并再次缓存。

但不是任何情况都可以缓存下来,只有当 go test 命令后跟着目录、指定的文件或包名才可以,举例如下:

  • go test ./

  • go test ./pkg

  • go test add.go add_test.go

  • go test fmt

如果我在 unittest 目录下运行测试,第一次和第二的结果如下:

  
# 第一次
  
$ go test ./
  
ok      github.com/miaogaolin/gobasic/unittest  0.228s
  

  
# 第二次
  
$ go test ./
  
ok      github.com/miaogaolin/gobasic/unittest  (cached)
  

可以看到第二次的结果中出现了 cached 字样,如果你问 “删掉后面的 ./” 可以吗?答:不可以,因为不会进行缓存。

1. 禁用缓存

如果想禁用缓存,可以使用如下命令运行:

  
go test ./ -count=1
  

2. 其它情况

上面说过,当单元测试文件或源文件修改时,会重新缓存。

但还有其它情况也会如此,比如当你的单元测试中涉及了如下情况:

  • 读取环境变量的内容更改

  • 读取文件的内容更改

这两种情况不会影响测试文件和源文件的修改,但还是会重新缓存测试结果。

并发测试

为了提高多个单元测试的运行效率,我们可以采取并发测试。先看一个没有并发的例子,如下:

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

该例子中没有写任何具体的测试逻辑,只是每个函数休眠了 1s 中,目的只是演示测试的时间。

测试结果如下:

  
ok      command-line-arguments  3.242s
  

可以看到总共花费了 3.242s。

下来加入并发,如下:

  
func TestA(t *testing.T) {
  
	t.Parallel()
  
	time.Sleep(time.Second)
  
}
  

  
func TestB(t *testing.T) {
  
	t.Parallel()
  
	time.Sleep(time.Second)
  
}
  

  
func TestC(t *testing.T) {
  
	t.Parallel()
  
	time.Sleep(time.Second)
  
}
  

在每个测试函数前增加了 t.Parallel() 实现并发。

测试如下:

  
ok      command-line-arguments  1.049s
  

很明显可以看到,测试的时间缩短到了 1s,大概是原来时间的三分之一。

代码覆盖率

代码覆盖率是一个指数,例如:20%、30% 、100% 等。

它体现了你的项目代码是否得到了足够的测试,指数越大,说明测试的覆盖情况越全面。

命令如下:

  
$ go test -cover
  
PASS
  
coverage: 100.0% of statements
  
ok      github.com/miaogaolin/gobasic/unittest  1.045s
  
  • -cover 输出覆盖率的标识符;

  • 覆盖率为 100%,说明被测试的函数代码都有运行到,覆盖率 = 已执行语句数 / 总语句数

在计算覆盖率时,还有三种模式,不同的模式在已执行语句的次数统计时存在差异性。

1. 模式 set

这是默认的模式,它的计算方式是 “如果同一语句多次执行只记录一次”。

举例看个例子,如下:

  
func GetSex(sex int) string {
  
	if sex == 1 {
  
		return "男"
  
	} else {
  
		return "女"
  
	}
  
}
  

下来给这个函数写个单元测试,如下:

  
func TestGetSex(t *testing.T) {
  
	excepted := "男"
  
	actual := GetSex(1)
  
	if actual != excepted {
  
		t.Errorf("excepted:%s, actual:%s", excepted, actual)
  
	}
  
}
  

我就不解释这个测试函数了,你很聪明的。

运行覆盖率命令:

  
$ go test -cover
  
ok      command-line-arguments  0.228s  coverage: 66.7% of statements
  

这次的覆盖率可不是 100% 了,那为啥是 66.7%,往下看。

在终端运行如下命令:

  
go test -coverprofile profile
  

运行后,会在当前目录生成一个覆盖率的采样文件 profile,打开内容如下:

  
mode: set
  
github.com/miaogaolin/gobasic/testcover/sex.go:3.29,4.14 1 1
  
github.com/miaogaolin/gobasic/testcover/sex.go:4.14,6.3 1 1
  
github.com/miaogaolin/gobasic/testcover/sex.go:6.8,8.3 1 0
  

暂时先不介绍这个文件内容细节,先使用这个文件生成一个直观图,命令如下:

  
go tool cover -html profile
  

-html profile 指明将 profile 文件在浏览器渲染出来,运行后会自动在浏览器出现如下图:

灰色不用管,绿色的已覆盖,红色的未覆盖。

下来回到 profile 文件的内容,看图说明:

  • 第一行,覆盖率模式;

  • 剩下三行,对应下图不同颜色的下划线。

可得:总语句数为 3,覆盖语句(执行语句)数为 2,计算覆盖率为 2/3 = 66.7%。

如果想达到 100% 覆盖,只需要增加 else 的测试情况,如下:

  
func TestGetSex2(t *testing.T) {
  
	excepted := "女"
  
	actual := GetSex(0)
  
	if actual != excepted {
  
		t.Errorf("excepted:%s, actual:%s", excepted, actual)
  
	}
  
}
  

2. 模式 count

该模式和 set 模式比较相似,唯一的区别是 count 模式对于相同的语句执行次数会进行累计。

使用下面命令生成 profile 文件:

  
go test -coverprofile profile -covermode count
  

这次测试,会将 TestGetSex 和 TestGetSex2 函数都运行,自然也会 100% 覆盖。

profile 文件内容:

  
mode: count
  
github.com/miaogaolin/gobasic/testcover/sex.go:3.29,4.14 1 2
  
github.com/miaogaolin/gobasic/testcover/sex.go:4.14,6.3 1 1
  
github.com/miaogaolin/gobasic/testcover/sex.go:6.8,8.3 1 1
  

如果再切换到 set 模式下生成,唯一不同点是,内容第二行中的最后一个数字 2 在 set 模式下会是 1。

那 count 模式下为啥是 2 呢?

因为 if sex == 1 语句被执行了两次,看下图再说明下:

  • 执行 TestGetSex 和 TestGetSex2 函数时,if sex == 1 都会被执行一次,因此总共 2 次,而剩下的语句只执行了 1 次。

  • 绿色表示覆盖率最高,下来是 low coverage 对应的颜色,表示低覆盖率。

总结,count 模式下能看出哪些代码执行的次数多,而 set 模式下不能。

3. 模式 atomic

该模式和 count 类似,都是统计执行语句的次数,不同点是,在并发情况下 atomic 模式比 count 模式计数更精确。

来看一个没啥用的并发例子,测试两者统计的结果,如下:

  
// gobasic/testatomic/nums.go
  
package testatomic
  

  
import "sync"
  

  
func AddNumber(num int) int {
  
	var wg sync.WaitGroup
  

  
	for i := 0; i < 200; i++ {
  
		wg.Add(1)
  
		go func(i int) {
  
			i += num
  
			wg.Done()
  
		}(i)
  
	}
  
	wg.Wait()
  
	return num
  
}
  

该代码创建了 200 个 Goroutine,再对 200 个数并发的与 num 参数相加。

单元测试的代码就不写了,只要调用了该函数就可以。如果想看,直接在 Github 上看完整代码。

count 模式下生成的 profile 文件内容如下:

  
mode: count
  
github.com/miaogaolin/gobasic/testatomic/nums.go:5.29,8.27 2 1
  
github.com/miaogaolin/gobasic/testatomic/nums.go:15.2,16.12 2 1
  
github.com/miaogaolin/gobasic/testatomic/nums.go:8.27,10.18 2 200
  
github.com/miaogaolin/gobasic/testatomic/nums.go:10.18,13.4 2 199
  

直接看最后一行,对应到源码上是 Goroutine 的代码块,即:go func(i int) {...}

199 表示的是该语句的执行次数,但循环次数总共是 200 次,所以是不准确的。

那再以 atomic 模式运行,命令如下:

  
go test -coverprofile profile -covermode atomic
  

profile 文件内容如下:

  
mode: atomic
  
github.com/miaogaolin/gobasic/testatomic/nums.go:5.29,8.27 2 1
  
github.com/miaogaolin/gobasic/testatomic/nums.go:15.2,16.12 2 1
  
github.com/miaogaolin/gobasic/testatomic/nums.go:8.27,10.18 2 200
  
github.com/miaogaolin/gobasic/testatomic/nums.go:10.18,13.4 2 200
  

直接看内容的最后一个数字,这下正确了。

testify 包

当对一个项目中写大量的单元测试时,如果按照上述的方式去写,就会产生大量的判断语句。

例如这样的 if 判断:

  
func TestAdd(t *testing.T) {
  
	excepted := 4
  
	actual := Add(2, 3)
  
	if excepted != actual {
  
		t.Errorf("excepted:%d, actual:%d", excepted, actual)
  
	}
  
}
  

下来我推荐一个第三方包 testfiy,首先在终端运行如下命令,表示下载该包。

  
go get github.com/stretchr/testify
  

改写单元测试代码,如下:

  
package unittest
  

  
import (
  
	"github.com/stretchr/testify/assert"
  
	"testing"
  
)
  

  
func TestAdd(t *testing.T) {
  
	excepted := 4
  
	actual := Add(2, 3)
  
	assert.Equal(t, excepted, actual)
  
}
  
  • 导入 testify 包下的一个子包 assert;

  • 使用 assert.Equal 函数简化 if 语句和日志打印,该函数期待 excepted 和 actual 变量相同,如果不相同会打印失败日志。

看看失败是啥样子,如下:

  
--- FAIL: TestAdd (0.00s)
  
    add_test.go:11: 
  
                Error Trace:    add_test.go:11
  
                Error:          Not equal: 
  
                                expected: 4
  
                                actual  : 5
  
                Test:           TestAdd
  
FAIL
  
FAIL    command-line-arguments  0.578s
  
FAIL
  

也是打印出了期待的值和实际的值,并说明了两值不相等。

当然该包也不只有 Equal 函数,这个学习就留给自己了,相信你可以的。

小结

本篇讲解了 Go 语言中如何写单元测试,并讲了代码覆盖率的 3 种统计方式,对于如何给函数和方法写单元测试,一定要掌握。

如果在测试代码时发现了和我所写的结果有出入,那可能就是版本差异。

有问题的话,随意讨论。