有些 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 或性质违反。
它不是普通单元测试的替代品。已知业务规则仍然用表驱动测试写清楚;模糊测试适合解析、编码、路径和协议这类边界很多的函数。把两者结合起来,代码对异常输入会更稳。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。