掘金 后端 ( ) • 2024-03-06 14:44

Go 笔记之如何测试你的 Go 代码

这篇文章是go学习笔记第三部分主要参考来源如下:

参考博主文章:如何测试你的 Go 代码 - POLOXUE's BLOG

参考文章来源:前景 · Go语言中文文档 (topgoer.com)

参考补充知识部分的文章:https://blog.csdn.net/m0_37710023/article/details/108284171

最易想到的方法

谈到如何测试一个函数的功能,对开发来说,最容易想到的方法就是在 main 中直接调用函数判断结果。

举个例子,测试 math 方法下的绝对值函数 Abs,示例代码如下:

package main
​
import (
    "fmt"
    "math"
)
​
func main() {
    v := math.Abs(-10)
    if v != 10 {
        fmt.Println("测试失败")
        return
    }
​
    fmt.Println("测试成功")
}

更常见的可能是,if 判断都没有,直接 Print 输出结果,我们观察结果确认问题。特别对于习惯使用 Python、PHP 脚本语言的开发, 建一个脚本测试是非常快速的,因为曾经很长一段时间,我就是如此。

这种方式有什么缺点?我的理解,主要几点,如main 中的测试不容易复用,常常是建了就删;测试用例变多时,灵活性不够,常会有修改代码的需求;自动化测试也不是非常方便等等问题。

遇到了问题就得解决,下面正式开始进入 go testing 中单元测试的介绍。

go Test工具

原文:单元测试 · Go语言中文文档 (topgoer.com)

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

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

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

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

Golang单元测试对文件名和方法名,参数都有很严格的要求。

    1、文件名必须以xx_test.go命名
    2、方法必须是Test[^a-z]开头
    3、方法参数必须 t *testing.T
    4、使用go test执行单元测试

一个快速体验案例

单元测试用于在指定场景下,测试功能模块在指定的输入情况下,确定有没有按期望结果输出结果。

我们直接看个例子,简单直观。测试 math 下的 Abs 绝对值函数。首先,在某个目录创建测试文件 math_test.go,代码如下:

package math
​
import (
    "math"
    "testing"
)
​
func TestAbs(t *testing.T) {
    var a, expect float64 = -10, 10
​
    actual := math.Abs(a)
    if actual != expect {
        t.Fatalf("a = %f, actual = %f, expected = %f", a, actual, expect)
    }
}

程序非常简洁,a 是 Abs 函数的输入参数,expect 是期望得到的执行结果,actual 是函数执行的实际结果,测试结果由 actual 和 expect 比较结果确定。

完成用例编写,go test 命令执行测试,我们会看到如下输出。

$ go test
PASS
ok      study/test/math 0.004s

输出为 PASS,表示测试用例成功执行。0.004s 表示用例执行时间。

学会使用 go testing

从前面例子中可以了解到,Go 的测试写起来还是非常方便的。关于它的使用方式,主要有两点,一是测试代码的编写规则,二是 API 的使用。

测试的编写规则

Go 的测试必须按规则方式编写,不然 go test 将无法正确定位测试代码的位置,主要三点规则。

首先,测试代码文件的命名必须是以 _test.go 结尾,比如上节中的文件名 math_tesh.go 并非随意取的。

还有,代码中的用例函数必须满足匹配 TestXxx,比如 TestAbs。

关于 Xxx,简单解释一下,它主要传达两点含义,一是 Xxx 表示首个字符必须大写或数字,简单而言就是可确定单词分隔,二是首字母后的字符可以是任意 Go 关键词合法字符,如大小写字母、下划线、数字。

第三,关于用例函数类型定义,定义如下。

func TestXxx(*testing.T)

测试函数必须按这个固定格式编写,否则 go test 将执行报错。函数中有一个输入参数 t, 类型是 *testing.T,它非常重要,单元测试需通过它反馈测试结果,具体后面再介绍。

灵活记忆 API 的使用

按规则编写测试用例只能保证 go test 的正确定位执行。但为了可以分析测试结果,我们还需要与测试框架进行交互,这就需要测试函数输入参数 t 的参与了。

在 TestAbs 中,我们用到了 t.Fatalf,它的作用就是反馈测试结果。假设没有这段代码,发生错误也不会反馈测试失败,这显然不是我们想要的。

我们可以通过官方文档,看下 testing.T 中支持的可导出方法,如下:

// 获取测试名称
method (*T) Name() string
// 打印日志
method (*T) Log(args ...interface{})
// 打印日志,支持 Printf 格式化打印
method (*T) Logf(format string, args ...interface{})
// 反馈测试失败,但不退出测试,继续执行
method (*T) Fail()
// 反馈测试成功,立刻退出测试
method (*T) FailNow()
// 反馈测试失败,打印错误
method (*T) Error(args ...interface{})
// 反馈测试失败,打印错误,支持 Printf 的格式化规则
method (*T) Errorf(format string, args ...interface{})
// 检测是否已经发生过错误
method (*T) Failed() bool
// 相当于 Error + FailNow,表示这是非常严重的错误,打印信息结束需立刻退出。
method (*T) Fatal(args ...interface{})
// 相当于 Errorf + FailNow,与 Fatal 类似,区别在于支持 Printf 格式化打印信息;
method (*T) Fatalf(format string, args ...interface{})
// 跳出测试,从调用 SkipNow 退出,如果之前有错误依然提示测试报错
method (*T) SkipNow()
// 相当于 Log 和 SkipNow 的组合
method (*T) Skip(args ...interface{})
// 与Skip,相当于 Logf 和 SkipNow 的组合,区别在于支持 Printf 格式化打印
method (*T) Skipf(format string, args ...interface{})
// 用于标记调用函数为 helper 函数,打印文件信息或日志,不会追溯该函数。
method (*T) Helper()
// 标记测试函数可并行执行,这个并行执行仅仅指的是与其他测试函数并行,相同测试不会并行。
method (*T) Parallel()
// 可用于执行子测试
method (*T) Run(name string, f func(t *T)) bool

上面列出了单元测试 testing.T 中所有的公开方法,我个人思路,把它们大概分为三类,分别是底层方法、测试反馈,还有一些其他运行控制的辅助方法。

基础信息的 API 只有 1 个,Name() 方法,用于获取测试名称。运行控制的辅助方法主要指的是 Helper、t.Parallel 和 Run,上面的注释对它们已经做了简单介绍。

我们这里重点说说测试反馈的 API,毕竟它用的最多。前面用到的 Fatalf 方法就是其中之一,它的效果是打印错误日志并立刻退出测试。希望速记这类 API 吗?我们或许可以按几个层级进行记忆。

首先,我们记住一些相关的基础方法,它们是其它方法的核心组成,如下:

  • 日志打印,Log 与 Logf,Log 和 Logf 区别可对比 Println 和 Printf,即 Logf 支持 Printf 格式化打印,而 Log 不支持。
  • 失败标记,Fail 和 FailNow,Fail 与 FailNow 都是用于标记测试失败的方法,它们的区别在于 Fail 标记失败后还会继续执行执行接下来的测试,而 FailNow 在标记失败后会立刻退出。
  • 测试忽略,SkipNow 方法退出测试,但并不会标记测试失败,可与 FailNow 对比记忆。

我们再看看剩余的那些方法,基本都是由基础方法组合而来。我们可根据场景,选择不同的组合。比如:

  • 普通日志,只是打印一些日志,可以直接使用 Log 或 Logf 即可;
  • 普通错误,如果不退出测试,只是打印一些错误提示信息,使用 Error 或 Errorf,这两个方法是 log 或 logf 和 Fail 的组合;
  • 严重错误,需要退出测试,并打印一些错误提示信息,使用 Fatal (log + FailNow) 或 Fatalf (logf + FailNow);
  • 忽略错误,并退出测试,可以使用 Skip (log + SkipNow) 和 Skipf (logf + SkipNow);

如果支持 Printf 的格式化信息打印,方法后面都会有一个 f 字符。如此一总结,我们发现 testing.T 中的方法的记忆非常简单。

突然想到,不知是否有人会问什么情况下算是测试成功。其实,只要没有标记失败,测试就是成功的。

实践一个案例

补充知识1 -- strings.Index()

strings.Index() Golang中的函数用于获取指定子字符串的第一个实例。如果未找到子字符串,则此方法将返回-1。

用法:

func Index(str, sbstr string) int

在这里,str是原始字符串,sbstr是我们要查找索引值的字符串。

示例:

func main() {  
     
    // Creating and initializing the strings  
    str1:= "Welcome to GeeksforGeeks"
    str2:= "My name is XYZ"
     
     
    // Using Index() function  
    res1:= strings.Index(str1, "Geeks")  
    res2:= strings.Index(str2, "is")  
   
    // Displaying the result  
    fmt.Println("\nIndex values:")  
    fmt.Println("Result 1:", res1)  
    fmt.Println("Result 2:", res2)  
     
} 

输出:

Index values:
Result 1: 11
Result 2: 8

补充知识2 -- reflect.DeepEqual

对于array、slice、map、struct等类型,想要比较两个值是否相等,不能使用==,处理起来十分麻烦,在对效率没有太大要求的情况下,reflect包中的DeepEqual函数完美的解决了比较问题。

函数签名:

func DeepEqual(a1, a2 interface{}) bool

文档中对该函数的说明: DeepEqual函数用来判断两个值是否深度一致:除了类型相同;在可以时(主要是基本类型)会使用==;但还会比较array、slice的成员,map的键值对,结构体字段进行深入比对。map的键值对,对键只使用==,但值会继续往深层比对。DeepEqual函数可以正确处理循环的类型。函数类型只有都会nil时才相等;空切片不等于nil切片;还会考虑array、slice的长度、map键值对数。

示例:

func main() {
    m1 := map[int]interface{}{1: []int{1, 2, 3}, 2: 3, 3: "a"}
    m2 := map[int]interface{}{1: []int{1, 2, 3}, 2: 3, 3: "a"}
    if reflect.DeepEqual(m1, m2) {
        fmt.Println("相等")
    }
}

最终的输出是相等。例子中map的值类型是interface{},如果自己处理去比较,还要使用swich Type来判断底层类型,十分麻烦。

测试函数示例

我们定义一个split的包,包中定义了一个Split函数,具体实现如下:

(这个函数的主要作用就是根据sep的值来划分s,并且将新的结果放置在result切片中)

package split
​
import "strings"
​
func Split(s, sep string) (result []string) {
    i := strings.Index(s, sep)
​
    for i > -1 {
        result = append(result, s[:i])
        s = s[i+len(sep):] // 这里使用len(sep)获取sep的长度
        i = strings.Index(s, sep)
    }
    result = append(result, s)
    return
}

在当前目录下,我们创建一个split_test.go的测试文件,并定义一个测试函数如下:

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

在split包路径下,执行go test命令,当然我们可以为go test命令添加-v参数,查看测试函数名称和运行时间:

  split $ go test -v
    === RUN   TestSplit
    --- PASS: TestSplit (0.00s)
    === RUN   TestMoreSplit
    --- PASS: TestMoreSplit (0.00s)
    PASS
    ok      github.com/pprof/studygo/code_demo/test_demo/split       0.006s

简洁紧凑的表组测试

如果将要测试的某个功能函数的用例非常多,我们将会需要写很多代码重复度非常高的测试函数,因为对于单元测试而言,基本都是围绕一个简单模式:

指定输入参数 -> 调用要测试的函数 -> 获取返回结果 -> 比较实际返回与期望结果 -> 确认测试失败提示

测试组

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

func TestSplit(t *testing.T) {
   // 定义一个测试用例类型
    type test struct {
        input string
        sep   string
        want  []string
    }
    // 定义一个存储测试用例的切片
    tests := []test{
        {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        {input: " 枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    // 遍历切片,逐一执行测试用例
    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("excepted:%#v, got:%#v", tc.want, got)
        }
    }
}

此时运行go test命令后就能看到比较明显的提示信息了:

    split $ go test -v
    === RUN   TestSplit
    --- FAIL: TestSplit (0.00s)
        split_test.go:42: excepted:[]string{"枯藤", "树昏鸦"}, got:[]string{"", "枯藤", "树昏鸦"}
    FAIL
    exit status 1
    FAIL    github.com/Q1mi/studygo/code_demo/test_demo/split       0.006s

子测试

看起来都挺不错的,但是如果测试用例比较多的时候,我们是没办法一眼看出来具体是哪个测试用例失败了。我们可能会想到下面的解决办法

func TestSplit(t *testing.T) {
    type test struct { // 定义test结构体
        input string
        sep   string
        want  []string
    }
    tests := map[string]test{ // 测试用例使用map存储
        "simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "leading sep": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    for name, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("name:%s excepted:%#v, got:%#v", name, tc.want, got) // 将测试用例的name格式化输出
        }
    }
}

上面的做法是能够解决问题的。同时Go1.7+中新增了子测试,我们可以按照如下方式使用t.Run执行子测试:

func TestSplit(t *testing.T) {
    type test struct { // 定义test结构体
        input string
        sep   string
        want  []string
    }
    tests := map[string]test{ // 测试用例使用map存储
        "simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "leading sep": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    for name, tc := range tests {
        t.Run(name, func(t *testing.T) { // 使用t.Run()执行子测试
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("excepted:%#v, got:%#v", tc.want, got)
            }
        })
    }
}

此时我们再执行go test命令就能够看到更清晰的输出内容了:

    split $ go test -v
    === RUN   TestSplit
    === RUN   TestSplit/leading_sep
    === RUN   TestSplit/simple
    === RUN   TestSplit/wrong_sep
    === RUN   TestSplit/more_sep
    --- FAIL: TestSplit (0.00s)
        --- FAIL: TestSplit/leading_sep (0.00s)
            split_test.go:83: excepted:[]string{"枯藤", "树昏鸦"}, got:[]string{"", "枯藤", "树昏鸦"}
        --- PASS: TestSplit/simple (0.00s)
        --- PASS: TestSplit/wrong_sep (0.00s)
        --- PASS: TestSplit/more_sep (0.00s)
    FAIL
    exit status 1
    FAIL    github.com/pprof/studygo/code_demo/test_demo/split       0.006s

这个时候我们要把测试用例中的错误修改回来:

func TestSplit(t *testing.T) {
    ...
    tests := map[string]test{ // 测试用例使用map存储
        "simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "leading sep": {input: "枯藤老树昏鸦", sep: "老", want: []string{"", "枯藤", "树昏鸦"}},
    }
    ...
}

我们都知道可以通过-run=RegExp来指定运行的测试用例,还可以通过/来指定要运行的子测试用例,例如:go test -v -run=Split/simple只会运行simple对应的子测试用例。

灵活控制运行哪些测试

假设,我们把前面演示用到的那些测试函数全部放在 math_test.go 中。此时,使用默认 go test 测试会遇到一个问题,那就是每次都将包中的测试函数都执行一遍。有什么办法能灵活控制呢?

可以先来看看此类问题,常见的使用场景有哪些!我想到的几点,如下:

  • 执行 package 下所有测试函数,go test 默认就是如此,不用多说;
  • 执行其中的某一个测试函数,比如当我们把前面写的所有测试函数都放在了 math_test.go 文件中,如何选择其中一个执行;
  • 按某一类匹配规则执行测试函数,比如执行名称满足以 Division 开头的测试函数;
  • 执行项目下的所有测试函数,一个项目通常不止一个包,如何要将所有包的测试函数都执行一遍,该如何做呢;

第一个本不怎么用介绍了。但有一点还是要介绍下,那就是除默认执行当前路径的包,我们也可以具体指定执行哪个 package 的测试函数,指定方式支持纯粹的文件路径方式以及包路径方式。

假设,我们包的导入路径为 example/math,而我们当前位置在 example 目录下,就有两种方式执行 math 下的测试。

$ go test # 目录路径执行
$ go test example/math # GOPATH 包导入路径

第二、三场景,执行其中的某个或某类测试,主要与 go test 的 -run 选项有关,-run 选项接收参数是正则表达式。

执行某一个具体的函数,如 TestDivision,命令执行效果如下:

$ go test -run "^TestDivision$" -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
    math_test.go:36: end
PASS
ok      study/test/math 0.004s

从输出中可了解到,确实只执行了 TestDivision。这里要记住加上 -v 选项,使输出信息具体到某一个测试。

执行具体的某一个类的函数,如除法相关测试 Division,命令执行效果如下:

$ go test -run "Division" -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
    math_test.go:36: end
=== RUN   TestDivisionZero
--- PASS: TestDivisionZero (0.00s)
=== RUN   TestDivisionTable
--- PASS: TestDivisionTable (0.00s)
PASS
ok      _/Users/polo/Public/Work/go/src/study/test/math 0.005s

将前面写过的函数名中包含 Division 全部执行一遍。

第四个场景,执行整个项目下的测试。在项目的顶层目录,直接执行 go test ./... 即可,具体就不演示了。