什么是单元测试
在 Go 语言中,单元测试是一种测试方法,用于验证代码的某个独立单元是否按预期功能,它的目的是确保代码的每个组成部分都在独立测试的情况下运行正常。
在我们对项目新增一个新功能时,最好就要养成写单元测试的好习惯,这样可以有助于提高我们代码的质量、可维护性和可靠性。
在 Go 中,单元测试的约定是使用标准库中的 testing
包。测试文件通常以 _test.go
为后缀,然后我们使用 go test ...
配合一些参数去进行测试,Go 测试工具会自动识别并运行这些文件中那点测试样例。
go test 的两种模式
1. 本地模式:执行当前目录下的所有测试用例
go test
2. 列表模式:输入一个或多个目录,执行这些目录下的测试用例
go test xx/xx
怎么写单元测试
首先,要写单元测试,那么肯定需要一个功能函数。这里我们借用一下之前文章内存缓存系统中使用到的一个功能函数 ParseSize
,它的功能是将用户的输入内存大小,转换为字节数和对应的字符串表示形式,其中还会涉及到一些输入不合法的处理,
本文讲的是如何写单元测试,这里 ParseSize 的源码就直接给大家了,如下:
package util
import (
"regexp"
"strconv"
"strings"
"time"
)
const (
B = 1 << (iota * 10)
KB
MB
GB
TB
PB
)
const defaultNum = 100
func ParseSize(size string) (int64, string) {
time.Sleep(time.Nanosecond * 500)
re, _ := regexp.Compile("[0-9]+")
unit := string(re.ReplaceAll([]byte(size), []byte("")))
num, _ := strconv.ParseInt(strings.Replace(size, unit, "", 1), 10, 64)
unit = strings.ToUpper(unit)
var byteNum int64 = 0
switch unit {
case "B":
byteNum = num
case "KB":
byteNum = num * KB
case "MB":
byteNum = num * MB
case "GB":
byteNum = num * GB
case "TB":
byteNum = num * TB
case "PB":
byteNum = num * PB
default:
num = 0
}
if num == 0 {
num = 100
byteNum = num * MB
unit = "MB"
}
sizeStr := strconv.FormatInt(num, 10) + unit
return byteNum, sizeStr
}
在项目根目录下创建 util 目录,然后创建 util.go 文件,将上面的代码粘贴进去就行了。
强调一点,上面的 ParseSize 函数的开头,我加了一个睡眠函数,是因为我们的 ParseSize 函数的处理逻辑比较简单,怕执行太快,进行测试时显示时间为 0 ,所以加了个睡眠延迟一点时间,模拟一些比较耗时功能函数。
准备工作
同样,我们先在 util 包下创建 util_test.go 文件。在写单元测试的时候,我们通常有两种方法,一种是在测试函数里面构建匿名结构体来组织数据,另一种就是在提前构建数据。前者就是将构建数据的逻辑写在测试函数里,这里不多做介绍,我们要着重讲的是第二种。
为了方便,我们先定义一个结构体,并将其实例化,用于存放我们的数据:
// 所有的测试用例放在这里头
var commTestData []commStruct
type commStruct struct {
Group string // 所属类别
SizeStr string // 输入大小
ExpectSize int64 // 预期输出大小
ExpectSizeStr string // 预期输出大小字符串类型
}
- Group:这个是用于子测试时分类的依据,关于子测试后面会提到,这里先不理会。
- SizeStr:是对应于我们的 ParseSize 功能函数的输入
- ExpectSize、ExpectSizeStr:对应于我们的 ParseSize 功能函数的输出
在单元测试中,也有一个 func TestMain(m *testing.M)
入口函数,功能和用法于平时我们使用的 main 类似。我们可以在这里面为单元测试做一些准备工作,但需要注意的是:如果我们没有写 TestMain 函数,那么测试工具会直接调用我们的测试函数,但如果我们写了 TestMain 函数,就需要在 TestMain 中通过 m.Run() 显示地调用测试用例:
// 测试用例的入口函数:可以为测试做一些准备工作
func TestMain(m *testing.M) {
initCommonData()
m.Run() // 执行测试用例
}
func initCommonData() {
commTestData = []commStruct{
{"B", "1b", B, "1B"},
{"B", "100b", 100 * B, "100B"},
{"KB", "1kb", KB, "1KB"},
{"KB", "100KB", 100 * KB, "100KB"},
{"MB", "1Mb", MB, "1MB"},
{"GB", "10Gb", 10 * GB, "10GB"},
{"TB", "1tb", TB, "1TB"},
{"PB", "10PB", 10 * PB, "10PB"},
{"unknown", "1G", 100 * MB, "100MB"},
}
}
上面我们通过 TestMain 函数,提前构建好了测试所需要的数据,避免在不同的测试函数中重复构建测试用例。
功能测试
功能测试是一种验证代码是否按照规范和需求进行工作的测试,它关注于测试单个函数或方法的功能是否正确,以确保其符合预期的行为。
根据它的定义,我们就大概知道该怎么写我们的功能测试了。首先功能测试的函数签名是这样的 func TestFunctionName(t *testing.T)
。我们直接在函数里面写逻辑即可,因为有很多组测试样例,所以我们肯定要用 for 循环将所有的样例拿出来,然后一一进行验证,验证的过程就是将该样例的输入拿出来执行一遍功能函数,然后将结果与我们的样例预期结果进行比对即可,如下:
// 功能测试
func TestParseSize(t *testing.T) {
testData := commTestData
for _, data := range testData {
size, sizeStr := ParseSize(data.SizeStr)
if size != data.ExpectSize || sizeStr != data.ExpectSizeStr {
t.Errorf("测试结果不符合预期:%+v", data)
}
}
}
这样我们就写好了一个具备基本功能的功能测试代码了。我们可以通过命令 go test -v
去执行,输出如下:
$ go test -v
=== RUN TestParseSize
--- PASS: TestParseSize (0.14s)
PASS
ok main/util 0.178s
我们一起来看看这个输出:
=== RUN TestParseSize
:表示正在运行名为TestParseSize
的测试函数。--- PASS: TestParseSize (0.14s)
:表示测试函数TestParseSize
成功通过,用时 0.14 秒。PASS
表示测试通过,FAIL
则表示测试失败。PASS
:表示整个测试过程中没有发现错误,所有的测试函数都成功通过。ok main/util 0.178s
:表示测试包main/util
成功通过,总用时为 0.178 秒。
下面我们再来看看功能测试的子测试。
功能测试的子测试,又可以叫做并发测试,我们可以利用它来加快测试的效率。我们下面以测试样例中的单位,即 group 字段来将测试样例分个组:
testData := make(map[string][]commStruct)
for _, item := range commTestData {
group := item.Group
_, ok := testData[group]
if !ok {
testData[group] = make([]commStruct, 0)
}
testData[group] = append(testData[group], item)
}
有了数据,其实我们的子测试,就相当于对不同组别分别去进行测试。
所以首先要用一个 for 循环拿出不同组别的数据,去分别运行,然后在每个组别运行时,去拿出对应组别的数据去做验证即可,代码如下:
func TestParseSizeSub(t *testing.T) {
if testing.Short() {
t.Skip("跳过测试用例 TestParseSizeSub")
}
// 按照 group 分个组
testData := make(map[string][]commStruct)
for _, item := range commTestData {
group := item.Group
_, ok := testData[group]
if !ok {
testData[group] = make([]commStruct, 0)
}
testData[group] = append(testData[group], item)
}
// 分组去测试 测试数据
for k, _ := range testData {
t.Run(k, func(t *testing.T) {
// 下面的子测试样例就会去并行执行:通过睡眠可以看出效果
t.Parallel()
for _, data := range testData[k] {
size, sizeStr := ParseSize(data.SizeStr)
if size != data.ExpectSize || sizeStr != data.ExpectSizeStr {
t.Errorf("测试结果不符合预期:%+v", data)
}
}
})
}
}
细心的小伙伴一定看到了上面有两个点是我们没讲的:
if testing.Short()
这个是做什么的呢?还记得我们上面介绍参数的时候说过吗,这个参数是用来避免一些不必要的测试的,所以如果我们的测试不需要,就可以使用 short 参数跳过这个子测试。t.Parallel()
这个就是我们子测试并行测试的关键了,只有加了这行代码,我们的子测试才能进行并行测试。
下面带大家看看t.Parallel()
是不是真的有效果,我们在子测试代码中加入一个睡眠时间,先把 t.Parallel()
注释掉:
for k, _ := range testData {
t.Run(k, func(t *testing.T) {
//t.Parallel()
for _, data := range testData[k] {
time.Sleep(time.Second)
size, sizeStr := ParseSize(data.SizeStr)
if size != data.ExpectSize || sizeStr != data.ExpectSizeStr {
t.Errorf("测试结果不符合预期:%+v", data)
}
}
})
}
然后执行命令 go test -v
,可以观察到子测试的样例每隔一秒才执行一次,最终耗时 9.367 秒。
$ go test -v
=== RUN TestParseSize
--- PASS: TestParseSize (0.10s)
=== RUN TestParseSizeSub
=== RUN TestParseSizeSub/KB
=== RUN TestParseSizeSub/MB
=== RUN TestParseSizeSub/GB
=== RUN TestParseSizeSub/TB
=== RUN TestParseSizeSub/PB
=== RUN TestParseSizeSub/unknown
=== RUN TestParseSizeSub/B
--- PASS: TestParseSizeSub (9.22s)
--- PASS: TestParseSizeSub/KB (2.05s)
--- PASS: TestParseSizeSub/MB (1.02s)
--- PASS: TestParseSizeSub/GB (1.02s)
--- PASS: TestParseSizeSub/TB (1.03s)
--- PASS: TestParseSizeSub/PB (1.03s)
--- PASS: TestParseSizeSub/unknown (1.03s)
--- PASS: TestParseSizeSub/B (2.04s)
PASS
ok main/util 9.367s
我们再把 t.Parallel()
的注释去掉,再执行 go test -v
观察一下:
$ go test -v
=== RUN TestParseSize
--- PASS: TestParseSize (0.14s)
=== RUN TestParseSizeSub
=== RUN TestParseSizeSub/unknown
=== PAUSE TestParseSizeSub/unknown
=== RUN TestParseSizeSub/B
=== PAUSE TestParseSizeSub/B
=== RUN TestParseSizeSub/KB
=== PAUSE TestParseSizeSub/KB
=== RUN TestParseSizeSub/MB
=== PAUSE TestParseSizeSub/MB
=== RUN TestParseSizeSub/GB
=== PAUSE TestParseSizeSub/GB
=== RUN TestParseSizeSub/TB
=== PAUSE TestParseSizeSub/TB
=== RUN TestParseSizeSub/PB
=== PAUSE TestParseSizeSub/PB
=== CONT TestParseSizeSub/unknown
=== CONT TestParseSizeSub/GB
=== CONT TestParseSizeSub/PB
=== CONT TestParseSizeSub/TB
=== CONT TestParseSizeSub/KB
=== CONT TestParseSizeSub/MB
=== CONT TestParseSizeSub/B
--- PASS: TestParseSizeSub (0.00s)
--- PASS: TestParseSizeSub/TB (1.03s)
--- PASS: TestParseSizeSub/B (1.03s)
--- PASS: TestParseSizeSub/MB (1.03s)
--- PASS: TestParseSizeSub/PB (1.03s)
--- PASS: TestParseSizeSub/KB (1.03s)
--- PASS: TestParseSizeSub/unknown (1.03s)
--- PASS: TestParseSizeSub/GB (1.03s)
PASS
ok main/util 1.210s
会发现子测试几乎是同时打印出来的信息,最终耗时 1.120s,这就验证了 t.Parallel()
的作用,也同时验证了功能测试的子测试的作用。
模糊测试
模糊测试是一种随机生成输入数据并将其提供给函数或程序的测试方法,它可以帮助发现潜在的边界情况和异常输入,以检测代码的鲁棒性。
也就是说,模式测试本质上也是功能测试,只不过模糊测试的输入不再是我们提前构建好的数据,而是测试工具根据我们传入的参数类型去帮我们构建各种输入,以此来检测我们的功能函数在这种随机构造的输入情况下,是否还能照常工作。模糊测试的函数签名是func FuzzFunctionName(f *testing.F) {}
,如下:
func FuzzParseSize(f *testing.F) {
// 也就是说,模糊测试,本质上也是一个功能测试。
// 只是输入的内容不再是 data,而是所谓的 a
f.Fuzz(func(t *testing.T, a string) {
size, sizeStr := ParseSize(a)
if size == 0 || sizeStr == "" {
t.Errorf("输入异常,导致 parsesize 没拿到正确结果")
}
})
}
然后我们可以通过 go test -fuzz FuzzParseSize
命令开启模糊测试,输出如下:
go test -fuzz FuzzParseSize
warning: starting with empty corpus
fuzz: elapsed: 0s, execs: 0 (0/sec), new interesting: 0 (total: 0)
fuzz: elapsed: 3s, execs: 614 (205/sec), new interesting: 7 (total: 7)
fuzz: elapsed: 6s, execs: 4210 (1194/sec), new interesting: 22 (total: 22)
fuzz: elapsed: 9s, execs: 5579 (456/sec), new interesting: 26 (total: 26)
fuzz: elapsed: 12s, execs: 9227 (1221/sec), new interesting: 35 (total: 35)
fuzz: elapsed: 15s, execs: 14480 (1744/sec), new interesting: 44 (total: 44)
fuzz: elapsed: 18s, execs: 16198 (572/sec), new interesting: 49 (total: 49)
......
warning: starting with empty corpus
:这是一个警告,表示开始时模糊测试的语料库(corpus)是空的。语料库是用来保存历史模式测试时,出现错误的样例。elapsed
:经过的时间execs
:执行的测试次数(平均每秒执行多少次)new interesting
:新增的随机测试输入个数total
:本次测试的的输入样例个数
运行模糊测试,你会发现根本不会停,只能主动去停止,这也是为什么模糊测试只能同时测试的原因。
还有就是上面提到的预料库,在运行模糊测试时,如果出现了预期之外的错误,那就会将这个样例保存到语料库中,并且在之后每次的模糊测试都会去运行这些出错的样例。语料库也是保存在本地的,会在根目录下生成一个对应的文件去存放。
性能测试
最后我们再来看看性能测试,在进行性能测试之前,我们需要先将 ParseSize 函数中的睡眠函数关掉,避免影响我们的性能测试。因为Sleep()
不仅会让程序睡眠,还会做一些其他处理,会对我们的性能测试产生不小的影响。
待会我们也可以做一个测试,然后进行一个对比。
性能测试写起来条条框框会比较多,它的函数签名是这样的 func BenchmarkFunctionName(b *testing.B) {}
我们啥也先不管,先来个 for 循环,然后直接调用我们的 ParseSize 函数:
func BenchmarkParseSize(b *testing.B) {
for i := 0; i < b.N; i++ {
ParseSize("1MB")
}
}
这样,一个简易的性能测试就写完了,我们可以用 go test -bench BenchmarkParseSize
,这里先不注释 ParseSize 中的睡眠函数,我们看看效果:
go test -bench BenchmarkParseSize
goos: windows
goarch: amd64
pkg: main/util
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkParseSize-16 100 15301008 ns/op
BenchmarkParseSizeSub/B-16 100 14830110 ns/op
BenchmarkParseSizeSub/KB-16 100 15324944 ns/op
BenchmarkParseSizeSub/MB-16 100 15445510 ns/op
BenchmarkParseSizeSub/GB-16 100 14851633 ns/op
BenchmarkParseSizeSub/TB-16 100 15136910 ns/op
BenchmarkParseSizeSub/PB-16 100 15281375 ns/op
BenchmarkParseSizeSub/unknown-16 100 15188822 ns/op
PASS
ok main/util 22.495s
再将睡眠函数注释掉,运行同样的命令,看看效果:
go test -bench BenchmarkParseSize
goos: windows
goarch: amd64
pkg: main/util
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkParseSize-16 735984 1603 ns/op
BenchmarkParseSizeSub/B-16 704841 1616 ns/op
BenchmarkParseSizeSub/KB-16 750050 1630 ns/op
BenchmarkParseSizeSub/MB-16 748998 1647 ns/op
BenchmarkParseSizeSub/GB-16 635871 1689 ns/op
BenchmarkParseSizeSub/TB-16 769012 1639 ns/op
BenchmarkParseSizeSub/PB-16 748689 1642 ns/op
BenchmarkParseSizeSub/unknown-16 770593 1620 ns/op
PASS
ok main/util 19.901s
我们先来解释一下各个参数代表什么:
goos: windows
和goarch: amd64
:表示你的操作系统和体系结构。pkg: main/util
:表示正在测试的 Go 包的路径。cpu: AMD Ryzen 7 4800H with Radeon Graphics
:表示你的 CPU 信息。BenchmarkParseSize-16 735984 1603 ns/op
:表示运行了 735984 次,平均每次耗时 1603 纳秒PASS
:表示所有的性能测试都通过ok main/util 19.901s
:表示整个测试过程消耗了 19.901 秒。
可以很明显的看到,这里两次测试的平均每次迭代耗时差了很多个数量级,但算上我们的睡眠时间 time.Sleep(time.Nanosecond * 500)
,也就 500 ns 而已。之所以会这样是因为 time.Sleep
函数的调用对于测试的结果会产生较大的影响,特别是在精度较高的情况,比如我们这里的纳米级别。 time.Sleep
会导致当前 goroutine 挂起,等待指定的时间再继续执行。在测试中,这样的挂起会导致每次迭代的耗时相对较大,从而影响性能测试的结果。
可能会有人好奇,为什么平均时长差了很多,但是总耗时却差不多。因为在 Go 语言的性能测试中,每个子测试的迭代次数数由测试框架自动决定的,它会根据自己执行时间的变化动态调整迭代次数,以保证测试结果的稳定性和可靠性。我们也可以自己使用 -benchtime t
参数来配置自己想要的运行次数和时间。
下面我们再来看看说说性能测试的子测试。
性能测试的子测试,其实没有啥明确的使用场景,我们下面所举的例子,也只是为了写性能测试子测试而写子测试,能够使用的场景也就是需要分组归类去测试的数据,比如 B、KB、MB 等相同单位的一组去测试。
这样做的好处是啥?有人肯定会觉得,可以像功能测试那样做并行测试。
答案是否定的,性能测试的子测试没有并行机制。我个人觉得这样的好处就是,可以指定只执行对应分组的测试用例,比如我们只需要对某一个单位的大小进行特殊处理,就可以只去执行对应分组的测试用例了。
然后我们来看看怎么写,同样的,需要先对我们的测试样例进行分组,然后在用 for 对不同组别的测试样例分别去运行性能测试函数:
func BenchmarkParseSizeSub(b *testing.B) {
testData := make(map[string][]commStruct)
for _, item := range commTestData {
group := item.Group
_, ok := testData[group]
if !ok {
testData[group] = make([]commStruct, 0)
}
testData[group] = append(testData[group], item)
}
for k, _ := range testData {
b.Run(k, func(b *testing.B) {
for i := 0; i < b.N; i++ {
ParseSize(testData[k][0].SizeStr)
}
})
}
}
上面代码需要知道的一点,就是在每次运行 b.Run()
的时候,for 循环里的测试次数是测试工具自动决定的,我们只需要调用就可以了。
上面就差不多是性能测试的基本写法了,只不过在一些情况下,比如我们在每次测试时需要去进行一下其他的数据准备,如果不进行一些处理,这些准备数据的时间就可能会导致我们的性能测试偏差较大:
for k, _ := range testData {
b.Run(k, func(b *testing.B) {
// case1
preBenchmark()
for i := 0; i < b.N; i++ {
// case2
preBenchmark1()
ParseSize(testData[k][0].SizeStr)
}
})
}
func preBenchmark1() {
time.Sleep(10 * time.Second)
}
func preBenchmark2() {
time.Sleep(time.Nanosecond * 500)
}
在上述代码中,我们通过 preBenchmark1
和 preBenchmark2
函数模拟了准备数据等其他操作的耗时,这里就直接告诉大家解决的方法了:
- 对于
case1
:可以在数据准备完成后,使用b.ResetTimer()
重置计时器 - 对于
case2
:可以在准备数据前使用b.StopTimer()
将计时器暂停,然后在准备好数据后,重新启动计时器b.StartTimer()
,这样就可以减小误差。
for k, _ := range testData {
b.Run(k, func(b *testing.B) {
// for 循环外,可以通过 b.ResetTimer() 来重置
preBenchmark1()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// for 循环内,可以通过 b.StopTimer() 和 b.StartTimer() 配合使用,来跳过我们不想统计的耗时操作。迫不得已不要使用,测试速度慢
b.StopTimer()
preBenchmark2()
b.StartTimer()
ParseSize(testData[k][0].SizeStr)
}
})
}
这里强调一点,上面的解决办法也只能减缓误差,并不能真正避免误差。并且如果你要测试上述代码的话,记得加上-benchtime
限制一下执行次数,否则会等很久。