Go 模糊测试入门:让工具帮你发现边界输入

本文讲解 Go fuzz testing 的基本写法、种子用例、运行命令和适合场景,帮助初学者用工具发现解析函数中的边界问题。

有些 bug 不是你能手写用例想到的

普通单元测试依赖我们自己列举输入。表驱动测试已经能覆盖很多边界,但面对解析器、编码转换、路径处理、压缩数据、模板变量、协议字段这类函数,人很难穷尽所有奇怪输入。模糊测试的思路是:你写出基本性质和种子用例,让工具不断生成新输入,尝试触发 panic、越界、死循环或违反性质的结果。

Go 的测试工具支持 fuzz testing。它不是替代表驱动测试,而是补充。你仍然需要清楚的普通测试覆盖已知规则;模糊测试更适合找未知边界。

这篇文章用一个简单解析函数做例子,讲如何写第一个 fuzz test。

一个容易出错的解析函数

假设我们解析 key=value

func ParsePair(input string) (string, string, error) {
	parts := strings.Split(input, "=")
	if len(parts) != 2 {
		return "", "", fmt.Errorf("invalid pair")
	}
	if parts[0] == "" {
		return "", "", fmt.Errorf("key is empty")
	}
	return parts[0], parts[1], nil
}

这个函数有个问题:如果 value 里也包含 =,比如 token=a=b,它会认为非法。也许业务确实不允许,也许我们其实希望只按第一个 = 切分。普通测试要靠我们自己想到这个场景。

先写普通测试:

func TestParsePair(t *testing.T) {
	key, value, err := ParsePair("name=go")
	if err != nil {
		t.Fatalf("ParsePair() error = %v", err)
	}
	if key != "name" || value != "go" {
		t.Fatalf("got %q=%q", key, value)
	}
}

写第一个 fuzz test

测试函数以 Fuzz 开头:

func FuzzParsePair(f *testing.F) {
	f.Add("name=go")
	f.Add("empty=")
	f.Add("token=a=b")

	f.Fuzz(func(t *testing.T, input string) {
		key, value, err := ParsePair(input)
		if err != nil {
			return
		}

		rebuilt := key + "=" + value
		if rebuilt != input {
			t.Fatalf("rebuilt = %q, want %q", rebuilt, input)
		}
	})
}

f.Add 添加种子用例。f.Fuzz 里的函数会接收工具生成的新输入。这里我们定义了一个性质:如果解析成功,把 key 和 value 拼回去应该等于原输入。

运行普通测试:

go test ./...

运行模糊测试:

go test -fuzz=FuzzParsePair ./...

工具会持续生成输入,直到你停止,或发现失败。

修复函数

如果业务规则是只按第一个 = 切,可以改成:

func ParsePair(input string) (string, string, error) {
	index := strings.Index(input, "=")
	if index < 0 {
		return "", "", fmt.Errorf("invalid pair")
	}

	key := input[:index]
	value := input[index+1:]
	if key == "" {
		return "", "", fmt.Errorf("key is empty")
	}

	return key, value, nil
}

现在 token=a=b 会解析成 key token,value a=b。模糊测试的性质也更合理。

模糊测试发现失败后,Go 会保存触发失败的输入。你可以把它转成普通回归测试,确保之后不再坏。

适合 fuzz 的场景

适合模糊测试的函数通常有几个特点:输入空间很大,边界很多,函数应该对任意输入保持安全。比如:

  • URL、路径、查询参数解析
  • JSON、CSV、XML 的轻量封装
  • 自定义协议解析
  • 字符串规范化
  • 压缩或编码转换
  • 权限表达式解析

不太适合的场景是大量依赖数据库、网络、当前时间或外部状态的业务流程。模糊测试会调用很多次,函数应该尽量快、纯粹、可重复。

模糊测试不是越久越好

第一次使用 fuzz 时,很容易让它一直跑,然后不知道什么时候该停。更实际的做法是把它当成一种探索工具:本地针对某个解析函数跑一段时间,发现失败后修复并保留回归用例;CI 里可以只跑普通测试,或者在专门任务里短时间运行 fuzz。

例如本地运行 30 秒:

go test -fuzz=FuzzParsePair -fuzztime=30s ./...

如果工具发现失败输入,先不要急着删除缓存。把触发失败的输入整理成普通测试:

func TestParsePairWithEqualsInValue(t *testing.T) {
	key, value, err := ParsePair("token=a=b")
	if err != nil {
		t.Fatalf("ParsePair() error = %v", err)
	}
	if key != "token" || value != "a=b" {
		t.Fatalf("got %q=%q", key, value)
	}
}

这样即使以后不跑 fuzz,已知 bug 也不会回归。模糊测试帮你发现未知输入,普通测试帮你长期固定预期。

还有一点很重要:fuzz 函数里不要随便打印大量日志,也不要访问真实外部服务。工具会高频调用目标函数,副作用越多,结果越不稳定。最适合 fuzz 的代码通常是纯函数,输入一段数据,返回解析结果或错误。

小结

Go 模糊测试让工具帮你探索奇怪输入。你提供种子用例和需要保持的性质,运行 go test -fuzz=...,工具会不断生成新输入,尝试发现 panic 或性质违反。

它不是普通单元测试的替代品。已知业务规则仍然用表驱动测试写清楚;模糊测试适合解析、编码、路径和协议这类边界很多的函数。把两者结合起来,代码对异常输入会更稳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页