大家好,我是 “潇洒哥老苗”。
该系列上篇讲解了 《并发》 ,今天我们学学 Go 语言中的单元测试。
依赖 Go 版本:1.16.4。
源码地址:
https://github.com/miaogaolin/gobasic
学到什么
- 什么是单元测试?
- 如何编写单元测试?
- 什么是代码覆盖率?
- 如何使用 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 种统计方式,对于如何给函数和方法写单元测试,一定要掌握。
如果在测试代码时发现了和我所写的结果有出入,那可能就是版本差异。
有问题的话,随意讨论。