Go 笔记之如何测试你的 Go 代码
Go 笔记之如何测试你的 Go 代码
这篇文章是go学习笔记第三部分主要参考来源如下:
参考博主文章:如何测试你的 Go 代码 - POLOXUE's BLOG
参考文章来源:前景 · Go语言中文文档 (topgoer.com)
参考补充知识部分的文章:blog.csdn.net/m0_37710023…
最易想到的方法
谈到如何测试一个函数的功能,对开发来说,最容易想到的方法就是在 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 ./... 即可,具体就不演示了。
转载自:https://juejin.cn/post/7342872745870983187