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

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

特别说明:这个真的不是标题党,我写代码20+年,真心认为 go fuzzing 是我见过的最牛逼的代码自测方法。我在用 AC自动机 算法改进关键字过滤效率(提升~50%),改进 mapreducepanic 的处理机制的时候,都通过 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 的最佳实践

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

  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 的,如下所示:

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

项目地址

https://github.com/zeromicro/go-zero

相关文章
|
6月前
|
程序员
面试高频题:开发人员说不是bug,测试如何答复?
面试高频题:开发人员说不是bug,测试如何答复?
116 0
|
1月前
|
NoSQL 安全 Java
Java后端基础自测
Java后端基础自测
60 12
|
消息中间件 算法 安全
开发者最怕遇到的代码报错
作为一名开发者,遇到代码报错是再平常不过的事情了。无论是在编写新代码还是修改现有代码时,都有可能出现各种各样的错误。有些错误可能只是简单的语法错误,而有些错误可能会导致整个程序无法正常运行。另外,结合阅读《实战总结|记一次消息队列堆积的问题排查》这篇文章,作者在工作中遇到的消息队列堆积的问题以及如何解决这一问题的过程,我深刻认识到了在开发过程中遇到问题准确排查的重要性,以及解决问题的策略和方法。那么接下来,就来聊一聊开发者最怕遇到的代码报错,以及如何有效地解决和避免这些问题。
294 2
开发者最怕遇到的代码报错
|
存储 程序员 编译器
【C/调试实用技巧】—作为程序员应如何面对并尝试解决Bug?
【C/调试实用技巧】—作为程序员应如何面对并尝试解决Bug?
149 0
|
开发框架 Java 测试技术
【测试基础】五、这样提bug单,开发小哥还会怼你么?
【测试基础】五、这样提bug单,开发小哥还会怼你么?
【测试基础】五、这样提bug单,开发小哥还会怼你么?
|
Web App开发 前端开发 测试技术
|
设计模式 编解码 移动开发
深聊自动化测试之:小鱼整理21条避坑指南,让你完美运行自动化
深聊自动化测试之:小鱼整理21条避坑指南,让你完美运行自动化
142 0
热饭的测开成果盘点第二十三期:wqrfproxy自动抓包断言库
本期介绍的是一个组件,身为python的第三方库,已经被pypi收录,且国内镜像源也早已复制。也就是说可以通过pip install 来下载。这个组件就是:wqrfproxy ,它的作用是可以在正常的unittest脚本中,对app发出的请求进行断言。
热饭的测开成果盘点第二十三期:wqrfproxy自动抓包断言库
|
设计模式 算法 程序员
你是否 diss 过别人的代码?| 怎样的代码才算优秀?
你一定在内心吐槽过他的代码太烂:没注释、逻辑混乱、到处都是 magic number、实现方案过时、耦合严重、一改就出 bug。 此时心中的怒火油然而生,仿佛自己是正义的化身。。。
125 0
|
XML Java 测试技术
告别加班/解放双手提高单测覆盖率之Java 自动生成单测代码神器推荐
很多公司对分支单测覆盖率会有一定的要求,比如 单测覆盖率要达到 60% 或者 80%才可以发布。 有时候工期相对紧张,就优先开发功能,测试功能,然后再去补单元测试。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/e9e8ea7d35ca4830bce7929774471207.jpg) 但是编写单元测试又比较浪费时间,有没有能够很大程度上自动化生成单元测试的插件,自己简单改改即可呢? 自己尝试在 Idea 插件库里搜索相关插件并去尝试使用,发现 `TestMe` 还可以。后面和其他同学交流,谎伴 同学推荐他一直在用的 `Squaretest`,我试用
6977 1
告别加班/解放双手提高单测覆盖率之Java 自动生成单测代码神器推荐
下一篇
无影云桌面