8.2 测试技巧:模糊测试(Fuzzing)

1. 什么是模糊测试?

单元测试,需要开发者根据函数逻辑,给定几组输入(入参)与输出(返回)的数据,然后 go test 根据这些数据集,调用函数,若返回值与预期相符,则说明函数的单元测试通过。

但单元测试的代码,也是由开发者写的一段一段代码,只要是代码,就会有 BUG,就会有遗漏的场景。

因此即使单元测试通过,也不代表你的程序没有问题。

可见,测试场景的数据集对于测试有多重要,而 Fuzzing 模糊测试就是一种用机器根据已知数据源,来自动生成测试数据的一种方案。

2. 简单的示例

接着前一篇文章的例子,我们再往 reverse_test.go 中加入 Fuzzing 模糊测试的代码

// 记得前面导入 "unicode/utf8" 包

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        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)
        }
    })
}

Fuzzing 模糊测试的代码格式与单元测试很像:

  • 函数名固定以 Fuzz 开头(单元测试是以 Test 开头)

  • 函数固定以 testing.F 类型做为入参(单元测试是以testing.T)

不一样的是 Fuzzing 模糊测试,提供两个函数:

  • t.Add:用于开发者输入模糊测试的种子数据,fuzzing 根据这些种子数据,自动随机生成更多测试数据

  • t.Fuzz:开始运行模糊测试,t.Fuzz 的入参是一个 Fuzz Target 函数(官方这么叫的),这个 Fuzz Target 函数的编写逻辑跟单元测试就一样了

在本例子中,Fuzz Target 接收 类型为 string 的入参,做为 Reverse 的输入源,然后利用两次 Reverse 的结果应与原字符串相等的原理进行测试。

有了 FuzzReverse 函数后,就可以使用如下命令进行模糊测试

go18 test -fuzz=Fuzz

通过输出发现测试并不顺利,Go 1.18 的 Fuzzing 会将导致测试异常的数据文件记录下来,使用 cat 可以查看该测试数据

https://image.iswbm.com/image-20220323214047119.png

记录下来后,该数据就可做为普通单元测试的数据,此时我们再执行 go test 就会引用该数据,当然了,在问题解决之前, go test 会一直报错

https://image.iswbm.com/image-20220323214900909.png

3. 问题排查与解决

模糊测试帮我们发现了一个出乎意料的 Bug 场景:在中文里的字符 其实是由3个字节组成的,如果按照字节反转,反转后得到的就是一个无效的字符串。

因此为了保证字符串反转后得到的仍然是一个有效的UTF-8编码的字符串,我们要按照rune进行字符串反转。

为了更好地方便大家理解中文里的字符 按照rune为维度有多少个rune,以及按照byte反转后得到的结果长什么样,我们对代码做一些修改。

https://image.iswbm.com/image-20220323214536938.png

改完之后,再次执行 go test 就会提示测试成功,说明我们已经修复上面的那个场景的 BUG

https://image.iswbm.com/image-20220323215108358.png

当下我们已经发现并修复了一个 BUG,程序肯定还有更多 BUG 存在,要继续寻找可以再次进行模糊测试,重复上面的步骤即可,这里不再赘述。

4. 更多参数介绍

在支持了 Fuzzing 模糊测试后,go test 工具也有了一些新的命令,在这里一并记录下

进行模糊测试

go test -fuzz=Fuzz

只对某个函数进行模糊测试:使用 -run=Fuzzxxx 或者 -fuzz=Fuzzxxx 指定模糊测试函数,避免执行到其他测试函数

go18 test -run=FuzzReverse
go18 test -fuzz=FuzzReverse

测试某个失败数据:使用 -run=file 指定数据文件

go test -run=FuzzReverse/1fdd0160e6b3dd8f1e6b7a4179b4787e0c014cf9c46c67a863d71e3a0277c213

指定模糊测试的时间:使用 -fuzztime 指定模糊测试时间或者迭代次数(默认无限期),避免一直在跑测试无法退出

还有一个 -fuzzminimizetime 参数,看官方文档的介绍,我没明白其作用,有知道的还请评论区分享下

go test -fuzz=Fuzz -fuzztime 30s

设置模糊测试进程数据:默认值是 $GOMAXPROCS,可根据实际情况进行设置,避免太占用机器的资源

go test -fuzz=Fuzz -parallel 4

5. 写在最后

模糊测试的存在,并不是为了替代原单元测试,而是为单元测试提供更好的保障,是一个补充方案,而非替代方案。

单元测试的局限性在于,你只能用预期的输入进行测试;模糊测试在发现暴露出奇怪行为的意外输入方面非常出色。一个好的模糊测试系统也会对被测试的代码进行分析,因此它可以有效地产生输入,从而扩大代码覆盖面。

同时模糊测试的适用场景也比较有限,如果函数的入参并不是像本例中的那样的简单(字符串),而是各种对象呢?可能它就无能为力了吧。