Go 模糊测试
Go 1.18+ 内置 fuzzing:种子语料、变异输入、失败落盘与 corpus 管理,用于发现崩溃、死循环与不变量破坏。
[!info] related notes
Go 模糊测试
这个概念为什么会出现
表驱动测试擅长已知边界:空串、最大长度、同事提过的坏例子。真实世界还有:
- 畸形 UTF-8、奇怪长度组合
- 解析器状态机的罕见转移
- “往返”不变量被冷门输入打破
模糊测试(fuzzing) 用引擎自动变异输入,寻找能让断言失败或 panic 的样本。Go 1.18 起 fuzz 进入标准 go test 工具链,无需先上外部框架即可对纯函数级目标开搞。
[!abstract] 一句话理解 Fuzz 测试提供种子语料并由引擎变异生成海量输入,反复执行目标函数;一旦失败就把输入沉淀到 corpus,变成可回归的资产。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:用字符串反转发现输入契约缺口
下面保留 MirrorDisplayName 这个教学函数名,实际只按 rune 反转。它不是 RTL 排版算法,也不保持字素簇;不要用于真实展示名布局。
第一阶段故意把“翻转两次回到原文”施加于任意 string。已有种子都是合法 UTF-8,会通过;fuzz 生成非法 UTF-8 时会揭示契约缺口。
用 fuzz:种子给业务常见名,引擎变异海量输入;一旦失败就落盘进 corpus,变成回归资产。
// displayname.go
package profile
// MirrorDisplayName 按 rune 反转,仅演示往返性质,不实现文本排版。
// 合法 UTF-8 可往返;非法字节转换成 RuneError 后会丢失原值。
func MirrorDisplayName(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// displayname_fuzz_test.go
package profile
import "testing"
// FuzzMirrorDisplayName 守护「镜像两次 = 原文」等不变量。
//
// 第一阶段故意不限制 UTF-8;用 fuzz 找出往返性质的适用域。
// 教学点:f.Add 种子 + f.Fuzz 目标;无 -fuzz 时仍跑种子/已存 corpus。
func FuzzMirrorDisplayName(f *testing.F) {
// 种子:真实展示名形态(英文、中文、空、emoji)。
f.Add("hello")
f.Add("世界")
f.Add("")
f.Add("🙂👍")
f.Fuzz(func(t *testing.T, in string) {
// 待验证性质:反转两次是否能还原任意输入字节?
rev := MirrorDisplayName(in)
double := MirrorDisplayName(rev)
if double != in {
t.Fatalf("MirrorDisplayName(MirrorDisplayName(%q)) = %q", in, double)
}
// 不变量 2:解码后的 rune 个数不变(不是字素簇个数)。
if len([]rune(rev)) != len([]rune(in)) {
t.Fatalf("rune length changed: in=%q rev=%q", in, rev)
}
})
}
将两段代码分别保存为 displayname.go 和 displayname_fuzz_test.go;新目录执行 go mod init example.com/profile 后运行:
# CI:只跑种子与已保存 corpus
go test .
# 本地/夜间:启用变异探索
go test -fuzz='^FuzzMirrorDisplayName$' -fuzztime=30s -parallel=2 .
首轮 go test . 只跑已有种子,会通过。探索阶段可能找到非法 UTF-8 反例,例如 "\xff":转成 []rune 后变为 U+FFFD,无法恢复原字节。短时没找到不代表性质成立。成功保存的失败样本位于 testdata/fuzz/FuzzMirrorDisplayName/,普通 go test 会回归这些样本。
第二阶段先选契约:本例只对合法 UTF-8 要求往返。在测试文件引入 unicode/utf8,并在 f.Fuzz 回调开头加入:
if !utf8.ValidString(in) {
t.Skip("round-trip contract requires valid UTF-8")
}
保留失败样本,重新执行时它会因超出契约而跳过;如果产品要求接受任意字节,则不能通过 Skip 掩盖缺陷,必须改用保字节的实现或明确返回错误。
结合场景再看三个关注点
-
结构:
FuzzXxx+f.Fuzz闭包
展示名目标的参数类型须在 fuzz 支持集合内(如string、[]byte)。 -
CI 与探索分工
默认go test回归 corpus;-fuzz才持续变异吃 CPU。 -
失败即资产
撞到的怪名字会落盘,避免「修过一次又丢」的国际化边角。
核心概念与准确模型
结构
种子 f.Add / testdata 语料
↓
变异引擎(实现细节)
↓
目标 f.Fuzz 闭包
↓
失败 → 最小化/保存 corpus
| 概念 | 含义 |
|---|---|
| Seed corpus | 你提供的初始有趣输入 |
| Generated corpus | 引擎发现的新覆盖/有趣输入 |
| Crashers | 导致失败的输入(会落盘) |
-fuzztime | 模糊持续时间或次数上限 |
与普通 Test 的关系
- 无
-fuzz:fuzz 目标执行 f.Add 与 testdata/fuzz 中的种子——CI 友好 - 有
-fuzz:进入变异循环,吃 CPU
因此 fuzz 适合:
- CI:回归 corpus
- 开发者机器/夜间:探索
好的 fuzz 目标特征
适合:
- 纯函数、解析、编解码、压缩、校验
- 可表达不变量(往返、幂等、从不 panic、输出满足格式)
不适合单独:
- 强依赖外部网络/真实时钟且难注入
- 只有“不崩溃”目标却试图据此推断业务正确性
- 非确定性且无法规范化的输出(需先固定随机源)
不变量示例
// 1) round-trip
// 2) 不 panic(由框架捕获)
// 3) 若 err==nil,则进一步结构合法
out, err := Parse(in)
if err != nil {
return // 本例允许按契约拒绝输入
}
if err := out.Validate(); err != nil {
t.Fatal(err)
}
“拒绝非法输入”本身不是失败;接受后破坏不变量或 panic 才是。
规范 vs 实现
- API 与
go test集成:标准库/cmd/go文档保证的用法。 - 变异策略、覆盖率引导的具体算法、调度:属实现细节,随版本演进,不应被测试逻辑依赖。
边界情况与反直觉行为
1. 字节与字符串的 Unicode
string 可以包含非法 UTF-8。按 rune 反转会丢失非法字节,按字节反转可能让中间结果不是合法 UTF-8,但反转两次仍能恢复原字节。仅靠往返断言不能证明文本处理正确;应按契约增加合法 UTF-8、字素边界等性质。
2. 无限循环
可能把机器跑挂。需要:
- 输入长度上限
- 复杂度护栏
- -fuzztime 限制探索预算,但不等于单次目标调用的硬截止;还需合理的 -timeout 和 CI 进程超时,失败最小化预算可用 -fuzzminimizetime 控制。
3. 非确定性
若目标用到全局随机/时间,先注入或在 fuzz 中固定,否则“失败”不可复现。
4. 语料库膨胀
失败回归样本在 testdata/fuzz 下;探索时扩展覆盖率的语料通常保存在 Go 构建缓存的 fuzz 子目录中,不会全部写进仓库,普通 go test 也不回归该缓存。不要把整个缓存当作需要提交的资产。
5. 多参数
f.Fuzz 回调的首参为 *testing.T,后续参数限支持的基础类型(如 string、[]byte、整数、浮点和 bool);不支持任意结构体或 map。f.Add 的数量、类型和顺序必须匹配。
常见误区
[!warning] 常见误区:没有不变量,只“调用一下” 只调用 Parse 仍能发现 panic,适合有限的“不崩溃”目标;但无法证明返回结果正确。需要业务正确性时,应检查错误契约与成功结果。
[!warning] 常见误区:CI 开超长 -fuzz 错误:每个 PR fuzz 10 分钟。
正确:CI 跑种子+corpus;探索放夜间/本地。
[!warning] 常见误区:把失败输入删了当解决 错误:删
testdata让测试绿。
正确:修代码,保留样本防回归。
[!warning] 常见误区:模糊替代表驱动 错误:不再写明确边界用例。
正确:表驱动固化规格;fuzz 挖未知。
工程实践
- 先表驱动后 fuzz:已知例子进表或
f.Add。 - 解析器/编解码优先接入 fuzz。
- 限制工作量:拷贝输入时注意长度;避免对巨型分配无上限。
- 与 race 组合谨慎:
go test -race -fuzz=...更慢,可阶段性用。 - 文档化不变量:在测试注释写清“维护什么性质”。
- Corpus 进版本库:让团队共享回归资产。
go test -fuzz='^FuzzMirrorDisplayName$' -fuzztime=1m -parallel=2 .
go test -run='^FuzzMirrorDisplayName$' . # 仅 f.Add/testdata 种子,不持续变异
可验证实验
实验 1:往返
先运行原始目标寻找反例,再为合法 UTF-8 契约添加过滤并重新探索;有限时长通过只能表示本次未发现反例。
实验 2:区分往返与 UTF-8 有效性
换成按字节反转,验证两次反转仍保持原字节;另对合法 UTF-8 输入断言单次输出仍合法,观察中文种子如何打破后者。
实验 3:CI 模式
不传 -fuzz,确认仍执行 f.Add 种子。
实验 4:JSON round-trip
json.Marshal/Unmarshal 对结构体字段集合做不变量(注意 NaN 等边界,按类型选择)。
本节总结
- 本质:自动输入生成 + 不变量检查 + 失败语料沉淀。
- 工具链:与
go test统一;CI 回归 / 本地探索分工。 - 最易错:无不变量、删 corpus、把探索放进每次 PR。
- 下一步:对发现的样本补表驱动可读用例;热点再 bench。
自测题
概念题
- 不带
-fuzz时 fuzz 测试做什么? - 种子语料的作用?
- 什么样的断言适合放进
f.Fuzz?
代码推理题
f.Fuzz(func(t *testing.T, b []byte) {
_ = Parse(string(b))
})
弱点是什么?如何加强?
工程思考题
模糊 2 小时找到的 crasher 只在某同事机器上有。如何保证全员回归?
参考答案
展开
- 执行种子与已保存 corpus,不作持续变异。
- 引导早期覆盖有意义结构,加快发现。
- 不变量、往返、成功则合法、明确禁止 panic 的路径。
代码题:无断言,只能靠 panic;应检查 err 与成功后的结构/往返。
工程题:把失败输入提交到testdata/fuzz,CIgo test即可复现。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Go Fuzzing | 官方文档 | 教程与概念 |
| testing — F | 标准库 | API |
| go test fuzz flags | 命令 | -fuzz/-fuzztime |
| Tutorial: Getting started with fuzzing | 教程 | 手把手 Reverse 例 |
笔记元信息
- 建议文件名:
go-fuzz-testing.md - 所属阶段:阶段五
- 学习顺序:表驱动之后
- 建议下一篇:竞态检测 或回到质量 MOC
- 本篇状态:已深化(书章结构;corpus 与 CI 分工)