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

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

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

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

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

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

最易想到的方法

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

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

go 复制代码
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单元测试对文件名和方法名,参数都有很严格的要求。

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

一个快速体验案例

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

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

kotlin 复制代码
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 命令执行测试,我们会看到如下输出。

bash 复制代码
$ 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 关键词合法字符,如大小写字母、下划线、数字。

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

go 复制代码
func TestXxx(*testing.T)

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

灵活记忆 API 的使用

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

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

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

scss 复制代码
// 获取测试名称
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。

用法:

go 复制代码
func Index(str, sbstr string) int

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

示例:

go 复制代码
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)  
     
} 

输出:

sql 复制代码
Index values:
Result 1: 11
Result 2: 8

补充知识2 -- reflect.DeepEqual

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

函数签名:

go 复制代码
func DeepEqual(a1, a2 interface{}) bool

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

示例:

go 复制代码
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切片中)

go 复制代码
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的测试文件,并定义一个测试函数如下:

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参数,查看测试函数名称和运行时间:

bash 复制代码
  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测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。

go 复制代码
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命令后就能看到比较明显的提示信息了:

lua 复制代码
    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

子测试

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

go 复制代码
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执行子测试:

go 复制代码
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命令就能够看到更清晰的输出内容了:

bash 复制代码
    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

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

go 复制代码
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 下的测试。

shell 复制代码
$ go test # 目录路径执行
$ go test example/math # GOPATH 包导入路径

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

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

lua 复制代码
$ 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,命令执行效果如下:

diff 复制代码
$ 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 ./... 即可,具体就不演示了。

相关推荐
吴佳浩2 小时前
Go史上最大“打脸”现场来了:泛型方法终于实现了
后端·go
明月_清风9 小时前
深入 Go 并发编程:从 Goroutine 到 Channel 的系统性避坑指南
后端·go
用户34232323763171 天前
开源!Go+Wails+Vue3 手搓一个 PLC 实时监控桌面工具
go
止语Lab1 天前
为什么你的 Go TCP server P99 延迟这么高
go
Andy Dennis1 天前
nsq学习记录
消息队列·go·nsq
韦胖漫谈IT2 天前
选语言不是站队,是选适合问题的工具
java·python·ai·rust·go·技术落地
喵个咪2 天前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
夜悊2 天前
Go网络编程的学习代码示例:客户端/服务端(C/S)模型
go
审判长烧鸡2 天前
【AI问答】GO代码循环返值
go
捧 花2 天前
Eino框架记忆功能实现指南
go·agent·eino