19. 单元测试
19. 单元测试

19. 单元测试

大家好,我是 “潇洒哥老苗”。
该系列上篇讲解了 《并发》,今天我们学学 Go 语言中的单元测试。
依赖 Go 版本:1.16.4。
源码地址:

学到什么

  1. 什么是单元测试?
  1. 如何编写单元测试?
  1. 什么是代码覆盖率?
  1. 如何使用 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 工具,直接点击下图的红框位置即可。
notion image

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 文件在浏览器渲染出来,运行后会自动在浏览器出现如下图:
notion image
灰色不用管,绿色的已覆盖,红色的未覆盖。
下来回到 profile 文件的内容,看图说明:
notion image
  • 第一行,覆盖率模式;
  • 剩下三行,对应下图不同颜色的下划线。
notion image
可得:总语句数为 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 语句被执行了两次,看下图再说明下:
notion image
  • 执行 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 种统计方式,对于如何给函数和方法写单元测试,一定要掌握。
如果在测试代码时发现了和我所写的结果有出入,那可能就是版本差异。
有问题的话,随意讨论。