认真一点学 Go:19. 单元测试

收录于 《Go 基础系列》,作者:潇洒哥老苗,原文链接

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

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

依赖 Go 版本:1.16.4。

源码地址:

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 种统计方式,对于如何给函数和方法写单元测试,一定要掌握。

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

有问题的话,随意讨论。

本作品采用《CC 协议》,转载必须注明作者和本文链接
潇洒哥老苗
讨论数量: 2

打卡!!!

3年前 评论

测试依赖 mysql 时,不好写

2年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!