Go 模糊测试

Go 1.18+ 内置 fuzzing:种子语料、变异输入、失败落盘与 corpus 管理,用于发现崩溃、死循环与不变量破坏。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 模糊测试

这个概念为什么会出现

表驱动测试擅长已知边界:空串、最大长度、同事提过的坏例子。真实世界还有:

  • 畸形 UTF-8、奇怪长度组合
  • 解析器状态机的罕见转移
  • “往返”不变量被冷门输入打破

模糊测试(fuzzing) 用引擎自动变异输入,寻找能让断言失败或 panic 的样本。Go 1.18 起 fuzz 进入标准 go test 工具链,无需先上外部框架即可对纯函数级目标开搞。

[!abstract] 一句话理解 Fuzz 测试提供种子语料并由引擎变异生成海量输入,反复执行目标函数;一旦失败就把输入沉淀到 corpus,变成可回归的资产。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:为「展示名镜像排版」写往返不变量 fuzz

国际化站点要把用户展示名做「镜像翻转」用于某种 RTL 预览组件(实现上按 rune 反转)。
表驱动能覆盖空串、中文、emoji,但畸形 UTF-8、奇怪组合仍可能打破「翻转两次回到原文」。

用 fuzz:种子给业务常见名,引擎变异海量输入;一旦失败就落盘进 corpus,变成回归资产。

// displayname.go
package profile

// MirrorDisplayName 按 rune 反转展示名(RTL 预览用,演示实现)。
// 业务意图:预览组件需要可逆变换;教学点:纯函数 + 不变量最适合 fuzz。
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 守护「镜像两次 = 原文」等不变量。
//
// 业务意图:防止冷门 Unicode 把预览组件弄成不可逆乱码。
// 教学点: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) {
		// 不变量 1:往返——预览开关两次应回到用户原名。
		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)
		}
	})
}

建议运行:

# CI:只跑种子与已保存 corpus
go test .

# 本地/夜间:启用变异探索
go test -fuzz=FuzzMirrorDisplayName -fuzztime=30s .

期望:短时 fuzz 无失败时输出 PASS;若断言失败,失败输入会写入 testdata/fuzz/...,之后普通 go test 也会跑到。

结合场景再看三个关注点

  1. 结构:FuzzXxx + f.Fuzz 闭包
    展示名目标的参数类型须在 fuzz 支持集合内(如 string[]byte)。

  2. CI 与探索分工
    默认 go test 回归 corpus;-fuzz 才持续变异吃 CPU。

  3. 失败即资产
    撞到的怪名字会落盘,避免「修过一次又丢」的国际化边角。

核心概念与准确模型

结构

种子 f.Add / testdata 语料

   变异引擎(实现细节)

  目标 f.Fuzz 闭包

  失败 → 最小化/保存 corpus
概念含义
Seed corpus你提供的初始有趣输入
Generated corpus引擎发现的新覆盖/有趣输入
Crashers导致失败的输入(会落盘)
-fuzztime模糊持续时间或次数上限

与普通 Test 的关系

  • -fuzz:fuzz 目标仍执行种子与已保存 corpus——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/[]byte 模糊时,要清楚函数语义是按字节还是按 rune。错误的 Reverse 按字节反转 UTF-8 会破坏往返——fuzz 很爱抓这个。

2. 无限循环

可能把机器跑挂。需要:

  • 输入长度上限
  • 复杂度护栏
  • -fuzztime

3. 非确定性

若目标用到全局随机/时间,先注入或在 fuzz 中固定,否则“失败”不可复现。

4. 语料库膨胀

长期 fuzz 可能堆积大量输入。定期审视 testdata/fuzz,保留有价值样本。

5. 多参数

f.Fuzz 可多参数;f.Add 参数类型与顺序必须匹配。

常见误区

[!warning] 常见误区:没有不变量,只“调用一下” 错误:fuzz 里只 Parse(in) 不检查。
正确:至少保证不 panic,并在成功路径验证结构。

[!warning] 常见误区:CI 开超长 -fuzz 错误:每个 PR fuzz 10 分钟。
正确:CI 跑种子+corpus;探索放夜间/本地。

[!warning] 常见误区:把失败输入删了当解决 错误:删 testdata 让测试绿。
正确:修代码,保留样本防回归。

[!warning] 常见误区:模糊替代表驱动 错误:不再写明确边界用例。
正确:表驱动固化规格;fuzz 挖未知。

工程实践

  1. 先表驱动后 fuzz:已知例子进表或 f.Add
  2. 解析器/编解码优先接入 fuzz。
  3. 限制工作量:拷贝输入时注意长度;避免对巨型分配无上限。
  4. 与 race 组合谨慎go test -race -fuzz=... 更慢,可阶段性用。
  5. 文档化不变量:在测试注释写清“维护什么性质”。
  6. Corpus 进版本库:让团队共享回归资产。
go test -fuzz=FuzzReverse -fuzztime=1m
go test -run=FuzzReverse   # 仅 corpus/种子,不持续变异

可验证实验

实验 1:往返

Reverse 跑 30s fuzz,确认无失败。

实验 2:故意 mag 实现

按字节反转 UTF-8 字符串,看 fuzz 是否快速找到坏例并落盘。

实验 3:CI 模式

不传 -fuzz,确认仍执行 f.Add 种子。

实验 4:JSON round-trip

json.Marshal/Unmarshal 对结构体字段集合做不变量(注意 NaN 等边界,按类型选择)。

本节总结

  • 本质:自动输入生成 + 不变量检查 + 失败语料沉淀。
  • 工具链:与 go test 统一;CI 回归 / 本地探索分工。
  • 最易错:无不变量、删 corpus、把探索放进每次 PR。
  • 下一步:对发现的样本补表驱动可读用例;热点再 bench

自测题

概念题

  1. 不带 -fuzz 时 fuzz 测试做什么?
  2. 种子语料的作用?
  3. 什么样的断言适合放进 f.Fuzz

代码推理题

f.Fuzz(func(t *testing.T, b []byte) {
	_ = Parse(string(b))
})

弱点是什么?如何加强?

工程思考题

模糊 2 小时找到的 crasher 只在某同事机器上有。如何保证全员回归?

参考答案

展开
  1. 执行种子与已保存 corpus,不作持续变异。
  2. 引导早期覆盖有意义结构,加快发现。
  3. 不变量、往返、成功则合法、明确禁止 panic 的路径。
    代码题:无断言,只能靠 panic;应检查 err 与成功后的结构/往返。
    工程题:把失败输入提交到 testdata/fuzz,CI go 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 分工)
创建于 2026/6/25 更新于 2026/7/15