用 Fuzz 测试字符串规范化函数的往返性质
来源:17golang原创
时间:2026-10-07 11:15:24 226浏览 收藏
测试字符串规范化函数,最有效的 Fuzz 写法不是为随机输入猜一个完整期望值,而是检查任何输入都必须满足的性质。本文把逗号分隔标签规范化为“小写、去空项、折叠空白、排序、去重”的规范字符串,然后验证四件事:解码再编码保持不变、再次规范化保持不变、有效输入不会变成无效 UTF-8、标签始终有序且无重复。
固定样例仍然保留,因为它们负责解释业务规则;Fuzz 负责在这些规则之外持续变异输入。Go 从 1.18 起在标准工具链中支持覆盖率引导的模糊测试,失败输入会写入 testdata/fuzz/测试名,修复后还能作为普通 go test 的回归语料。
官方文档:https://go.dev/doc/security/fuzz/
先用六个样例建立可读基线
先定义输入格式:逗号是标签分隔符;标签首尾空白会移除,内部连续空白会折叠成一个空格;标签转为小写;空标签丢弃;最终按字典序排序并去重。这个格式不支持“标签本身包含逗号”,若业务需要该能力,应改用 JSON 等有转义规则的格式。
基线测试只准备六种代表性输入:空字符串、普通标签、大小写重复、连续分隔符、Unicode 标签、制表符和换行。数量不追求大,重点是让读者一眼看懂规范。
package taglist
import "testing"
func TestNormalize(t *testing.T) {
// 六个固定样例负责说明业务规则,不替代随机输入探索
tests := []struct {
name string
raw string
want string
}{
{"空输入", "", ""},
{"普通标签", "Go,Redis", "go,redis"},
{"大小写重复", "Go, go, GO", "go"},
{"空项", ",api,,test,", "api,test"},
{"Unicode", " 数据库 ,Go", "go,数据库"},
{"多种空白", "web\tapi, web\napi", "web api"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Normalize(tt.raw)
if err != nil {
t.Fatalf("Normalize(%q) 返回错误: %v", tt.raw, err)
}
if got != tt.want {
t.Fatalf("Normalize(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
表驱动测试的指标很明确:6 个输入、6 个确定输出。它适合审查需求,但无法证明逗号、控制字符、长字符串、无效字节和 Unicode 的任意组合都安全。Fuzz 的假设就是:不用继续手写几百个例子,只要把稳定性质写准确,让工具寻找能破坏性质的输入。
先把规范化契约写成可观察结构

实现把无效 UTF-8 当作显式错误。这样 Fuzz 产生任意字节字符串时,调用方可以区分“输入不合法”和“规范化结果为空”。对有效输入,strings.Fields 负责折叠 Unicode 空白,strings.ToLower 负责小写转换,最后排序并去重。
package taglist
import (
"errors"
"sort"
"strings"
"unicode/utf8"
)
var ErrInvalidUTF8 = errors.New("tag list is not valid UTF-8")
// Normalize 把逗号分隔标签转换为稳定的规范字符串
func Normalize(raw string) (string, error) {
if !utf8.ValidString(raw) {
return "", ErrInvalidUTF8
}
seen := make(map[string]struct{})
tags := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
// Fields 折叠连续空白,Join 把内部空白统一为一个空格
tag := strings.ToLower(strings.Join(strings.Fields(part), " "))
if tag == "" {
continue
}
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
tags = append(tags, tag)
}
// 排序让同一组标签总能得到相同的规范表示
sort.Strings(tags)
return strings.Join(tags, ","), nil
}
// Decode 只解析已经规范化的字符串,不再次修改标签
func Decode(canonical string) []string {
if canonical == "" {
return nil
}
return strings.Split(canonical, ",")
}
// Encode 把规范标签原样拼回字符串,用于验证往返性质
func Encode(tags []string) string {
return strings.Join(tags, ",")
}
这里把 Decode 和 Encode 保持得很薄,是为了让往返性质有实际意义。如果 Encode 内部再次调用 Normalize,错误可能被第二次规范化掩盖,测试就会变成“函数用自己证明自己”。
把四条性质放进一个 Fuzz 目标

Go 的 fuzz test 必须命名为 FuzzXxx,只接收一个 *testing.F。种子参数的类型和顺序必须与 fuzz target 一致。本文只变异一个 string,因此每个 f.Add 也只添加一个字符串。
package taglist
import (
"errors"
"sort"
"strings"
"testing"
"unicode/utf8"
)
func FuzzNormalize(f *testing.F) {
// 种子覆盖空值、重复、空项、Unicode 和混合空白
seeds := []string{
"",
"Go,Redis",
"Go, go, GO",
",api,,test,",
" 数据库 ,Go",
"web\tapi, web\napi",
}
for _, seed := range seeds {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, raw string) {
canonical, err := Normalize(raw)
// 任意 string 可能含无效字节,无效 UTF-8 必须返回约定错误
if !utf8.ValidString(raw) {
if !errors.Is(err, ErrInvalidUTF8) {
t.Fatalf("无效 UTF-8 未返回 ErrInvalidUTF8: %q", raw)
}
return
}
if err != nil {
t.Fatalf("有效 UTF-8 不应失败: %v", err)
}
// 性质一:规范字符串解码后再编码,内容必须保持不变
roundTrip := Encode(Decode(canonical))
if roundTrip != canonical {
t.Fatalf("往返不一致: canonical=%q roundTrip=%q", canonical, roundTrip)
}
// 性质二:规范化已经规范的字符串,结果必须幂等
again, err := Normalize(canonical)
if err != nil || again != canonical {
t.Fatalf("幂等性失败: first=%q second=%q err=%v", canonical, again, err)
}
// 性质三:有效输入产生的规范字符串仍必须是有效 UTF-8
if !utf8.ValidString(canonical) {
t.Fatalf("规范化产生无效 UTF-8: %q", canonical)
}
// 性质四:标签必须有序、非空、小写、无首尾空白且不重复
tags := Decode(canonical)
if !sort.StringsAreSorted(tags) {
t.Fatalf("标签未排序: %#v", tags)
}
for i, tag := range tags {
if tag == "" || strings.TrimSpace(tag) != tag || strings.ToLower(tag) != tag {
t.Fatalf("标签不满足规范: %q", tag)
}
if i > 0 && tag == tags[i-1] {
t.Fatalf("发现重复标签: %q", tag)
}
}
})
}
四条性质各自覆盖不同风险。往返检查规范表示能否被无损解析;幂等检查规范函数是否会继续改变自己的输出;UTF-8 检查避免按字节截断或错误替换;排序去重检查业务不变量。它们比单纯“不能 panic”更有约束力。
用预算指标代替虚构的性能结论
Fuzz 的每秒执行次数受 CPU、Go 版本、并行度和语料变化影响,不能把某台机器的一次数字写成通用性能结论。更可复现的做法是固定测试资产和预算,然后记录失败与否。
| 指标 | 本文设置 | 用途 |
|---|---|---|
| 固定样例 | 6 个 | 解释业务规则 |
| Fuzz 性质 | 4 条 | 限制任意输入的可接受结果 |
| Fuzz 目标 | 1 个 | 保持状态隔离和失败定位清晰 |
| 本地预算 | 30 秒 | 开发时快速探索 |
| CI 预算 | 10000 次 | 让单次任务有明确上限 |
| 失败资产 | testdata/fuzz | 固化最小失败输入并回归 |
# 先运行固定测试和种子语料,确认基础规则通过 go test ./... # 本地只启动目标 Fuzz 测试,限定探索时间为 30 秒 go test -run='^$' -fuzz='^FuzzNormalize$' -fuzztime=30s ./... # CI 可改用固定迭代次数,使任务预算更容易控制 go test -run='^$' -fuzz='^FuzzNormalize$' -fuzztime=10000x ./...
运行日志中的 execs 是本次执行数量,new interesting 表示扩展覆盖范围的语料。它们适合观察单次探索进展,但不是跨机器性能基准。真正的结果对比是:表驱动测试只判断 6 个已知输入,而 Fuzz 在同样四条契约上持续尝试变异输入;任何失败都会给出可重放的具体值。
失败输入要进入回归,而不是只修代码
当 fuzz target 失败时,Go 会尝试最小化输入,并把结果写入当前包的 testdata/fuzz/FuzzNormalize/。官方文档说明,这些语料之后会在普通 go test 中作为种子执行。修复流程应同时提交实现改动和失败语料,避免同类缺陷复发。
# 使用失败日志给出的完整子测试名重放单个语料 go test -run='^FuzzNormalize/失败语料哈希$' ./... # 修复后运行全部测试,testdata 中的失败语料会自动回归 go test ./... # 再给同一目标一个受限预算,继续寻找新的反例 go test -run='^$' -fuzz='^FuzzNormalize$' -fuzztime=30s ./...
不要手工改写 Go 生成的 corpus 文件。若失败来自合理但未定义的输入,例如标签允许不允许逗号,应先补充格式契约,再决定修改实现还是拒绝输入;不能为了让 Fuzz 变绿而直接跳过所有困难输入。
适用边界和常见误区
为什么不比较 Normalize(raw) 的完整期望值?
Fuzz 输入由工具生成,无法为每个值预先写出期望字符串。性质测试改为检查始终成立的不变量;少量固定样例仍负责校验具体业务输出。
可以在 fuzz target 里访问数据库吗?
不建议。官方建议 fuzz target 快速、确定,而且每次调用不保留状态。数据库、网络、全局缓存和当前时间会降低可复现性,也让并行 worker 互相干扰。把纯函数和状态ful集成层拆开,先 Fuzz 纯规范化逻辑。
通过 30 秒是否证明没有 Bug?
不能。预算内未失败只说明这次探索没有找到反例。持续集成可以保留固定迭代预算,夜间任务再给更长时间;重要的是性质稳定、失败可重放、语料持续积累。
strings.ToLower 等于完整 Unicode 规范化吗?
不等于。大小写转换不会把所有视觉等价的组合字符变成同一 Unicode 规范形式。若业务要求 NFC/NFKC 等规范化,需要引入明确的 Unicode 规范化库,并为新规则增加相应性质和种子。
落地清单
- 用固定样例解释规则,用 Fuzz 覆盖未知组合。
- 先写可证明的性质,再决定输入类型和种子。
- 让 target 快速、确定、无共享状态,并只注册一个
f.Fuzz。 - 对无效 UTF-8 明确返回错误,不让失败输入被静默改写。
- 本地按时间预算运行,CI 可按迭代次数控制上限。
- 保留
testdata/fuzz失败语料,让普通测试持续回归。
对字符串规范化这类输入空间很大的纯函数,Fuzz 的价值不在于随机跑得多,而在于把“什么永远不能变”写成可执行契约。往返、幂等、UTF-8 和排序去重四条性质组合起来,既覆盖表示层,也覆盖业务规则,发现反例后还能自然沉淀成回归测试。
-
151 收藏
-
101 收藏
-
323 收藏
-
428 收藏
-
143 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习