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

用 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校验、规范规则、排序去重标签与规范字符串的静态结构关系
图1:字符串规范化契约的静态结构图;三个分组展示输入、规则与输出对象之间的依赖,不代表代码执行步骤。

实现把无效 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种子语料、Fuzz目标、四条性质与失败回归语料的静态关系
图2:Go 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 和排序去重四条性质组合起来,既覆盖表示层,也覆盖业务规则,发现反例后还能自然沉淀成回归测试。

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