Go 模糊测试基础流程:从一个字符串解析函数开始

本文用一个 key=value 解析函数讲解 Go 模糊测试的种子用例、运行命令、失败输入和回归测试整理方式。

模糊测试适合找你没想到的输入

普通测试需要我们自己写输入和预期。表驱动测试已经很强,但它仍然依赖人的想象力。解析函数、路径处理、编码转换、协议拆包这类代码,输入空间很大,你很难手写覆盖所有奇怪情况。模糊测试的思路是:你给工具一些种子样本和一个必须保持的性质,让工具不断生成新输入,尝试触发 panic 或违反性质。

Go 1.18 把 fuzzing 放进了标准测试工具链。你不需要额外安装复杂框架,就可以用 go test -fuzz 开始探索边界。它不是替代单元测试,而是补充。已知规则仍然用普通测试写清楚,未知边界交给 fuzz 帮你找。

这篇文章用一个小解析函数演示完整流程。

一个 key=value 解析器

func ParsePair(input string) (string, string, error) {
	parts := strings.Split(input, "=")
	if len(parts) != 2 {
		return "", "", fmt.Errorf("invalid pair")
	}
	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("ParsePair() error = %v", err)
	}
	if key != "name" || value != "go" {
		t.Fatalf("got %q=%q", key, value)
	}
}

这个函数看起来没问题,但如果输入是 token=a=bstrings.Split 会切出三段。也许这正是业务规则,也许你想保留 value 里的 =。模糊测试可以帮助你发现类似边界。

写 Fuzz 函数

func FuzzParsePair(f *testing.F) {
	f.Add("name=go")
	f.Add("empty=")
	f.Add("token=a=b")

	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)
		}
	})
}

f.Add 添加种子输入。f.Fuzz 里的函数会被测试工具反复调用。这里我们定义了一个性质:如果解析成功,把 key 和 value 拼回去,应该等于原始输入。

运行:

go test -fuzz=FuzzParsePair ./...

限制时间:

go test -fuzz=FuzzParsePair -fuzztime=30s ./...

本地探索时可以跑几十秒或几分钟。CI 里是否跑 fuzz,要看项目规模和时间预算。

修复解析逻辑

如果业务希望只按第一个等号切分,可以改成:

func ParsePair(input string) (string, string, error) {
	index := strings.Index(input, "=")
	if index < 0 {
		return "", "", fmt.Errorf("invalid pair")
	}

	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) {
	key, value, err := ParsePair("token=a=b")
	if err != nil {
		t.Fatalf("ParsePair() error = %v", err)
	}
	if key != "token" || value != "a=b" {
		t.Fatalf("got %q=%q", key, value)
	}
}

这是使用 fuzz 的重要习惯:工具帮你发现输入,修复后把它变成稳定回归测试。

Fuzz 函数要保持纯粹

模糊测试会高频调用目标函数,所以不要在 fuzz 里做这些事:

  • 访问真实数据库
  • 调用外部 HTTP 服务
  • 写生产文件
  • 打大量日志
  • 依赖当前时间产生不稳定结果

适合 fuzz 的函数应该尽量像纯函数:输入数据,返回结果或错误。解析器、编码器、路径清理、表达式解析、简单协议处理都很适合。

如果函数可能非常慢,也要谨慎。fuzz 会尝试很多输入,目标函数越慢,探索效率越低。

失败输入要进入长期测试

当 fuzz 找到失败输入后,最重要的不是“这次修好了”,而是让这个输入以后一直被测试覆盖。比如工具发现输入 "=value" 会触发你没处理好的边界,就应该补一条普通测试:

func TestParsePairEmptyKey(t *testing.T) {
	_, _, err := ParsePair("=value")
	if err == nil {
		t.Fatal("expected error")
	}
}

如果发现某个 Unicode 字符、超长字符串或特殊分隔符导致 panic,也同样整理成普通测试。普通测试运行快、结果稳定,适合长期留在 CI。fuzz 更像探索过程,不一定每次提交都跑很久。

这也是入门阶段使用 fuzz 的正确心态:它不是神秘工具,而是帮你发现测试样本。最终让项目稳定的,仍然是清楚的函数边界和持续运行的测试集。

小结

Go 模糊测试的基本流程是:写一个 FuzzXxx 函数,添加种子用例,定义必须保持的性质,运行 go test -fuzz=...,发现失败后修复,并把失败输入整理成普通测试。

它最适合边界复杂、输入空间大的函数。不要把它当成所有测试的替代品,也不要让它依赖外部系统。普通测试写规则,模糊测试找盲点,两者配合起来,解析类代码会稳很多。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页