特别说明:这个真的不是标题党,我写代码20+年,真心认为
go fuzzing
是我见过的最牛逼的代码自测方法。我在用AC自动机
算法改进关键字过滤效率(提升~50%),改进mapreduce
对panic
的处理机制的时候,都通过go fuzzing
发现了极端边缘情况的 bug。所以深深的认为,这是我见过最牛逼的代码自测方法,没有之一!
go fuzzing
至今已经发现了代码质量极高的Go
标准库超过200个bug,见:https://github.com/dvyukov/go-fuzz#trophies
春节程序员之间的祝福经常是,祝你代码永无 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,亲测确实发现的都是些平时很难发现的问题。
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 的最佳实践
按照我使用下来的经验总结,我把最佳实践初步总结为以下四步:
- 定义
fuzzing arguments
,首先要想明白怎么定义fuzzing arguments
,并通过给定的fuzzing arguments
写fuzzing target
- 思考
fuzzing target
怎么写,这里的重点是怎么验证结果的正确性,因为fuzzing arguments
是“随机”给的,所以要有个通用的结果验证方法 - 思考遇到失败的 case 如何打印结果,便于生成新的
unit test
- 根据失败的
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
的复杂场景可以借鉴
- 模糊测试了常规的算法实现,对于算法类场景可以借鉴