登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go 模糊测试怎样组织种子语料

来源:17golang原创

时间:2026-10-14 20:07:59 265浏览 收藏

Go 的模糊测试不只是让引擎“随机生成一堆输入”。真正能长期维护的 Fuzz 测试,通常需要一小组容易读懂的初始样本,再配合能够独立保存的语料文件。前者适合放在 f.Add 中,后者适合放在 testdata/fuzz/ 目录里。

这两类输入都会成为种子语料,并且在普通 go test 中执行。本文只讨论这两个入口:怎样选择放置位置、怎样让类型对齐,以及失败样本进入目录后如何继续维护。

官方资料:https://go.dev/doc/security/fuzz/

先把种子语料分成两类

可以先用一个简单判断:能用一两行 Go 代码表达、需要让读者一眼看懂的输入,放进 f.Add;数量较多、内容较长、由失败用例沉淀出来,或者希望独立于测试代码版本管理的输入,放进语料目录。

例如,解析器的空输入、最短合法输入和一个带边界符号的输入,很适合作为代码内种子。一个几十行的二进制报文或曾经触发过问题的特殊字符串,则更适合成为目录文件。两者不是二选一:同一个 Fuzz 测试可以同时拥有它们。

Go Fuzz 的 f.Add 与 testdata/fuzz 目录共同组成种子语料的静态结构图
图1:Go Fuzz 种子来源说明图,展示 f.Add 与 testdata/fuzz 目录如何共同组成种子语料。这是静态说明图,不是运行截图。

最小配方:用 f.Add 放入代表性样本

f.Add 要放在 f.Fuzz 之前。传入的参数数量、类型和顺序,必须分别对应 Fuzz 目标函数的参数。下面的目标函数接收一个 []byte 和一个 string,因此每次 f.Add 也必须按这个顺序提供二者。

package decoder

import "testing"

func FuzzDecode(f *testing.F) {
	// 这些样本覆盖空报文、最短头部和带分隔符的常见输入。
	f.Add([]byte{}, "plain")
	f.Add([]byte{0x01, 0x02, 0x03}, "compact")
	f.Add([]byte{0xff, 0x00, 0x7f}, "edge")

	f.Fuzz(func(t *testing.T, payload []byte, mode string) {
		// 目标函数只接收与 f.Add 相同顺序的 []byte 和 string。
		if err := decode(payload, mode); err != nil {
			t.Skipf("当前输入不满足协议前置条件: %v", err)
		}
	})
}

这段写法的重点不在样本数量,而在样本是否能表达输入边界。把所有随机想到的字符串都塞进 f.Add,会让测试代码变得难读,也不利于判断哪些样本是故意保留的。

关键 Go 规则:类型和顺序必须完全对齐

Go 会根据 f.Fuzz 目标函数推断语料字段类型。上例的字段顺序是 []byte、string,下面这些写法都不应当作为“先写进去再说”的临时方案:

func FuzzDecode(f *testing.F) {
	// 错误示例一:顺序反了,目录文件中的第一项也必须跟着反转。
	// f.Add("compact", []byte{0x01})

	// 错误示例二:不能用 int 代替目标函数声明的 string。
	// f.Add([]byte{0x01}, 1)

	f.Fuzz(func(t *testing.T, payload []byte, mode string) {
		// 这里的两个参数决定所有种子语料的字段契约。
		_ = payload
		_ = mode
	})
}

支持的种子字段是有限的内置类型集合,例如 string、[]byte、整数、浮点数和 bool。复杂结构体不能直接作为 Fuzz 参数;如果输入是结构化报文,可以先把它编码成字节切片,再在目标函数里解析。

目录配方:按 Fuzz 测试名保存独立语料

目录语料的路径由两部分组成:包目录下的 testdata/fuzz,再加上 Fuzz 函数名。例如函数叫 FuzzDecode,目录就是:

decoder/
├── decode.go
├── decode_fuzz_test.go
└── testdata/
    └── fuzz/
        └── FuzzDecode/
            ├── seed-header
            └── seed-empty-mode

文件名可以帮助团队识别样本来源,但真正决定它是否属于哪个 Fuzz 测试的是父目录名。不要把不同 Fuzz 函数的文件混在同一个目录中,否则维护者很难判断字段契约,也可能在加载时遇到类型不匹配。

目录中的文本文件使用 Go Fuzz 的语料格式。每个文件以版本头开始,后面的每一行对应一个 Fuzz 参数:

go test fuzz v1
[]byte("\x01\x02\x03")
string("compact")

这里的 []byte 和 string 不只是说明文字,而是编码格式中的值声明。它们必须和 f.Fuzz 的参数顺序一致。大型二进制输入不适合手写进 Go 源文件时,可以使用 Go 官方文档提到的 file2fuzz 工具把文件转换为语料格式。

失败输入怎样变成回归语料

执行 Fuzz 测试时,如果引擎找到会触发失败或崩溃的输入,它会把该输入写入对应的种子语料目录。修复问题后,这个文件仍然保留,就能让后续普通 go test 继续覆盖这个曾经出错的边界。

这也是目录语料和 f.Add 的重要分工:f.Add 主要表达开发者主动选择的代表样本,目录则更适合承载较长的回归样本。收到一个失败文件时,先确认它属于正确的 FuzzTestName,再决定是否为它补一个能说明业务含义的文件名。

Go Fuzz 失败输入进入 FuzzDecode 语料目录并作为回归测试的静态维护关系图
图2:种子语料维护边界说明图,强调失败样本进入对应 Fuzz 目录后会参与普通 go test。这是静态说明图,不是执行证据。

变体:什么时候只用 Add,什么时候增加目录

如果 Fuzz 目标只有一两个简单字段,而且初始样本短小、数量稳定,只用 f.Add 就够了。它让读者不用打开额外文件就能理解测试从哪些输入开始。

如果出现下面任一情况,就可以增加目录语料:

  • 输入是较长的二进制数据或包含大量转义字符。
  • 失败输入需要作为回归样本长期保留。
  • 测试维护者希望只增删样本文件,不频繁修改测试函数。
  • 一组样本需要和代码分开审查、分类或由不同成员维护。

两种来源会一起进入种子集合,所以不要为了把目录样本“搬到代码里”而复制一份。重复输入不会带来额外覆盖,反而增加维护成本。

兼容坑:目录名、字段和普通 go test

最常见的坑有三个。第一,目录名必须对应完整的 Fuzz 函数名,大小写也要保持一致。第二,目录文件里的值必须与目标函数的类型和顺序相同。第三,种子语料并不只在 -fuzz 模式中使用,普通 go test 默认也会执行这些种子。

因此可以把日常检查分成两层:日常测试先用普通命令快速跑种子,专项探索时再指定 Fuzz 函数并设置运行时长。命令本身只是操作示例,实际时长应按项目的 CI 预算决定:

# 先执行全部种子,尽早发现目录格式或回归样本问题。
go test ./...

# 再针对指定 Fuzz 测试探索新输入,并限制本次运行时长。
go test -run=^$ -fuzz=FuzzDecode -fuzztime=30s ./...

如果普通测试在新增目录文件后突然失败,优先检查语料文件头、字段顺序、字段类型以及目标函数是否改过参数。不要先删除失败文件来“让测试变绿”,因为它可能正是需要保留的回归边界。

完整片段:代码种子和目录语料一起工作

下面的骨架把两种来源放在同一个 Fuzz 测试中。代码内只保留最小代表样本,复杂或失败后的输入留在 testdata/fuzz/FuzzDecode,这样测试函数保持清晰,目录又能承接真实回归数据。

package decoder

import (
	"testing"
)

func FuzzDecode(f *testing.F) {
	// 用短样本说明协议的基本边界,便于代码审查时快速理解。
	f.Add([]byte{}, "plain")
	f.Add([]byte{0x01, 0x02}, "compact")

	f.Fuzz(func(t *testing.T, payload []byte, mode string) {
		// 真实目录语料也会以相同的 []byte、string 契约进入这里。
		result, err := decode(payload, mode)
		if err != nil {
			// 非法协议输入不是本例要断言的失败,交给 Fuzz 继续换样本。
			t.Skipf("跳过非法协议输入: %v", err)
		}
		if result == nil {
			t.Fatal("解码成功后不应返回 nil")
		}
	})
}

配套的目录语料可以像这样组织:

testdata/fuzz/FuzzDecode/
├── seed-header
├── seed-binary-regression
└── seed-empty-mode

最终的维护原则很简单:短而有代表性的样本写进 f.Add,长的、失败后沉淀的、需要独立审查的样本放进对应目录;所有输入都遵守同一个参数契约。

相关问题

f.Add 必须放在 f.Fuzz 前面吗?

是。f.Add 用来注册种子,在进入 Fuzz 目标后再调用没有意义,也不应当把它当成目标函数内的动态数据注入方式。

目录语料会不会只在模糊测试时运行?

不会。种子语料默认会随普通 go test 执行;使用 -fuzz 时,它们还会作为引导随机输入生成的起点。

失败样本可以直接改成 f.Add 吗?

短小且需要直接表达测试意图的样本可以迁移;较长或带有回归背景的输入更适合保留为目录文件,并在代码审查记录中说明它对应的问题。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>