Go 1.18 最强代码自测方法!

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

go fuzzing 至今已经发现了代码质量极高的 Go 标准库超过200个bug,见://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,比如:

// 具体代码见 //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 argumentsfuzzing 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 的,如下所示:

项目地址

//github.com/zeromicro/go-zero

欢迎使用 go-zerostar 支持我们!

微信交流群

关注『微服务实践』公众号并点击 交流群 获取社区群二维码。