← [ BACK_TO_NOTES ]

Go 模糊测试

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

#concept #growing #dev #go

[!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 掩盖缺陷,必须改用保字节的实现或明确返回错误。

结合场景再看三个关注点

  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 目标执行 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 挖未知。

工程实践

  1. 先表驱动后 fuzz:已知例子进表或 f.Add。
  2. 解析器/编解码优先接入 fuzz。
  3. 限制工作量:拷贝输入时注意长度;避免对巨型分配无上限。
  4. 与 race 组合谨慎:go test -race -fuzz=... 更慢,可阶段性用。
  5. 文档化不变量:在测试注释写清“维护什么性质”。
  6. 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。

自测题

概念题

  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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本