0%

什么是模糊测试

在 Go 1.18 版本中,Go 引入了一个新的测试工具:模糊测试(Fuzzing)。模糊测试是一种自动化测试技术,它通过随机输入来发现程序中的错误。

模糊测试的原理很简单:随机生成输入,然后运行程序,检查程序的输出是否符合预期。如果程序的输出不符合预期,那么就说明程序中存在错误。

模糊测试的优点是可以发现一些边缘情况下的错误或者可能导致程序崩溃的输入,这些错误很难通过手工测试来发现。因此,模糊测试是一种非常有效的测试方法。

如何进行模糊测试

在这里引入一下官方博客的图:

下面是详细说明:

  1. 首先,我们需要创建一个模糊测试函数,函数名以 Fuzz 开头,后面跟着要测试的函数名,函数名第一个字母大写,接收一个 *testing.F 参数。
  2. 模糊测试需要写在 _test.go 文件中。
  3. 一个模糊测试函数里面,必须包含一个模糊目标,也就是需要调用 (*testing.F).Fuzz 方法,这个方法的第一个参数是 *testing.T,后续是模糊测试自动生成的输入(我们需要使用这些随机的输入去调用我们的函数)。这个模糊目标没有返回值。
  4. 一个模糊测试只能有一个模糊目标(也就是上图的 Fuzz target 只能有一个)。
  5. 所有的种子语料库的类型必须和 Fuzz 函数的输入参数(上图的 Seed corpus additionFuzzing arguments)类型一致,因为模糊测试是根据 Seed corpus 的类型生成的随机参数来传递给 Fuzzing arguments 的。
  6. 模糊测试的参数只能是以下的类型:
    • string, []byte
    • int, int8, int16, int32, int64
    • uint, uint8/byte, uint16, uint32, uint64
    • float32, float64
    • bool

模糊测试示例

假设我们有以下这个反转字符串的函数:

1
2
3
4
5
6
7
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}

rune 在 Go 里面是 int32 的别名,用来表示 Unicode 字符(因为 Unicode 字符最多只有 4 字节,所以 rune 足够存储一个 Unicode 字符)。

接着,为这个 Reverse 函数写一个模糊测试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func FuzzReverse(f *testing.F) {
// 种子语料库
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc)
}
// 模糊目标,orig 是随机生成的输入(根据上面的种子语料库生成)
f.Fuzz(func(t *testing.T, orig string) {
// 使用随机生成的输入去调用 Reverse 函数
// 如果 Reverse 函数返回的结果不等于 orig 的反转,那么就说明 Reverse 函数有问题
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
// 又或者,如果 Reverse 函数返回的结果不是一个有效的 UTF-8 字符串,那么就说明 Reverse 函数有问题
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}

最后,通过下面的命令来执行一下模糊测试:

1
go test -fuzz=FuzzReverse -fuzztime=3s .

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 20 workers
fuzz: minimizing 51-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzReverse (0.03s)
--- FAIL: FuzzReverse (0.00s)
hello_test.go:25: Before: "\x80", after: "�"

Failing input written to testdata/fuzz/FuzzReverse/98fce631eb9c5dd5
To re-run:
go test -run=FuzzReverse/98fce631eb9c5dd5
FAIL
exit status 1
FAIL main 0.033s

从上述输出可以看到,我们有一个模糊测试的用例失败了,然后 Go 帮我们把错误的测试用例写入到了 testdata/fuzz/FuzzReverse/98fce631eb9c5dd5 文件中:

1
2
go test fuzz v1
string("\x80")

修正这个问题

我们从 98fce631eb9c5dd5 这个文件可以看出,模糊测试给我们生成的字符串并不是一个有效的 UTF-8 字符串,所以我们需要在 FuzzReverse 函数中加入一些判断:

1
2
3
4
5
6
7
8
9
10
11
12
func Reverse(s string) (string, error) {
// 判断是否是有效的 utf-8 字符串
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}

runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes), nil
}

在判断到传入的字符串不是有效的 UTF-8 字符串的时候,我们返回一个错误。然后在 FuzzReverse 函数中加入对错误的判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err := Reverse(orig)
if err != nil {
return
}
doubleRev, err := Reverse(rev)
if err != nil {
return
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}

跳过不是 UTF-8 字符串的测试用例,然后再次执行模糊测试:

1
go test -fuzz=FuzzReverse -fuzztime=3s .

输出如下:

1
2
3
4
5
6
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 20 workers
fuzz: elapsed: 3s, execs: 1431158 (477041/sec), new interesting: 39 (total: 45)
fuzz: elapsed: 3s, execs: 1431158 (0/sec), new interesting: 39 (total: 45)
PASS
ok main 3.117s

这次模糊测试通过了,没有发现问题。

运行模糊测试一些建议

模糊测试的时间

需要注意的是,在进行模糊测试的时候,我们可能需要指定一个合适的时间,否则模糊测试可能会一直运行下去。可以通过 -fuzztime 参数来指定模糊测试的时间:

1
go test -fuzz=FuzzReverse -fuzztime=3s .

如果我们需要在 CI 中集成,这个可能是必须的。否则,CI 会一直运行模糊测试。

调整种子语料库

检查提供给模糊器的种子语料库,确保其多样性足以探索各种代码路径,但又不会过于宽泛,导致模糊器陷入过多路径。有时,过于通用的种子会导致模糊器在无益路径上花费过多时间。

并行模糊测试

如果我们需要控制模糊测试的并行度,可以通过 -parallel 参数来指定模糊测试的并行度:

默认情况下,Go 会使用所有的 CPU 核心来运行模糊测试。

1
go test -fuzz=FuzzReverse -fuzztime=3s -parallel=10 .

输出:

1
2
3
4
5
6
fuzz: elapsed: 0s, gathering baseline coverage: 0/52 completed
fuzz: elapsed: 0s, gathering baseline coverage: 52/52 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1164562 (388133/sec), new interesting: 0 (total: 52)
fuzz: elapsed: 3s, execs: 1164562 (0/sec), new interesting: 0 (total: 52)
PASS
ok main 3.109s

我们从第二行输出可以看到 10 workers,因为我们通过了 -parallel 参数指定了只使用 10 个 CPU 核心来运行模糊测试。

总结

模糊测试是一种自动化测试技术,它通过随机输入来发现程序中的错误。Go 1.18 版本引入了模糊测试,我们可以通过 (*testing.F).Fuzz 方法来进行模糊测试。模糊测试是一种非常有效的测试方法,可以发现一些边缘情况下的错误或者可能导致程序崩溃的输入。

单元测试可以帮助我们发现一些常规路径上的错误,而模糊测试可以帮助我们发现一些边缘情况下的错误。因此,单元测试和模糊测试是互补的,我们可以同时使用这两种测试方法来提高代码的质量。

什么是 Example 测试

Example 测试是 Go 语言中的一种特殊测试,它用于展示函数或方法的使用方式。Example 测试的代码位于 _test.go 文件中,以 Example 开头,后面跟着函数名。

其实,Example 测试是 godoc 工具的一部分,它会读取代码中的 Example 测试,并将其展示在文档中。这样,用户就可以直接在文档中看到函数或方法的使用方式。

可以说,Example 测试是一种文档测试,它不仅可以测试代码,还可以帮助用户更好地理解代码。

一个简单的 Example 测试

1
2
3
4
// hello.go
func Hello() {
fmt.Println("Hello, World!")
}
1
2
3
4
5
// hello_test.go
func ExampleHello() {
Hello()
// Output: Hello, World!
}

我们执行 go test 命令就可以运行 Example 测试:

1
go test -v

如果我们的 Example 测试通过,那么输出结果会是:

1
2
3
4
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
PASS
ok go-test 0.004s

说明:

  1. ExampleHello 是我们的 Example 测试函数名。(注意:Hello 是规范的一部分,实际上是我们的函数名)
  2. Example 测试的格式是在代码后面加上 // Output: 注释,后面跟着期望的输出结果。如果实际输出结果和期望的输出结果一致,那么 Example 测试就通过了。
  3. Example 测试的目的是展示函数或方法的使用方式,而不只是测试。

Example 跟文档的关系

我们写的所有的 Example 测试都会被 godoc 工具读取,然后展示在文档中。这样,用户就可以直接在文档中看到函数或方法的使用方式。

我们可以通过下面的命令预览文档:

1
godoc -http=:8080

注意,如果找不到 godoc 命令,通过下面的命令安装:

1
go install golang.org/x/tools/cmd/godoc@latest

安装完成后,再将 $GOPATH/bin 目录添加到环境变量中。

示例

假设我的目录结构如下:

1
2
3
4
// go.mod
module mytest

go 1.22
1
2
3
4
5
6
7
8
9
// x/hello.go
package x

import "fmt"

// Hello prints "Hello, World!" to the console.
func Hello() {
fmt.Println("Hello, World!")
}
1
2
3
4
5
6
7
// x/hello_test.go
package x

func ExampleHello() {
Hello()
// Output: Hello, World!
}

在运行了 godoc -http=:8080 命令后,我们可以在浏览器中输入 http://localhost:8080/pkg/mytest/x/ 来查看文档:

我们可以看到 Hello 的文档以及其使用示例。

Example 测试既是测试,又是文档。

Example 测试的命名规范

  1. Example 测试的函数名必须以 Example 开头。
  2. 遵循以下的命名规范,可以让我们的 Example 在 godoc 中展示在不同的位置。
命名规范 位置
Example 在包的概述中列出
ExampleXxx 在 Xxx 函数/结构体/接口 中列出
ExampleXxx_Yyy Xxx 结构体的 Yyy 方法
ExampleXxx_Yyy_one 当你想给 Xxx 结构体的 Yyy 方法写多个示例的时候。当需要写多个示例的时候,前面几个也可以加 _one 这样的后缀

也就是说,不同的命名规范意味着是写给不同对象的示例。

无序输出的 Example 测试

如果我们的函数或方法的输出是无序的,那么我们可以使用 // Unordered 注释来标记。

1
2
3
4
5
6
7
8
9
10
func ExamplePerm() {
for _, value := range rand.Perm(5) {
fmt.Println(value)
}
// Unordered output: 4
// 2
// 1
// 3
// 0
}

一个基本的性能测试

我们以斐波那契数列为例,来看一个基本的性能测试。

需要测试的文件:

1
2
3
4
5
6
7
// fib.go
func fib(n int) int {
if n == 0 || n == 1 {
return n
}
return fib(n-2) + fib(n-1)
}

测试文件:

1
2
3
4
5
6
7
8
// fib_test.go
import "testing"

func BenchmarkFib(b *testing.B) {
for i := 0; i < b.N; i++ {
fib(20)
}
}

我们可以通过执行下面的命令来运行性能测试:

1
go test -bench .

输出结果:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkFib-20 47515 25053 ns/op
PASS
ok go-test 1.451s

说明:

  • 输出字段说明:
    • goos:操作系统。
    • goarch:CPU 架构。
    • pkg:包名。
    • cpu:CPU 信息。
    • BenchmarkFib-20:测试函数的名字。20 表示 GOMAXPROCS(线程数)的值为 20。
    • 47515:测试函数运行的次数。
    • 25053 ns/op:每次运行的平均耗时,也就是每次操作耗时 25053 纳秒。
  • 基准测试函数的名字必须以 Benchmark 开头,后面跟被测试的函数名,函数名的第一个字母必须大写。如上面的 BenchmarkFib
  • 基准测试函数的参数是 *testing.B
  • 运行基准测试的命令是 go test -bench .,其中 . 表示当前目录。
  • b.N 是基准测试框架提供的一个参数,表示基准测试函数运行的次数。

如果我们想知道每次操作中内存的分配情况,可以使用 -benchmem 参数:

1
go test -bench . -benchmem

在输出中就会显示每次操作分配的内存情况。

CPU 性能测试及分析

上面的基准测试,我们是直接输出了测试结果,如果我们想要更详细的分析,可以使用 pprof 工具。

我们可以使用下面的测试命令来生成 CPU 性能分析的文件:

1
go test -bench . -cpuprofile=cpu.out

接着,我们可以使用 go tool pprof 来查看分析结果:

1
go tool pprof cpu.out

输出如下:

1
2
3
4
5
6
File: go-test.test
Type: cpu
Time: May 10, 2024 at 3:21pm (CST)
Duration: 1.61s, Total samples = 1.24s (76.86%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

我们可以使用 top 命令来查看 CPU 占用最高的函数:

1
2
3
4
5
6
7
8
(pprof) top
Showing nodes accounting for 1.24s, 100% of 1.24s total
flat flat% sum% cum cum%
1.24s 100% 100% 1.24s 100% go-test.fib
0 0% 100% 1.24s 100% go-test.BenchmarkFib
0 0% 100% 1.24s 100% testing.(*B).launch
0 0% 100% 1.24s 100% testing.(*B).runN
(pprof)

我们也可以在 top 命令后面加上一个数字,表示显示前几个占用 CPU 时间最多的函数。比如 top3 表示显示前 3 个。

也就是说,我们可以通过 pprof 工具来查看哪些地方占用了比较多的 CPU 时间,从而进行性能优化。

内存性能测试及分析

上一个例子中,我们并没有在函数中分配内存,我们使用下面这个例子来演示内存性能测试及分析。

需要测试的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"math/rand"
"testing"
"time"
)

func test(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, i)
}
return nums
}

func BenchmarkItoa(b *testing.B) {
for i := 0; i < b.N; i++ {
test(1000)
}
}

执行下面的命令来运行内存性能测试:

1
go test -bench . -benchmem -memprofile mem.out

输出如下:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 143871 8169 ns/op 8192 B/op 1 allocs/op
PASS
ok go-test 1.266s

我们可以看到,在 BenchmarkItoa 这一行中,多了两列,其中 8192 B/op 表示每次操作(每次调用 test 函数)分配了 8192 字节的内存,1 allocs/op 表示每次操作分配了 1 次内存。

这个输出对我们的意义是,尽量减少内存的分配,很多时候可以提高程序的性能。

同样的,我们可以使用 go tool pprof 来查看内存分析结果:

1
go tool pprof mem.out

我们在交互模式下,可以使用 top 命令来查看内存分配最高的函数:

1
2
3
4
5
6
7
(pprof) top
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
flat flat% sum% cum cum%
1.20GB 100% 100% 1.20GB 100% go-test.test
0 0% 100% 1.20GB 100% go-test.BenchmarkItoa
0 0% 100% 1.20GB 100% testing.(*B).launch
0 0% 100% 1.20GB 100% testing.(*B).runN

通过 http 页面的方式展示性能测试结果

我们上面这两个例子还是过于简单了,在实际的项目中,函数调用可能会非常复杂,我们可以通过 web 界面来展示性能测试结果。同时,交互上也会更加友好。

比如,针对上面的 mem.out,我们可以使用下面的命令来启动一个 http 服务:

1
go tool pprof -http=:8081 mem.out

接着,我们可以在浏览器中输入 http://localhost:8081 来查看性能测试结果:

除了直接看到的结果,还可以操作上面的菜单来实现不同的展示方式,比如选择 VIEW->Top,展示出来的是一个列表:

pprof 的其他功能

在我们使用 go tool pprof 的时候,还有很多其他的功能,比如:

  • top:查看 CPU 或内存占用最高的函数。上面有介绍。
  • listlist 命令后跟函数名称以显示该函数的源代码,突出显示哪些代码占用了最多的 CPU 或内存,如下所示
1
2
3
4
5
6
7
8
9
10
11
12
13
(pprof) list go-test.test
Total: 1.20GB
ROUTINE ======================== go-test.test in /Users/ruby/GolandProjects/go-test/fib_test.go
1.20GB 1.20GB (flat, cum) 100% of Total
. . 9:func test(n int) []int {
. . 10: rand.Seed(time.Now().UnixNano())
1.20GB 1.20GB 11: nums := make([]int, 0, n)
. . 12: for i := 0; i < n; i++ {
. . 13: nums = append(nums, i)
. . 14: }
. . 15: return nums
. . 16:}
(pprof)
  • webweb 命令可以在浏览器中打开一个页面,以图形的形式展示性能测试结果,如下

  • weblistweblist 命令可以在浏览器中打开一个页面,显示函数的源代码,突出显示哪些代码占用了最多的 CPU 或内存,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ... 其他内容
/Users/ruby/GolandProjects/go-test/fib_test.go

Total: 1.20GB 1.20GB (flat, cum) 100%
6 . . "time"
7 . . )
8 . .
9 . . func test(n int) []int {
10 . . rand.Seed(time.Now().UnixNano())
11 1.20GB 1.20GB nums := make([]int, 0, n)
12 . . for i := 0; i < n; i++ {
13 . . nums = append(nums, i)
14 . . }
15 . . return nums
// ... 其他内容
  • peek:显示某一个函数的调用详情,如:
1
2
3
4
5
6
7
8
9
10
(pprof) peek go-test.test
Active filters:
show=go-test.test
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
1.20GB 100% | go-test.BenchmarkItoa
1.20GB 100% 100% 1.20GB 100% | go-test.test
----------------------------------------------------------+-------------
  • text:以文本的形式展示性能分析结果,如:
1
2
3
4
5
6
7
8
9
(pprof) text
Active filters:
show=go-test.test
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
flat flat% sum% cum cum%
1.20GB 100% 100% 1.20GB 100% go-test.test
0 0% 100% 1.20GB 100% go-test.BenchmarkItoa
0 0% 100% 1.20GB 100% testing.(*B).launch
0 0% 100% 1.20GB 100% testing.(*B).runN
  • 各种形式的输出:pprof 还支持其他的输出形式,比如 pdfpngsvg 等,具体可以查看 help 命令。

testing.B 的其他方法

最后,再简单介绍一下 testing.B 的其他方法:

  • b.ResetTimer():重置计时器,可以在测试函数中的循环体中使用,以避免循环体的初始化时间影响测试结果。
1
2
3
4
5
6
7
8
func BenchmarkFib(b *testing.B) {
// 模拟初始化时间,这一行代码不会计入测试时间
time.Sleep(1 * time.Second)
b.ResetTimer()
for i := 0; i < b.N; i++ {
fib(20)
}
}
  • b.StartTimer():负责启动计时并初始化内存相关计数,测试执行时会自动调用,一般不需要用户启动。
  • b.StopTimer():负责停止计时,并累加相应的统计值。
  • b.ReportAllocs():用于设置是否打印内存统计信息,与命令行参数 -benchmem 一致,但本方法只作用于单个测试函数。
  • b.SetParallelism(p int):设置并行测试的线程数,设置为 p*GOMAXPROCS。影响 b.RunParallel 的并行度。
  • b.RunParallel(body func(*PB)):用于并行测试,body 函数会被并行执行,b.N 会被分配到各个并行体中。通常跟 -cpu 参数一起使用。

其他方法跟 testing.T 有很多重复的,这里不赘述了,可以看上一篇。

高阶用法

指定性能测试时间

可以通过 -benchtime 参数来指定性能测试的时间,如:

1
go test -bench . -benchtime=3s

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 499718 7051 ns/op
PASS
ok go-test 3.602s

指定性能测试执行次数,也就是 b.N

也是通过 -benchtime 参数来指定,但是单位是 x,如:

1
go test -bench . -benchtime=3x

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 3 14378 ns/op
PASS
ok go-test 0.005s

执行多次性能测试

可以通过 -count 参数来指定执行多少次性能测试,如:

1
go test -bench . -count=3

输出:

1
2
3
4
5
6
7
8
9
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 166116 7187 ns/op
BenchmarkItoa-20 165289 7168 ns/op
BenchmarkItoa-20 161872 7146 ns/op
PASS
ok go-test 3.765s

指定性能测试的 CPU 数

可以通过 -cpu 参数来指定性能测试的 CPU 数,如:

1
go test -bench . -cpu=1,2,4

这个命令会执行多次性能测试,分别使用 1、2、4 个 CPU(也就是 GOMAXPROCS 的值分别为 1、2、4)。

输出:

1
2
3
4
5
6
7
8
9
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa 166363 7153 ns/op
BenchmarkItoa-2 170436 7114 ns/op
BenchmarkItoa-4 171088 6976 ns/op
PASS
ok go-test 3.819s

输出内存分配信息

可以通过 -benchmem 参数来输出内存分配信息,如:

1
go test -bench . -benchmem

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 163878 7081 ns/op 896 B/op 1 allocs/op
PASS
ok go-test 1.240s

为什么写单元测试?

关于测试,有一张很经典的图,如下:

说明:

测试类型 成本 速度 频率
E2E 测试
集成测试
单元测试

也就是说,单元测试是最快、最便宜的测试方式。这不难理解,单元测试往往用来验证代码的最小单元,比如一个函数、一个方法,这样的测试我们一个命令就能跑完整个项目的单元测试,而且速度还很快,所以单元测试是我们最常用的测试方式。 而 E2E 测试和集成测试,往往需要启动整个项目,然后需要真实用户进行手动操作,这样的测试成本高,速度慢,所以我们往往不会频繁地运行这样的测试。只有在项目的最后阶段,我们才会运行这样的测试。而单元测试,我们可以在开发的过程中,随时随地地运行,这样我们就能及时发现问题,及时解决问题。

一个基本的 Go 单元测试

Go 从一开始就支持单元测试,Go 的测试代码和普通代码一般是放在同一个包下的,只是测试代码的文件名是 _test.go 结尾的。比如我们有一个 add.go 文件,那么我们的测试文件就是 add_test.go

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
// add_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 did not equal 3")
}
}

我们可以通过 go test 命令来运行测试:

1
go test

输出:

1
2
PASS
ok go-test 0.004s

注意:

  1. 测试函数的命名必须以 Test 开头,后面的名字必须以大写字母开头,比如 TestAdd
  2. 测试函数的参数是 *testing.T 类型。
  3. go test 加上 -v 参数可以输出详细的测试信息,加上 -cover 参数可以输出测试覆盖率。

go test 命令的参数详解

基本参数

  • -v:输出详细的测试信息。比如输出每个测试用例的名称。
  • -run regexp:只运行匹配正则表达式的测试用例。如 -run TestAdd
  • -bench regexp:运行匹配正则表达式的基准测试用例。
  • -benchtime t:设置基准测试的时间,默认是 1s,也就是让基准测试运行 1s。也可以指定基准测试的执行次数,格式如 -benchtime 100x,表示基准测试执行 100 次。
  • -count n:运行每个测试函数的次数,默认是 1 次。如果指定了 -cpu 参数,那么每个测试函数会运行 n * GOMAXPROCS 次。但是示例测试只会运行一次,该参数对模糊测试无效。
  • -cover:输出测试覆盖率。
  • -covermode set,count,atomic:设置测试覆盖率的模式。默认是 set,也就是记录哪些语句被执行过。
  • -coverpkg pkg1,pkg2,pkg3:用于指定哪些包应该生成覆盖率信息。这个参数允许你指定一个或多个包的模式,以便在运行测试时生成这些包的覆盖率信息。
  • -cpu 1,2,4:设置并行测试的 CPU 数量。默认是 GOMAXPROCS。这个参数对模糊测试无效。
  • -failfast:一旦某个测试函数失败,就停止运行其他的测试函数了。默认情况下,一个测试函数失败了,其他的测试函数还会继续运行。
  • -fullpath:测试失败的时候,输出完整的文件路径。
  • -fuzz regexp:运行模糊测试。
  • -fuzztime t:设置模糊测试的时间,默认是 1s。又或者我们可以指定模糊测试的执行次数,格式如 -fuzztime 100x,表示模糊测试执行 100 次。
  • -fuzzminimizetime t:设置模糊测试的最小化时间,默认是 1s。又或者我们可以指定模糊测试的最小化执行次数,格式如 -fuzzminimizetime 100x,表示模糊测试最小化执行 100 次。在模糊测试中,当发现一个失败的案例后,系统会尝试最小化这个失败案例,以找到导致失败的最小输入。
  • -json:以 json 格式输出
  • -list regexp:列出所有匹配正则表达式的测试用例名称。
  • -parallel n:设置并行测试的数量。默认是 GOMAXPROCS。
  • -run regexp:只运行匹配正则表达式的测试用例。
  • -short:缩短长时间运行的测试的测试时间。默认关闭。
  • -shuffle off,on,N:打乱测试用例的执行顺序。默认是 off,也就是不打乱,这会由上到下执行测试函数。
  • -skip regexp:跳过匹配正则表达式的测试用例。
  • -timeout t:设置测试的超时时间,默认是 10m,也就是 10 分钟。如果测试函数在超时时间内没有执行完,那么测试会 panic
  • -vet list:设置 go vet 的检查列表。默认是 all,也就是检查所有的。

性能相关

  • -benchmem:输出基准测试的内存分配情况(也就是 go test -bench . 的时候可以显示每次基准测试分配的内存)。
  • -blockprofile block.out:输出阻塞事件的分析数据。
  • -blockprofilerate n:设置阻塞事件的采样频率。默认是 1(单位纳秒)。如果没有设置采样频率,那么就会记录所有的阻塞事件。
  • -coverprofile coverage.out:输出测试覆盖率到文件 coverage.out
  • -cpuprofile cpu.out:输出 CPU 性能分析信息到文件 cpu.out
  • -memprofile mem.out:输出内存分析信息到文件 mem.out
  • -memprofilerate n:设置内存分析的采样频率。
  • -mutexprofile mutex.out:输出互斥锁事件的分析数据。
  • -mutexprofilefraction n:设置互斥锁事件的采样频率。
  • -outputdir directory:设置输出文件的目录。
  • -trace trace.out:输出跟踪信息到文件 trace.out

子测试

使用场景:当我们有多个测试用例的时候,我们可以使用子测试来组织测试代码,使得测试代码更具组织性和可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"testing"
)

func TestAdd2(t *testing.T) {
cases := []struct {
name string
a, b, sum int
}{
{"case1", 1, 2, 3},
{"case2", 2, 3, 5},
{"case3", 3, 4, 7},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sum := Add(c.a, c.b)
if sum != c.sum {
t.Errorf("Sum was incorrect, got: %d, want: %d.", sum, c.sum)
}
})
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
➜  go-test go test   
--- FAIL: TestAdd2 (0.00s)
--- FAIL: TestAdd2/case1 (0.00s)
add_test.go:21: Sum was incorrect, got: 4, want: 3.
--- FAIL: TestAdd2/case2 (0.00s)
add_test.go:21: Sum was incorrect, got: 6, want: 5.
--- FAIL: TestAdd2/case3 (0.00s)
add_test.go:21: Sum was incorrect, got: 8, want: 7.
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,上面的输出中,失败的单元测试带有每个子测试的名称,这样我们就能很方便地知道是哪个测试用例失败了。

setup 和 teardown

在一般的单元测试框架中,都会提供 setupteardown 的功能,setup 用来初始化测试环境,teardown 用来清理测试环境。

方法一:通过 Go 的 TestMain 方法

很遗憾的是,Go 的测试框架并没有直接提供这样的功能,但是我们可以通过 Go 的特性来实现这样的功能。

在 Go 的测试文件中,如果有 TestMain 函数,那么执行 go test 的时候会执行这个函数,而不会执行其他测试函数了,其他的测试函数需要通过 m.Run 来执行,如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"os"
"testing"
)

func setup() {
fmt.Println("setup")
}

func teardown() {
fmt.Println("teardown")
}

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 != 3")
}
}

func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}

在这个例子中,我们在 TestMain 函数中调用了 setupteardown 函数,这样我们就实现了 setupteardown 的功能。

方法二:使用 testify 框架

我们也可以使用 Go 中的第三方测试框架 testify 来实现 setupteardown 的功能(使用 testify 中的 suite 功能)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"github.com/stretchr/testify/suite"
"testing"
)

type AddSuite struct {
suite.Suite
}

func (suite *AddSuite) SetupTest() {
fmt.Println("Before test")
}

func (suite *AddSuite) TearDownTest() {
fmt.Println("After test")
}

func (suite *AddSuite) TestAdd() {
suite.Equal(Add(1, 2), 3)
}

func TestAddSuite(t *testing.T) {
suite.Run(t, new(AddSuite))
}

go test 输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  go-test go test
Before test
After test
--- FAIL: TestAddSuite (0.00s)
--- FAIL: TestAddSuite/TestAdd (0.00s)
add_test.go:22:
Error Trace: /Users/ruby/GolandProjects/go-test/add_test.go:22
Error: Not equal:
expected: 4
actual : 3
Test: TestAddSuite/TestAdd
FAIL
exit status 1
FAIL go-test 0.006s

我们可以看到,这里也同样执行了 SetupTestTearDownTest 函数。

testing.T 可用的方法

最后,我们可以直接从 testing.T 提供的 API 来学习如何编写测试代码。

基本日志输出

  • t.Log(args ...any):打印信息,不会标记测试函数为失败。
  • t.Logf(format string, args ...any):打印格式化的信息,不会标记测试函数为失败。

可能有读者会有疑问,输出不用 fmt 而用 t.Log,这是因为:

  • t.Logt.Logf 打印的信息默认不会显示,只有在测试函数失败的时候才会显示。又或者我们使用 -v 参数的时候才显示,这让我们的测试输出更加清晰,只有必要的时候日志才会显示。
  • t.Logt.Logf 打印的时候,还会显示是哪一行代码打印的信息,这样我们就能很方便地定位问题。
  • fmt.Println 打印的信息一定会显示在控制台上,就算我们的测试函数通过了,也会显示,这样会让控制台的输出很乱。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// add_test.go
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
t.Log("TestAdd is running")
if Add(1, 2) != 3 {
t.Error("Expected 3")
}
}

输出:

1
2
3
➜  go-test go test
PASS
ok go-test 0.004s

我们修改一下 Add 函数,让测试失败,再次运行,输出如下:

1
2
3
4
5
6
7
➜  go-test go test
--- FAIL: TestAdd (0.00s)
add_test.go:8: TestAdd is running
add_test.go:10: Expected 3
FAIL
exit status 1
FAIL go-test 0.004s

我们可以发现,在测试成功的时候,t.Log 打印的日志并没有显示,只有在测试失败的时候才会显示。

如果我们想要在测试成功的时候也显示日志,可以使用 -v 参数:go test -v

标记测试函数为失败

  • t.Fail():标记测试函数为失败,但是测试函数后续代码会继续执行。(让你在测试函数中标记失败情况,并收集所有失败的情况,而不是在遇到第一个失败时就立即停止测试函数的执行。)
  • t.FailNow():标记测试函数为失败,并立即返回,后续代码不会执行(通过调用 runtime.Goexit,但是 defer 语句还是会被执行)。
  • t.Failed():返回测试函数是否失败。
  • t.Fatal(args ...any):标记测试函数为失败,并输出信息,然后立即返回。等价于 t.Log + t.FailNow
  • t.Fatalf(format string, args ...any):标记测试函数为失败,并输出格式化的信息,然后立即返回。等价于 t.Logf + t.FailNow

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Fatal("Expected 3")
}

if Add(2, 3) != 5 {
t.Fatal("Expected 4")
}
}

这里只会输出第一个失败的测试用例,因为 t.Fatal 会立即返回。

标记测试函数为失败并输出信息

  • t.Error(args ...any):标记测试函数为失败,并打印错误信息。等价于 t.Log + t.Fail
  • t.Errorf(format string, args ...any):标记测试函数为失败,并打印格式化的错误信息。等价于 t.Logf + t.Fail

这两个方法会让测试函数立即返回,不会继续执行后面的代码。

测试超时控制

  • t.Deadline():返回测试函数的截止时间(这是通过 go test -timeout 60s 这种形式指定的超时时间)。

注意:如果我们通过 -timeout 指定了超时时间,当测试函数超时的时候,测试会 panic

跳过测试函数中后续代码

作用:可以帮助测试代码在特定条件下灵活地跳过测试,避免不必要的测试执行,同时提供清晰的信息说明为什么跳过测试。

  • t.Skip(args ...any):跳过测试函数中后续代码,标记测试函数为跳过。等同于 t.Log + t.SkipNow
  • t.Skipf(format string, args ...any):跳过测试函数中后续代码,并打印格式化的跳过信息。等同于 t.Logf + t.SkipNow
  • t.SkipNow():跳过测试函数中后续代码,标记测试函数为跳过。这个方法不会输出内容,前面两个会输出一些信息
  • t.Skipped():返回测试函数是否被跳过。

测试清理函数

  • t.Cleanup(f func()):注册一个函数,这个函数会在测试函数结束后执行。这个函数会在测试函数结束后执行,不管测试函数是否失败,都会执行。(可以注册多个,执行顺序类似 defer,后注册的先执行)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
t.Cleanup(func() {
fmt.Println("cleanup 0")
})
t.Cleanup(func() {
fmt.Println("cleanup 1")
})
}

输出:

1
2
3
4
5
➜  go-test go test
cleanup 1
cleanup 0
PASS
ok go-test 0.004s

使用临时文件夹

  • t.TempDir():返回一个临时文件夹,这个文件夹会在测试函数结束后被删除。可以调用多次,每次都是不同的文件夹。
1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
fmt.Println(t.TempDir())
fmt.Println(t.TempDir())
}

输出:

1
2
3
4
5
➜  go-test go test
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/001
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/002
PASS
ok go-test 0.004s

临时的环境变量

  • t.Setenv(key, value string):设置一个临时的环境变量,这个环境变量会在测试函数结束后被还原。

在单元测试中,使用 Setenv 函数可以模拟不同的环境变量设置,从而测试代码在不同环境下的行为。例如,你可以在测试中设置特定的环境变量值,然后运行被测试的代码,以验证代码在这些环境变量设置下的正确性。

子测试

可以将一个大的测试函数拆分成多个子测试,使得测试代码更具组织性和可读性。

  • t.Run(name string, f func(t *testing.T)):创建一个子测试,这个子测试会在父测试中执行。子测试可以有自己的测试函数,也可以有自己的子测试。

获取当前测试的名称

  • t.Name():返回当前测试的名称(也就是测试函数名)。

t.Helper()

  • t.Helper():标记当前测试函数是一个辅助函数,这样会让测试输出更加清晰,只有真正的测试函数会被标记为失败。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b + 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// add_test.go
package main

import (
"testing"
)

func test(a, b, sum int, t *testing.T) {
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

func TestAdd(t *testing.T) {
test(1, 2, 3, t)
test(2, 3, 5, t)
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:10: Add(1, 2) = 4; want 3
add_test.go:10: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,两个测试失败输出的报错行都是 test 函数里面的 t.Errorf,而不是 test 函数的调用者 TestAdd,也就是说,在这种情况下我们不好知道是 test(1, 2, 3, t) 还是 test(2, 3, 5, t) 失败了(当然我们这里还是挺明显的,只是举个例子),这时我们可以使用 t.Helper()

1
2
3
4
5
6
7
func test(a, b, sum int, t *testing.T) {
t.Helper() // 在助手函数中加上这一行
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:16: Add(1, 2) = 4; want 3
add_test.go:17: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

这个时候,我们就很容易知道是哪一个测试用例失败了,这对于我们需要封装 helper 函数的时候很有用。

如果我们想使用 pm2 来管理多个应用,可以通过配置文件来实现。

初始化配置文件

1
pm2 init simple

这个命令会在当前目录下生成一个 ecosystem.config.js 文件,这个文件就是 pm2 的配置文件。

文件内容大概如下:

1
2
3
4
5
6
module.exports = {
apps : [{
name : "app1",
script : "./app.js"
}]
}

根据配置文件来管理应用

  • pm2 reload 重新加载应用程序,但不会停止正在运行的应用程序实例。它会重新启动应用程序,但在新实例准备就绪之前,旧实例将继续提供服务。这意味着在应用程序重新加载期间,可能会有短暂的服务中断。
  • pm2 restart 停止当前运行的应用程序实例,并启动一个新的实例来替代它。这意味着在重启过程中会有一个短暂的服务中断,因为在新实例启动之前,旧实例将停止提供服务。

启动所有应用

1
pm2 start ecosystem.config.js

停止所有应用

1
pm2 stop ecosystem.config.js

重启所有应用

1
pm2 restart ecosystem.config.js

重载所有应用

1
pm2 reload ecosystem.config.js

删除所有应用

1
pm2 delete ecosystem.config.js

启动特定应用

1
pm2 start ecosystem.config.js --only app1

指定多个:

1
pm2 start ecosystem.config.js --only app1,app2

不同的环境(env)

你可以通过 env_* 来指定不同的环境,比如:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
apps : [{
name : "app1",
script : "./app.js",
env_production: {
NODE_ENV: "production"
},
env_development: {
NODE_ENV: "development"
}
}]
}

然后启动时指定环境:

1
2
pm2 start ecosystem.config.js --env production
pm2 start ecosystem.config.js --env development

配置项详解

配置项 类型 示例 说明
name string "app1" 应用名称
script string "./app.js" 启动脚本
cwd string "/path/to/app" 启动应用程序的目录
args string "-a 13 -b 12" 传递给脚本的参数
interpreter string "/usr/bin/python" 解释器
interpreter_args string "-u" 传递给解释器的参数

高级配置项

  • exec_mode 说明:
    • 在 fork 模式下,每个应用程序实例都在单独的进程中运行。这意味着每个应用程序实例都有自己的内存空间和资源。
    • 在 cluster 模式下,应用程序会以集群的方式运行,使用 Node.js 的 cluster 模块来创建多个子进程。这些子进程共享同一个端口,可以充分利用多核处理器的优势。
配置项 类型 示例 说明
instances number 4 启动多少个实例
exec_mode string "cluster" 执行模式,默认 fork
watch boolean true 是否监视文件变化,文件变动的时候重启进程
ignore_watch array ["node_modules", "logs"] 忽略监视的文件或目录
max_memory_restart string "1G" 当内存使用超过多少时重启进程
env object { NODE_ENV: "development" } 环境变量
env_production object { NODE_ENV: "production" } 生产环境的环境变量,当指定 --env 参数的时候生效
appendEnvToName boolean true 是否将环境变量追加到应用名称后面

日志配置

配置项 类型 示例 说明
log_date_format string "YYYY-MM-DD HH:mm Z" 日志日期格式
error_file string "/path/to/error.log" 错误日志文件,默认 $HOME/.pm2/logs/<app name>-error-<pid>.log
out_file string "/path/to/out.log" 标准输出日志文件,默认 $HOME/.pm2/logs/<app name>-out-<pid>.log
log_file string "/path/to/combined.log" 组合日志文件(标准输出+错误输出)
pid_file string "/path/to/pid" pid 文件,默认 $HOME/.pm2/pids/<app name>-<pid>.pid
combine_logs boolean true 日志文件名不添加 pid 后缀
time boolean true 在日志中添加时间戳

控制流程

配置项 类型 示例 说明
min_uptime number 1000 应用程序在多少毫秒内被认为是启动成功的
listen_timeout number 8000 应用程序启动后多少毫秒没有监听端口就认为启动失败
kill_timeout number 1600 pm2 发送 kill 信号给应用程序后多少毫秒后强制杀死进程
shutdown_with_message boolean false 是否在关闭进程时发送消息给进程
wait_ready boolean false
max_restarts number 10 启动失败之后,最大重试次数
autorestart boolean true 是否自动重启
cron_restart string "0 0 * * *" 定时重启,参考 cron
vizion boolean false 是否启用版本控制
post_update list ["npm install"] 更新后执行的命令
force boolean false 强制启动应用程序

部署

配置项 类型 示例 说明
key string "/path/to/key" ssh 私钥
user string ssh 用户名
host string ssh 主机
ssh_options object { "StrictHostKeyChecking": "no" } ssh 选项
ref string "origin/master" git 分支
repo string git 仓库
path string "/path/to/deploy" 部署路径
pre-setup string "echo 'commands or script to run before setup on target host'" 部署前执行的命令
post-setup string "echo 'commands or script to run after setup on target host'" 部署后执行的命令
pre-deploy-local string "echo 'commands or script to run on local machine before the setup process starts'" 本地部署前执行的命令
post-deploy string "echo 'commands or script to run on target host after the deploy process finishes'" 部署后执行的命令