模糊测试适合找你没想到的输入
普通测试需要开发者自己编写输入和预期结果。表驱动测试虽然已经足够强大,但它始终受限于人的想象力。解析函数、路径处理、编码转换、协议拆包这类代码,输入空间庞大到几乎不可能被人脑穷尽。模糊测试的思路正是为解决这一困境而生:你给工具一些种子样本和一个必须始终保持的性质,让工具自动不断生成新的输入,尝试触发 panic 或违反你定义的性质。
Go 1.18 将 fuzzing 正式纳入标准测试工具链。这意味着你不需要额外安装复杂框架,直接通过 go test -fuzz 命令就可以开始探索输入边界。它不是普通单元测试的替代品,而是有力的补充。已知业务规则仍应使用表驱动测试写清楚;未知边界则交给 fuzz 帮你探索。
本文用一个小解析函数演示 fuzzing 的完整工作流,帮助你建立从编写到维护的系统性理解。
第一个 key=value 解析器
我们从这样一个简单的解析函数开始:
package parser
import (
"fmt"
"strings"
)
func ParsePair(input string) (string, string, error) {
parts := strings.Split(input, "=")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid pair: expected exactly one '='")
}
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("unexpected error: %v", err)
}
if key != "name" || value != "go" {
t.Fatalf("got key=%q value=%q", key, value)
}
}
但这个测试没有覆盖 value 中包含 = 的情况。如果输入是 token=a=b,strings.Split 会分成三段,函数返回错误。也许这正是业务预期,也许不是。fuzz 可以帮助你发现这类边界。
编写 Fuzz 函数
Go 的 fuzz 函数以 Fuzz 开头:
func FuzzParsePair(f *testing.F) {
// 添加种子输入
f.Add("name=go")
f.Add("empty=")
f.Add("key=value")
// 定义 fuzz 目标
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)
}
})
}
这里的性质检查(property check)非常关键。我们没有告诉 fuzzer 应该输出什么,而是定义了一个必须保持的数学性质:如果解析成功,重建后的字符串应该等于原始输入。这个性质对许多解析函数都适用。
运行与监控
基础运行
# 运行普通测试(包含 fuzz 回归用例)
go test ./...
# 开始 fuzz 探索
go test -fuzz=FuzzParsePair ./...
常用运行参数
# 限制探索时间
go test -fuzz=FuzzParsePair -fuzztime=30s ./...
# 限制迭代次数
go test -fuzz=FuzzParsePair -fuzztime=10000x ./...
# 限制内存使用
go test -fuzz=FuzzParsePair -test.memory=2G ./...
# 指定并行度
go test -fuzz=FuzzParsePair -parallel=4 ./...
运行输出解读
fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed
fuzz: elapsed: 3s, execs: 123456 (41234/sec), new interesting: 12 (total: 19)
fuzz: minimizing 1-byte fail input...
fuzz: failed input written to testdata/fuzz/FuzzParsePair/0c4a...
--- FAIL: FuzzParsePair (3.12s)
fuzz_test.go:24: rebuilt = "token=base64", want "token=a=b"
FAIL
exit status 1
输出中的关键信息:
gathering baseline coverage:收集初始种子覆盖的代码路径new interesting:发现新覆盖路径的输入数量minimizing:对新发现的崩溃输入进行最小化failed input written to:崩溃输入的保存位置
发现并分析 Crash
当 fuzzer 发现输入违反了性质时,它会自动保存该输入到 testdata/fuzz/FuzzXxx/ 目录中。这个目录结构如下:
testdata/
└── fuzz/
└── FuzzParsePair/
├── 0a1b2c3d... <- 普通回归用例
├── 0c4a4c6c... <- 最小化后的崩溃用例
└── 5e6f7a8b...
每个文件的内容就是触发该行为的输入数据。你可以查看文件内容来了解触发的输入是什么。
分析崩溃的步骤:
- 查看
go test -fuzz输出的错误信息 - 检查
testdata/fuzz/FuzzXxx/中的新文件 - 编写一个手动测试来单独复现该输入
- 调试理解为什么该输入触发了 bug
- 修复代码
- 验证修复(运行普通测试包含该回归用例)
修复与回归测试
发现崩溃后,我们的 ParsePair 解析逻辑修复如下:
func ParsePair(input string) (string, string, error) {
index := strings.Index(input, "=")
if index < 0 {
return "", "", fmt.Errorf("invalid pair: no '=' found")
}
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) {
tests := []struct {
input string
wantKey string
wantValue string
wantErr bool
}{
{"name=go", "name", "go", false},
{"token=a=b", "token", "a=b", false},
{"empty=", "empty", "", false},
{"=value", "", "", true},
{"noequals", "", "", true},
{"", "", "", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
key, value, err := ParsePair(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr = %v", err, tt.wantErr)
}
if !tt.wantErr {
if key != tt.wantKey || value != tt.wantValue {
t.Errorf("got key=%q value=%q, want key=%q value=%q",
key, value, tt.wantKey, tt.wantValue)
}
}
})
}
}
不同类型输入的 Fuzzing
字符串输入
上面的示例已经展示了字符串 fuzzing。字符串是最常见的 fuzz 目标,fuzzer 会生成各种边界输入:空字符串、超长字符串、非法 UTF-8、特殊字符等。
字节切片输入
字节切片适合处理二进制数据:
func FuzzBinaryParser(f *testing.F) {
f.Add([]byte{0x01, 0x00, 0x00, 0x00, 0x0A}) // 简单的 TLV
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) < 5 {
return
}
tag := data[0]
length := uint32(data[1])<<24 | uint32(data[2])<<16 |
uint32(data[3])<<8 | uint32(data[4])
if length > 10000 {
return
}
// 检查报文是否完整
if len(data) < int(5+length) {
return
}
payload := data[5 : 5+length]
_ = tag
_ = payload
})
}
结构体输入
对于复杂结构,可以通过字节切片来间接 fuzz:
type UserInput struct {
Name string
Age int
Email string
}
func FuzzUserValidator(f *testing.F) {
f.Add([]byte(`{"name":"Alice","age":30,"email":"alice@example.com"}`))
f.Fuzz(func(t *testing.T, data []byte) {
var user UserInput
if err := json.Unmarshal(data, &user); err != nil {
return
}
if err := ValidateUser(user); err != nil {
return
}
// 性质:通过验证的结构体必须满足基本条件
if user.Age < 0 {
t.Errorf("age cannot be negative: %d", user.Age)
}
if user.Name == "" {
t.Errorf("name cannot be empty")
}
})
}
Fuzz 函数设计原则
一个好的 fuzz 函数应遵循以下原则:
- 保持纯粹:不访问真实数据库、不调用外部 HTTP 服务、不写生产文件
- 快速执行:fuzzer 会高频调用目标函数,单次执行应在毫秒级别
- 明确定义性质:编写清晰的性质检查,而不是简单的“不 panic”
- 提供种子:至少提供 3-5 个具有代表性的种子输入
- 不要打印:避免在 fuzz 函数中打印日志,会严重影响性能
反例:
// 错误:访问外部服务
func FuzzAPIService(f *testing.F) {
f.Fuzz(func(t *testing.T, input string) {
resp, err := http.Post("https://api.example.com/parse", "text/plain",
strings.NewReader(input))
// ...
})
}
// 错误:太慢
func FuzzSlowProcess(f *testing.F) {
f.Fuzz(func(t *testing.T, input string) {
result := ComputeForSeconds(input, 10) // 太慢了!
// ...
})
}
// 错误:没有明确性质
func FuzzVague(f *testing.F) {
f.Fuzz(func(t *testing.T, input string) {
DoSomething(input) // 不检查任何性质
})
}
失败输入的管理策略
当 fuzz 发现失败输入后,关键不是“这次修好了”,而是建立长期防护。推荐的工作流程:
- 保留失败输入:
testdata/fuzz/FuzzXxx/中的文件是宝贵的回归资产 - 命名规范化:将自动生成的文件名改为描述性名称
- 代码审查:将新发现的边界用例纳入普通测试
- 文档记录:记录发现的 bug 类型和修复策略
# 提交前检查是否存在新崩溃用例
git status testdata/fuzz/
# 将崩溃用例重命名为可读名称
mv testdata/fuzz/FuzzParsePair/0c4a... testdata/fuzz/FuzzParsePair/equal-in-value
与 CI/CD 的持续集成
推荐策略:定期运行 + 回归失败用例
# .github/workflows/continuous-fuzz.yml
name: Continuous Fuzzing
on:
schedule:
- cron: '0 3 * * *' # 每天凌晨 3 点
push:
branches: [main]
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Run fuzz tests for 10 minutes
run: go test -fuzz=. -fuzztime=10m ./...
- name: Upload any new corpus
uses: actions/upload-artifact@v4
with:
name: fuzz-corpus
path: testdata/fuzz/
- name: Check for new crashes
run: |
if [ -n "$(git status --porcelain testdata/fuzz/)" ]; then
echo "New fuzz inputs found. Please review and commit."
exit 1
fi
PR 阶段:运行回归用例
在拉取请求中,不需要运行完整的 fuzz 探索。普通 go test ./... 会自动执行 testdata/fuzz/ 中的所有回归用例:
# .github/workflows/pr.yml
name: PR Checks
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Run all tests including fuzz regressions
run: go test -race ./...
云托管的持续 Fuzzing
对于安全关键项目,可以考虑以下云托管 fuzzing 服务:
- OSS-Fuzz:Google 主办的免费 fuzzing 服务,支持 Go 项目
- ClusterFuzzLite:可以集成到 CI 中的轻量级 fuzzing
# oss-fuzz 集成需要在项目根目录添加 .clusterfuzzlite 配置
# 并提交到 oss-fuzz 仓库
性能考量与限制
运行时间建议
| 场景 | 建议时间 |
|---|---|
| 本地快速探索 | 30 秒 - 5 分钟 |
| 合并前验证 | 10 - 15 分钟 |
| CI 定期运行 | 1 - 4 小时 |
| 安全关键模块 | 持续运行 |
内存和 CPU 限制
# 限制内存使用
go test -fuzz=FuzzXxx -test.memory=1G ./...
# 限制 CPU 使用
go test -fuzz=FuzzXxx -test.cpu=2 ./...
已知限制
- Go 内置 fuzzer 不支持自定义输入生成策略
- 不支持并行 fuzz 同一个目标
- 结构体 fuzzing 需要通过字节切片间接实现
- 不支持目标导向的特定路径覆盖
常见问题 FAQ
Q: Fuzz 回归用例会拖慢 CI 吗?
A: 回归用例本质上是加载字节数据并执行 fuzz 函数,速度非常快。通常比普通单元测试还快。
Q: 同一个 fuzz 目标可以多次添加同一个种子吗?
A: 可以,但没必要。重复的输入不会带来额外价值。
Q: 可以删除 testdata/fuzz/ 中的旧文件吗?
A: 可以,但要谨慎。这些文件是回归测试的一部分,删除意味着放弃了对相应边界的覆盖。
Q: Fuzz 能发现所有 bug 吗?
A: 不能。Fuzz 是一种概率性方法,发现的边界取决于运行时间和代码结构。它不能替代代码审查和静态分析。
小结
Go 模糊测试的基本工作流是:编写 FuzzXxx 函数、添加种子用例、定义必须保持的性质、运行 go test -fuzz=...、发现崩溃后分析修复、并将失败输入纳入回归测试集。
它最适合边界复杂、输入空间大的函数:解析器、编码器、路径处理、协议拆包等。不要把它当成所有测试的替代品,也不要让它依赖外部系统。普通测试写规则,模糊测试找盲点,两者配合起来,代码对异常输入的鲁棒性会显著提升。
建立从发现到修复到回归测试的完整工作流,并把 fuzzing 纳入 CI 的持续运行计划,是让模糊测试发挥长期价值的关键。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。