解析 Golang 测试(11)- 模糊测试

时间:2022-9-2     作者:smarteng     分类: Go语言


Go 1.18 开始提供了 fuzzing 能力的支持,testing 包在我们常见的 T, B 之外新增了 F 的类型,用于支持模糊测试。

那到底什么是模糊测试?Golang 提供了什么样的支持?作为开发者我们在什么场景下可以用呢?

今天我们继续【解析 Golang 测试】系列,带大家一起了解一下基于 Golang 使用模糊测试的那些事。

fuzzing test

日常测试代码的时候我们经常使用 table driven test 的方式,构造一组输入和预期的结果,调用我们的待测函数,检查结果是否和我们预期的匹配。

这样做自然而然带来一个思考:这个 table(或者说要验证的 case)要多大呢?

很多时候大家只是写一个【正常】的case,一个【异常】的case,顶多不超过 10 个就跑单测了。

但到底够不够呢?不好说,如果来了一些异常值,边缘 case,能不能处理?是否会导致程序挂掉?或者安全问题?

但这时候大家又会说了,你说的轻巧,我也想都验证啊,那我得构造多少个 case 出来,各种边边角角都测到,成本太高了。而且我还得一个个去想有哪些可能的输入,谁能想到那么多?

没错,这就是赤裸裸的现实。那有没有什么工具能帮我们自动生成输入呢?

有,这就是 fuzzing test!

Fuzzing is a technique where you automagically generate input values for your functions to find bugs

模糊测试能够【持续】,【自动】地生成一系列【半随机】的数据作为待测函数的输入,来找到程序里隐藏的 bug,对于边界case 能够很好的验证。

我们知道,任何涉及人工主观判断的地方,都必然带着偏见,而模糊测试中的输入,不是由人工指定的,而是自动生成的随机数据,所以可以规避这一点。

模糊测试通常可以不依赖于开发测试人员定义好的数据集,取而代之的则是一组通过数据构造引擎自行构造的一系列随机数据。模糊测试会将这些数据作为输入提供给待测程序,并且监测程序是否出现panic、断言失败、无限循环,或者其他什么异常情况。

这些通过数据构造引擎生成的数据被称为语料(corpus) 。另外模糊测试其实也是一种持续测试的手段,因为如果不限制执行的次数或者执行的最大时间,它就会一直不停的执行下去。

Golang 提供的支持

在 Go 1.18 之前,大家只能依赖于 Dmitry Vyukov 大佬的 go-fuzz,这是一个非官方的 fuzz 工具,帮助标准库找到了大量的 bug,但毕竟不是官方的支持,go-fuzz 在用法上还是比较麻烦的,而且很难将其集成到其他构建系统和非标准上下文中。

而现在,在 Go 1.19 已经 release 的今天,我们可以放心大胆地直接使用原生支持的 fuzz 能力。

fuzz 的支持同样基于 testing 包,为此还新增了一个类型 testing.F

我们来看看一个 Golang 实现的模糊测试长什么样:

undefined

注意签名,从常见的 func TestXXX(t *testing.T) 变成了 func FuzzXXX(f *testing.F) ,这个好理解,毕竟和普通单测以及benchmark定位不同。

问题来了,这个 seed corpus 是个什么?我们参照 官方术语解释 来理解一下:

seed corpus: A user-provided corpus for a fuzz test which can be used to guide the fuzzing engine. It is composed of the corpus entries provided by f.Add calls within the fuzz test, and the files in the testdata/fuzz/{FuzzTestName} directory within the package. These entries are run by default with go test , whether fuzzing or not.

所谓 seed corpus,就是一组用户提供的语料,fuzzing 引擎将会使用这个语料来生成随机数据。其实就是一个样板,比如上面的待测函数 func Foo(i int, s string) 接受的入参就是一个 int 加上一个 string,所以这里我们提供了一组样板,fuzzing 引擎就知道要生成什么类型的随机数据了。

undefined

参照 API 文档 也可以看到,这里的 Add 的入参是 N 个 interface{},你传什么都可以。

这里传入 f.Add(5, "hello") 就告诉了 fuzzing 引擎我们需要的数据类型和顺序。

好,我们继续往下看,Add 种子语料之后,我们发现执行模糊测试的代码是这样:

f.Fuzz(func(t *testing.T, i int, s string) {
    out, err := Foo(i, s)
    if err != nil && out != "" {
        t.Errorf("%q, %v", out, err)
    }
})
复制代码

我们先来看看这个 f.Fuzz 方法是干什么的:

undefined

这里它接受了一个 interface{},主要还是为了通用,实际上这里的定位就是要传一个用于模糊测试的函数,函数的首个入参必须是 *testing.T ,后面的参数就是你希望 Go 的 fuzz 引擎帮你生成的随机数据类型。并且要注意,这个函数不能有返回值。

这里我们希望对 Foo 的两个参数,int 和 string 都生成随机数据(术语上叫做 fuzzing arguments),所以函数签名就变成了

func(t *testing.T, i int, s string)

在这个函数的包装下,我们就可以调用实际的【待测函数】,把入参透传过来即可。后面的逻辑和普通单测没有区别。

再说个题外话,有多少人打印字符串的时候会用这个 %q ?

undefined

undefined

fmt 的文档里对这个有说明,简单说就是带上了引号,处理了 escape 的问题,看个示例大家就明白了:

import "fmt"

func main() {
    fmt.Printf("%s\n", "hello") // prints hello
    fmt.Printf("%q\n", "hello") // prints "hello"
    fmt.Printf("%s\n", "hello\n;") // prints hello
//; \n is not escaped
    fmt.Printf("%q\n", "hello\n;") // prints "hello\n;" \n is escaped here
}
复制代码

模糊测试的要求

Golang 原生支持中对于模糊测试的要求有下面 6 个

  1. 模糊测试必须是一个名称类似 FuzzXxx 的函数,仅仅接收一个 *testing.F 类型的参数, 无返回值;

  2. 模糊测试必须在 *_test.go 文件中才能运行;

  3. Fuzz target(模糊目标)必须是对 (*testing.F).Fuzz 的方法调用,参数是一个函数,并且此函数的第一个参数是 *testing.T ,然后是模糊参数( fuzzing argument ),没有返回值;

  4. 一个模糊测试中必须只有一个模糊目标;

  5. 所有的种子语料库( seed corpus )必须具有与模糊参数相同的类型,顺序相同。对 (*testing.F).Add 的调用也是如此, 同样也适用模糊测试中的testdata/fuzz中的语料文件;

  6. 模糊参数只能是下面的类型:

    • string, []byte
    • int, int8, int16, int32/rune, int64
    • uint, uint8/byte, uint16, uint32, uint64
    • float32, float64
    • bool

除此之外,还有一些隐藏的建议,我们的 fuzzing target 模糊目标,也就是上面用于测试的函数:

f.Fuzz(func(t *testing.T, i int, s string) {
    out, err := Foo(i, s)
    if err != nil && out != "" {
        t.Errorf("%q, %v", out, err)
    }
})
复制代码

执行过程中出现失败(命中了那个 t.Errorf 的分支),事实上这里的数据会被记录下来,下次跑的时候还会拿一模一样的失败 case 验证,防止此前失败的case 后面不见了。

这里需要注意一点,事实上 Go 执行的过程中,多个 fuzzing target 是并行来处理的,底层会有多个 worker,调度的顺序也不一定,所以,不能做持久化,也不能依赖一些全局状态,不要尝试改入参。

This function should be fast and deterministic, and its behavior should not depend on shared state. No mutatable input arguments, or pointers to them, should be retained between executions of the fuzz function, as the memory backing them may be mutated during a subsequent invocation. ff must not modify the underlying data of the arguments provided by the fuzzing engine.

运行测试

我们依然可以用 go test 命令来跑模糊测试,只是需要加上一个 -fuzz=FuzzTestName 这样的选项。同个包下其他所有类型的 test 都会先于模糊测试执行,毕竟比较耗费资源,随机数据生成是有成本的。如果其他单测能验出来,就不必动用模糊测试了。

需要注意的是,执行模糊测试的时间是由开发者自己定的,如果你的代码非常强健,不管怎么更换随机数据,测试都能通过,那么 fuzzing 将一直执行下去。除非找到了 error,或者你手动用 Ctrl^C 来停掉。详细的命令可以参照 文档 了解,这里我们看几个常用的:

  1. -fuzztime: 执行的模糊目标在退出的时候要执行的总时间或者迭代次数,默认是永不结束;

  2. -fuzzminimizetime: 模糊目标在每次最少尝试时要执行的时间或者迭代次数,默认是60秒。你可以禁用最小化尝试,只需把这个参数设置为0;

  3. -parallel: 同时执行的模糊化数量,默认是 $GOMAXPROCS 。当前进行模糊化测试时设置-cpu无效果。

执行的结果类似这样:

~ go test -fuzz FuzzFoo
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
fuzz: elapsed: 9s, execs: 1039901 (119895/sec), new interesting: 19 (total: 210)
fuzz: elapsed: 12s, execs: 1386684 (115594/sec), new interesting: 21 (total: 212)
PASS
ok      foo 12.692s
复制代码
  • elapsed: 从测试开始到此刻所经历的时间;

  • execs: 针对 fuzzing target 执行了多少组输入;

  • new interesting: 这个很有意思,啥是 interesting ?也就是增加了当前语料库的覆盖率的输入。执行模糊测试的过程中,它表达的是这一轮执行中,新增的 interesting 数量。通常来说越是开始,new interesting 增长的就越快,后来慢慢降速。

如果执行过程中存在失败,我们将会看到类似这样的结果:

    Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
    To re-run:
    go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
FAIL
exit status 1
FAIL    foo 0.839s
复制代码

这个失败的case,会被 fuzzing 引擎写到本地的 testdata 文件夹中,当你再执行 go test 的时候就会自动执行,作为回归测试。

最佳实践

通常来说,写出一个模糊测试并不麻烦,基本格式类似这样:

func MultipleInputs(a, b int, name string) {
    // ... fancy code goes here 
}

func FuzzMultipleInputs(f *testing.F) {
  // We can add Multiple Seeds, but it has to be the same order as the input parameters for MultipleInputs
  f.Add(10,20,"John the Ripper")
  f.Fuzz(func(t *testing.T,a int,b int,name string){
      MultipleInputs(a,b,name)      
  })
}
复制代码

当然,注意遵循我们上一节的要求, FuzzMultipleInputs 这个一定得在 xxx_test.go 的文件中。

如何发挥模糊测试的功效呢?go-zero 给出了一个流程,本质是 3 步,大家可以参考一下:

  1. 想清楚如何定义自己场景的 fuzzing arguments;

  2. 思考怎么写 fuzzing target,注意我们对于 fuzzing arguments 只是指定了类型,实际数据是引擎随机生成的,那应该如何验证【正确性】呢?这个需要业务根据场景判断,比如官方示例中其实使用 Reverse 字符串这样的函数示例:

func Reverse(s string) string {
    b := []byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}
复制代码

这其实是 fuzzing 测试的绝佳案例,为什么?因为你可以对任意一个输入,进行 reverse,然后转回来,比较两个是否相等即可。我们随便生成随机字符串,都能够验证。这个其实比较 trick,因为它是闭环的。

  1. 思考如何打印出来失败的 case,我们可以用它来构建一个新的单测,这样就把它固化了起来,fuzzing test 往往不是最终目标,而是为了找出来代码中的问题,帮我们沉淀下来一些边界 case,这样加上单测,以后就可以交给 CI 流程来处理了。

事实上,虽然 fuzzing 引擎只能提供基础的几种类型,但我们可以基于这些类型的随机值,自己处理变成我们需要的类型,go-zero 大佬的这个 FuzzSum 就是自己构造出来的 slice 入参,大家可以借鉴一下:

待测函数:

func Sum(vals []int64) int64 {
  var total int64  for _, val := range vals {
    if val%1e5 ! = 0 {
      total += val
    }
  }  return total
}
复制代码

模糊测试:

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.
    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())
  })
}
复制代码

这样不仅仅有 fuzz 给出的错误信息,也有我们自己 assert 提供必要的上下文,输出如下:

$ 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
复制代码

固化的单测:

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))
}
复制代码

更多实战的部分建议大家直接参考官方 Reverse 字符串的案例,比较清晰。

小结

模糊测试能够通过提供随机输入,帮助我们发现程序中潜藏的问题,建议大家先通过我们这篇文章的介绍,以及相关的链接,从概念上接受,理解 fuzzing test 是什么,怎么写。然后对自己的业务尝试写出模糊测试,看看有没有可能发现一些藏得比较深的 bug。

参考资料

标签: 测试