模糊测试适合找你没想到的输入
普通测试需要我们自己写输入和预期。表驱动测试已经很强,但它仍然依赖人的想象力。解析函数、路径处理、编码转换、协议拆包这类代码,输入空间很大,你很难手写覆盖所有奇怪情况。模糊测试的思路是:你给工具一些种子样本和一个必须保持的性质,让工具不断生成新输入,尝试触发 panic 或违反性质。
Go 1.18 把 fuzzing 放进了标准测试工具链。你不需要额外安装复杂框架,就可以用 go test -fuzz 开始探索边界。它不是替代单元测试,而是补充。已知规则仍然用普通测试写清楚,未知边界交给 fuzz 帮你找。
这篇文章用一个小解析函数演示完整流程。
一个 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
}
普通测试:
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)
}
}
这个函数看起来没问题,但如果输入是 token=a=b,strings.Split 会切出三段。也许这正是业务规则,也许你想保留 value 里的 =。模糊测试可以帮助你发现类似边界。
写 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 -fuzz=FuzzParsePair ./...
限制时间:
go test -fuzz=FuzzParsePair -fuzztime=30s ./...
本地探索时可以跑几十秒或几分钟。CI 里是否跑 fuzz,要看项目规模和时间预算。
修复解析逻辑
如果业务希望只按第一个等号切分,可以改成:
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。普通测试也应该补上:
func TestParsePairValueContainsEqual(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 的重要习惯:工具帮你发现输入,修复后把它变成稳定回归测试。
Fuzz 函数要保持纯粹
模糊测试会高频调用目标函数,所以不要在 fuzz 里做这些事:
- 访问真实数据库
- 调用外部 HTTP 服务
- 写生产文件
- 打大量日志
- 依赖当前时间产生不稳定结果
适合 fuzz 的函数应该尽量像纯函数:输入数据,返回结果或错误。解析器、编码器、路径清理、表达式解析、简单协议处理都很适合。
如果函数可能非常慢,也要谨慎。fuzz 会尝试很多输入,目标函数越慢,探索效率越低。
失败输入要进入长期测试
当 fuzz 找到失败输入后,最重要的不是“这次修好了”,而是让这个输入以后一直被测试覆盖。比如工具发现输入 "=value" 会触发你没处理好的边界,就应该补一条普通测试:
func TestParsePairEmptyKey(t *testing.T) {
_, _, err := ParsePair("=value")
if err == nil {
t.Fatal("expected error")
}
}
如果发现某个 Unicode 字符、超长字符串或特殊分隔符导致 panic,也同样整理成普通测试。普通测试运行快、结果稳定,适合长期留在 CI。fuzz 更像探索过程,不一定每次提交都跑很久。
这也是入门阶段使用 fuzz 的正确心态:它不是神秘工具,而是帮你发现测试样本。最终让项目稳定的,仍然是清楚的函数边界和持续运行的测试集。
小结
Go 模糊测试的基本流程是:写一个 FuzzXxx 函数,添加种子用例,定义必须保持的性质,运行 go test -fuzz=...,发现失败后修复,并把失败输入整理成普通测试。
它最适合边界复杂、输入空间大的函数。不要把它当成所有测试的替代品,也不要让它依赖外部系统。普通测试写规则,模糊测试找盲点,两者配合起来,解析类代码会稳很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。