掘金 后端 ( ) • 2024-05-14 11:24

什么是模糊测试

在 Go 1.18 版本中,Go 引入了一个新的测试工具:模糊测试(Fuzzing)。模糊测试是一种自动化测试技术,它通过随机输入来发现程序中的错误。

模糊测试的原理很简单:随机生成输入,然后运行程序,检查程序的输出是否符合预期。如果程序的输出不符合预期,那么就说明程序中存在错误。

模糊测试的优点是可以发现一些边缘情况下的错误或者可能导致程序崩溃的输入,这些错误很难通过手工测试来发现。因此,模糊测试是一种非常有效的测试方法。

如何进行模糊测试

在这里引入一下官方博客的图:

Snipaste_2024-05-14_11-22-49.png

下面是详细说明:

  1. 首先,我们需要创建一个模糊测试函数,函数名以 Fuzz 开头,后面跟着要测试的函数名,函数名第一个字母大写,接收一个 *testing.F 参数。
  2. 模糊测试需要写在 _test.go 文件中。
  3. 一个模糊测试函数里面,必须包含一个模糊目标,也就是需要调用 (*testing.F).Fuzz 方法,这个方法的第一个参数是 *testing.T,后续是模糊测试自动生成的输入(我们需要使用这些随机的输入去调用我们的函数)。这个模糊目标没有返回值。
  4. 一个模糊测试只能有一个模糊目标(也就是上图的 Fuzz target 只能有一个)。
  5. 所有的种子语料库的类型必须和 Fuzz 函数的输入参数(上图的 Seed corpus additionFuzzing arguments)类型一致,因为模糊测试是根据 Seed corpus 的类型生成的随机参数来传递给 Fuzzing arguments 的。
  6. 模糊测试的参数只能是以下的类型:
    • string, []byte
    • int, int8, int16, int32, int64
    • uint, uint8/byte, uint16, uint32, uint64
    • float32, float64
    • bool

模糊测试示例

假设我们有以下这个反转字符串的函数:

func Reverse(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

rune 在 Go 里面是 int32 的别名,用来表示 Unicode 字符(因为 Unicode 字符最多只有 4 字节,所以 rune 足够存储一个 Unicode 字符)。

接着,为这个 Reverse 函数写一个模糊测试函数:

func FuzzReverse(f *testing.F) {
    // 种子语料库
	testcases := []string{"Hello, world", " ", "!12345"}
	for _, tc := range testcases {
		f.Add(tc)
	}
    // 模糊目标,orig 是随机生成的输入(根据上面的种子语料库生成)
	f.Fuzz(func(t *testing.T, orig string) {
        // 使用随机生成的输入去调用 Reverse 函数
        // 如果 Reverse 函数返回的结果不等于 orig 的反转,那么就说明 Reverse 函数有问题
		rev := Reverse(orig)
		doubleRev := Reverse(rev)
		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", orig, doubleRev)
		}
        // 又或者,如果 Reverse 函数返回的结果不是一个有效的 UTF-8 字符串,那么就说明 Reverse 函数有问题
		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
		}
	})
}

最后,通过下面的命令来执行一下模糊测试:

go test -fuzz=FuzzReverse -fuzztime=3s .

输出如下:

fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 20 workers
fuzz: minimizing 51-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzReverse (0.03s)
    --- FAIL: FuzzReverse (0.00s)
        hello_test.go:25: Before: "\x80", after: "�"
    
    Failing input written to testdata/fuzz/FuzzReverse/98fce631eb9c5dd5
    To re-run:
    go test -run=FuzzReverse/98fce631eb9c5dd5
FAIL
exit status 1
FAIL    main  0.033s

从上述输出可以看到,我们有一个模糊测试的用例失败了,然后 Go 帮我们把错误的测试用例写入到了 testdata/fuzz/FuzzReverse/98fce631eb9c5dd5 文件中:

go test fuzz v1
string("\x80")

修正这个问题

我们从 98fce631eb9c5dd5 这个文件可以看出,模糊测试给我们生成的字符串并不是一个有效的 UTF-8 字符串,所以我们需要在 FuzzReverse 函数中加入一些判断:

func Reverse(s string) (string, error) {
    // 判断是否是有效的 utf-8 字符串
	if !utf8.ValidString(s) {
		return s, errors.New("input is not valid UTF-8")
	}

	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes), nil
}

在判断到传入的字符串不是有效的 UTF-8 字符串的时候,我们返回一个错误。然后在 FuzzReverse 函数中加入对错误的判断:

func FuzzReverse(f *testing.F) {
	testcases := []string{"Hello, world", " ", "!12345"}
	for _, tc := range testcases {
		f.Add(tc)
	}
	f.Fuzz(func(t *testing.T, orig string) {
		rev, err := Reverse(orig)
		if err != nil {
			return
		}
		doubleRev, err := Reverse(rev)
		if err != nil {
			return
		}
		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)
		}
	})
}

跳过不是 UTF-8 字符串的测试用例,然后再次执行模糊测试:

go test -fuzz=FuzzReverse -fuzztime=3s .

输出如下:

fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 20 workers
fuzz: elapsed: 3s, execs: 1431158 (477041/sec), new interesting: 39 (total: 45)
fuzz: elapsed: 3s, execs: 1431158 (0/sec), new interesting: 39 (total: 45)
PASS
ok      main  3.117s

这次模糊测试通过了,没有发现问题。

运行模糊测试一些建议

模糊测试的时间

需要注意的是,在进行模糊测试的时候,我们可能需要指定一个合适的时间,否则模糊测试可能会一直运行下去。可以通过 -fuzztime 参数来指定模糊测试的时间:

go test -fuzz=FuzzReverse -fuzztime=3s .

如果我们需要在 CI 中集成,这个可能是必须的。否则,CI 会一直运行模糊测试。

调整种子语料库

检查提供给模糊器的种子语料库,确保其多样性足以探索各种代码路径,但又不会过于宽泛,导致模糊器陷入过多路径。有时,过于通用的种子会导致模糊器在无益路径上花费过多时间。

并行模糊测试

如果我们需要控制模糊测试的并行度,可以通过 -parallel 参数来指定模糊测试的并行度:

默认情况下,Go 会使用所有的 CPU 核心来运行模糊测试。

 go test -fuzz=FuzzReverse -fuzztime=3s -parallel=10 .

输出:

fuzz: elapsed: 0s, gathering baseline coverage: 0/52 completed
fuzz: elapsed: 0s, gathering baseline coverage: 52/52 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1164562 (388133/sec), new interesting: 0 (total: 52)
fuzz: elapsed: 3s, execs: 1164562 (0/sec), new interesting: 0 (total: 52)
PASS
ok      main  3.109s

我们从第二行输出可以看到 10 workers,因为我们通过了 -parallel 参数指定了只使用 10 个 CPU 核心来运行模糊测试。

总结

模糊测试是一种自动化测试技术,它通过随机输入来发现程序中的错误。Go 1.18 版本引入了模糊测试,我们可以通过 (*testing.F).Fuzz 方法来进行模糊测试。模糊测试是一种非常有效的测试方法,可以发现一些边缘情况下的错误或者可能导致程序崩溃的输入。

单元测试可以帮助我们发现一些常规路径上的错误,而模糊测试可以帮助我们发现一些边缘情况下的错误。因此,单元测试和模糊测试是互补的,我们可以同时使用这两种测试方法来提高代码的质量。