史上最强代码自测方法,没有之一!_sed


特别说明:这个真的不是标题党,我写代码20+年,真心认为 ​​go fuzzing​​​ 是我见过的最牛逼的代码自测方法。我在用 ​​AC自动机​​​ 算法改进关键字过滤效率(提升~50%),改进 ​​mapreduce​​​ 对 ​​panic​​​ 的处理机制的时候,都通过 ​​go fuzzing​​ 发现了边缘情况的 bug。所以深深的认为,这是我见过最牛逼的代码自测方法,没有之一!

​go fuzzing​​​ 至今已经发现了代码质量极高的 ​​Go​​​ 标准库超过200个bug,见:​​github.com/dvyukov/go-…​


春节程序员之间的祝福经常是,祝你代码永无 bug!虽然调侃,但对我们每个程序员来说,每天都在写 bug,这是事实。代码没 bug 这事,只能证伪,不能证明。即将发布的 Go 1.18 官方提供了一个帮助我们证伪的绝佳工具 - ​​go fuzzing​​。

Go 1.18 大家最关注的是泛型,然而我真的觉得 ​​go fuzzing​​ 真的是 Go 1.18 最有用的功能,没有之一!

本文我们就来详细看看 ​​go fuzzing:​

  • 是什么?
  • 怎么用?
  • 有何最佳实践?


首先,你需要升级到 Go 1.18

Go 1.18 虽然还未正式发布,但你可以下载 RC 版本,而且即使你生产用 Go 更早版本,你也可以开发环境使用 go fuzzing 寻找 bug


go fuzzing 是什么

根据 ​​官方文档​​​ 介绍,​​go fuzzing​​ 是通过持续给一个程序不同的输入来自动化测试,并通过分析代码覆盖率来智能的寻找失败的 case。这种方法可以尽可能的寻找到一些边缘 case,亲测确实发现的都是些平时很难发现的问题。

史上最强代码自测方法,没有之一!_mapreduce_02

go fuzzing 怎么用

官方介绍写 fuzz tests 的一些规则:

  • 函数必须是 Fuzz开头,唯一的参数是 ​​*testing.F​​,没有返回值
  • Fuzz tests 必须在 ​​*_test.go​​ 的文件里
  • 上图中的 ​​fuzz target​​ 是个方法调用 ​​(*testing.F).Fuzz​​,第一个参数是 ​​*testing.T​​,然后就是称之为 ​​fuzzing arguments​​ 的参数,没有返回值
  • 每个 ​​fuzz test​​ 里只能有一个 ​​fuzz target​
  • 调用 ​​f.Add(…)​​ 的时候需要参数类型跟 ​​fuzzing arguments​​ 顺序和类型都一致
  • ​fuzzing arguments​​ 只支持以下类型:
  • ​string​​​,​​[]byte​
  • ​int​​​,​​int8​​​,​​int16​​​,​​int32​​​/​​rune​​​,​​int64​
  • ​uint​​​,​​uint8​​​/​​byte​​​,​​uint16​​​,​​uint32​​​,​​uint64​
  • ​float32​​​,​​float64​
  • ​bool​
  • ​fuzz target​​ 不要依赖全局状态,会并行跑。

运行​​fuzzing tests​

如果我写了一个 ​​fuzzing test​​,比如:

// 具体代码见 https://github.com/zeromicro/go-zero/blob/master/core/mr/mapreduce_fuzz_test.go
func FuzzMapReduce(f *testing.F) {
...
}

那么我们可以这样执行:

go test -fuzz=MapReduce

我们会得到类似如下结果:

fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 3338 (1112/sec), new interesting: 56 (total: 57)
fuzz: elapsed: 6s, execs: 6770 (1144/sec), new interesting: 62 (total: 63)
fuzz: elapsed: 9s, execs: 10157 (1129/sec), new interesting: 69 (total: 70)
fuzz: elapsed: 12s, execs: 13586 (1143/sec), new interesting: 72 (total: 73)
^Cfuzz: elapsed: 13s, execs: 14031 (1084/sec), new interesting: 72 (total: 73)
PASS
ok github.com/zeromicro/go-zero/core/mr 13.169s

其中的 ​​^C​​​ 是我按了 ​​ctrl-C​​ 终止了测试,详细解释参考官方文档。

go-zero 的最佳实践

按照我使用下来的经验总结,我把最佳实践初步总结为以下四步:

  1. 定义​​fuzzing arguments​​​,首先要想明白怎么定义​​fuzzing arguments​​​,并通过给定的​​fuzzing arguments​​​ 写​​fuzzing target​
  2. 思考​​fuzzing target​​​ 怎么写,这里的重点是怎么验证结果的正确性,因为​​fuzzing arguments​​ 是“随机”给的,所以要有个通用的结果验证方法
  3. 思考遇到失败的 case 如何打印结果,便于生成新的​​unit test​
  4. 根据失败的​​fuzzing test​​​ 打印结果编写新的​​unit test,这个新的 ​​​unit test​​会被用来调试解决​​​fuzzing test​​发现的问题,并固化下来留给​​​CI​​ 用​

接下来我们以一个最简单的数组求和函数来展示一下上述步骤,go-zero 的实际案例略显复杂,文末我会给出 go-zero 内部落地案例,供大家参考复杂场景写法。

这是一个注入了 bug 的求和的代码实现:

func Sum(vals []int64) int64 {
var total int64

for _, val := range vals {
if val%1e5 != 0 {
total += val
}
}

return total
}

1. 定义​​fuzzing arguments​

你至少需要给出一个 ​​fuzzing argument​​​,不然 ​​go fuzzing​​​ 没法生成测试代码,所以即使我们没有很好的输入,我们也需要定义一个对结果产生影响的 ​​fuzzing argument​​​,这里我们就用 slice 元素个数作为 ​​fuzzing arguments​​​,然后 ​​Go fuzzing​​​ 会根据跑出来的 ​​code coverage​​ 自动生成不同的参数来模拟测试。

func FuzzSum(f *testing.F) {
f.Add(10)
f.Fuzz(func(t *testing.T, n int) {
n %= 20
...
})
}

这里的 ​​n​​​ 就是让 ​​go fuzzing​​​ 来模拟 slice 元素个数,为了保证元素个数不会太多,我们限制在20以内(0个也没问题),并且我们添加了一个值为10的语料(​​go fuzzing​​​ 里面称之为 ​​corpus​​​),这个值就是让 ​​go fuzzing​​ 冷启动的一个值,具体为多少不重要。

2. 怎么写​​fuzzing target​

这一步的重点是如何编写可验证的 ​​fuzzing target​​​,根据给定的 ​​fuzzing arguments​​ 写出测试代码的同时,还需要生成验证结果正确性用的数据。

对我们这个 ​​Sum​​​ 函数来说,其实还是比较简单的,就是随机生成 ​​n​​ 个元素的 slice,然后求和算出期望的结果。如下:

func FuzzSum(f *testing.F) {
rand.Seed(time.Now().UnixNano())

f.Add(10)
f.Fuzz(func(t *testing.T, n int) {
n %= 20
var vals []int64
var expect int64
for i := 0; i < n; i++ {
val := rand.Int63() % 1e6
vals = append(vals, val)
expect += val
}

assert.Equal(t, expect, Sum(vals))
})
}

这段代码还是很容易理解的,自己求和和 ​​Sum​​ 求和做比较而已,就不详细解释了。但复杂场景你就需要仔细想想怎么写验证代码了,不过这不会太难,太难的话,可能是对测试函数没有足够理解或者简化。

此时就可以用如下命令跑 ​​fuzzing tests​​ 了,结果类似如下:

$ go test -fuzz=Sum
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: elapsed: 0s, execs: 6672 (33646/sec), new interesting: 7 (total: 6)
--- FAIL: FuzzSum (0.21s)
--- FAIL: FuzzSum (0.00s)
sum_fuzz_test.go:34:
Error Trace: sum_fuzz_test.go:34
value.go:556
value.go:339
fuzz.go:334
Error: Not equal:
expected: 8736932
actual : 8636932
Test: FuzzSum

Failing input written to testdata/fuzz/FuzzSum/739002313aceff0ff5ef993030bbde9115541cabee2554e6c9f3faaf581f2004
To re-run:
go test -run=FuzzSum/739002313aceff0ff5ef993030bbde9115541cabee2554e6c9f3faaf581f2004
FAIL
exit status 1
FAIL github.com/kevwan/fuzzing 0.614s

那么问题来了!我们看到了结果不对,但是我们很难去分析为啥不对,你仔细品品,上面这段输出,你怎么分析?

3. 失败 case 如何打印输入

对于上面失败的测试,我们如果能打印出输入,然后形成一个简单的测试用例,那我们就可以直接调试了。打印出来的输入最好能够直接 ​​copy/paste​​ 到新的测试用例里,如果格式不对,对于那么多行的输入,你需要一行一行调格式就太累了,而且这未必就只有一个失败的 case。

所以我们把代码改成了下面这样:

func FuzzSum(f *testing.F) {
rand.Seed(time.Now().UnixNano())

f.Add(10)
f.Fuzz(func(t *testing.T, n int) {
n %= 20
var vals []int64
var expect int64
var buf strings.Builder
buf.WriteString("\n")
for i := 0; i < n; i++ {
val := rand.Int63() % 1e6
vals = append(vals, val)
expect += val
buf.WriteString(fmt.Sprintf("%d,\n", val))
}

assert.Equal(t, expect, Sum(vals), buf.String())
})
}

再跑命令,得到如下结果:

$ go test -fuzz=Sum
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: elapsed: 0s, execs: 1402 (10028/sec), new interesting: 10 (total: 8)
--- FAIL: FuzzSum (0.16s)
--- FAIL: FuzzSum (0.00s)
sum_fuzz_test.go:34:
Error Trace: sum_fuzz_test.go:34
value.go:556
value.go:339
fuzz.go:334
Error: Not equal:
expected: 5823336
actual : 5623336
Test: FuzzSum
Messages:
799023,
110387,
811082,
115543,
859422,
997646,
200000,
399008,
7905,
931332,
591988,

Failing input written to testdata/fuzz/FuzzSum/26d024acf85aae88f3291bf7e1c6f473eab8b051f2adb1bf05d4491bc49f5767
To re-run:
go test -run=FuzzSum/26d024acf85aae88f3291bf7e1c6f473eab8b051f2adb1bf05d4491bc49f5767
FAIL
exit status 1
FAIL github.com/kevwan/fuzzing 0.602s

4. 编写新的测试用例

根据上面的失败 case 的输出,我们可以 ​​copy/paste​​ 生成如下代码,当然框架是自己写的,输入参数可以直接拷贝进去。

func TestSumFuzzCase1(t *testing.T) {
vals := []int64{
799023,
110387,
811082,
115543,
859422,
997646,
200000,
399008,
7905,
931332,
591988,
}
assert.Equal(t, int64(5823336), Sum(vals))
}

这样我们就可以很方便的调试了,并且能够增加一个有效 ​​unit test​​,确保这个 bug 再也不会出现了。

​go fuzzing​​ 更多经验

Go 版本问题

我相信,Go 1.18 发布了,大多数项目线上代码不会立马升级到 1.18 的,那么 ​​go fuzzing​​​ 引入的 ​​testing.F​​ 不能使用怎么办?

线上(go.mod)不升级到 Go 1.18,但是我们本机是完全推荐升级的,那么这时我们只需要把上面的 ​​FuzzSum​​​ 放到一个文件名类似 ​​sum_fuzz_test.go​​ 的文件里,然后在文件头加上如下指令即可:

//go:build go1.18
// +build go1.18


注意:第三行必须是一个空行,否则就会变成 ​​package​​ 的注释了。


这样我们在线上不管用哪个版本就不会报错了,而我们跑 ​​fuzz testing​​ 一般都是本机跑的,不受影响。

go fuzzing 不能复现的失败

上面讲的步骤是针对简单情况的,但有时根据失败 case 得到的输入形成新的 ​​unit test​​ 并不能复现问题时(特别是有 goroutine 死锁问题),问题就变得复杂起来了,如下输出你感受一下:

go test -fuzz=MapReduce
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 3681 (1227/sec), new interesting: 54 (total: 55)
...
fuzz: elapsed: 1m21s, execs: 92705 (1101/sec), new interesting: 85 (total: 86)
--- FAIL: FuzzMapReduce (80.96s)
fuzzing process hung or terminated unexpectedly: exit status 2
Failing input written to testdata/fuzz/FuzzMapReduce/ee6a61e8c968adad2e629fba11984532cac5d177c4899d3e0b7c2949a0a3d840
To re-run:
go test -run=FuzzMapReduce/ee6a61e8c968adad2e629fba11984532cac5d177c4899d3e0b7c2949a0a3d840
FAIL
exit status 1
FAIL github.com/zeromicro/go-zero/core/mr 81.471s

这种情况下,只是告诉我们 ​​fuzzing process​​​ 卡住了或者不正常结束了,状态码是2。这种情况下,一般 ​​re-run​​​ 是不会复现的。为什么只是简单的返回错误码2呢?我仔细去看了 ​​go fuzzing​​​ 的源码,每个 ​​fuzzing test​​​ 都是一个单独的进程跑的,然后 ​​go fuzzing​​ 把模糊测试的进程输出扔掉了,只是显示了状态码。那么我们如何解决这个问题呢?

我仔细分析了之后,决定自己来写一个类似 ​​fuzzing test​​ 的常规单元测试代码,这样就可以保证失败是在同一个进程内,并且会把错误信息打印到标准输出,代码大致如下:

func TestSumFuzzRandom(t *testing.T) {
const times = 100000
rand.Seed(time.Now().UnixNano())

for i := 0; i < times; i++ {
n := rand.Intn(20)
var vals []int64
var expect int64
var buf strings.Builder
buf.WriteString("\n")
for i := 0; i < n; i++ {
val := rand.Int63() % 1e6
vals = append(vals, val)
expect += val
buf.WriteString(fmt.Sprintf("%d,\n", val))
}

assert.Equal(t, expect, Sum(vals), buf.String())
}
}

这样我们就可以自己来简单模拟一下 ​​go fuzzing​​​,但是任何错误我们可以得到清晰的输出。这里或许我没研究透 ​​go fuzzing​​,或者还有其它方法可以控制,如果你知道,感谢告诉我一声。

但这种需要跑很长时间的模拟 case,我们不会希望它在 CI 时每次都被执行,所以我把它放在一个单独的文件里,文件名类似 ​​sum_fuzzcase_test.go​​,并在文件头加上了如下指令:

//go:build fuzz
// +build fuzz

这样我们需要跑这个模拟 case 的时候加上 ​​-tags fuzz​​ 即可,比如:

go test -tags fuzz ./...

复杂用法示例

上面介绍的是一个示例,还是比较简单的,如果遇到复杂场景不知道怎么写,可以先看看 go-zero 是如何落地 ​​go fuzzing​​ 的,如下所示:

  • 模糊测试了死锁goroutine leak,特别是​​chan + goroutine​​ 的复杂场景可以借鉴
  • 模糊测试了常规的算法实现,对于算法类场景可以借鉴

项目地址

​github.com/zeromicro/g…​

欢迎使用 ​​go-zero​​ 并 star 支持我们!

微信交流群