大家好,我是极客老墨。

之前写过一篇Go 测试:写得爽,跑得快,聊了单元测试、表格驱动测试和基准测试。很多朋友留言问:“老墨,单元测试我会写了,但总感觉测不全,怎么办?”

这就是今天要聊的:模糊测试(Fuzzing)

写代码爽,写测试累。更累的是,你辛辛苦苦写了一堆单元测试,覆盖率看起来挺高,结果上线还是出Bug。为啥?因为你只测了你能想到的场景,没测你想不到的

Go 1.18 开始,官方直接把模糊测试内置了。它会自动生成海量随机输入,帮你找到那些"想不到的 Bug"。不需要装第三方库,go test 就能跑。

什么是模糊测试?一句话说清楚

模糊测试就是让程序自己生成各种奇葩输入,疯狂测试你的代码,直到找出Bug为止。

传统单元测试:你说测什么就测什么。 模糊测试:程序自己想办法搞你,直到搞出问题。

从Go 1.18开始,Go官方在标准库里内置了Fuzzing支持。不需要装第三方工具,开箱即用。

为什么要用模糊测试?

老墨先给你看个真实案例。

假设你写了个URL解析函数,单元测试写了10个case,都通过了。结果上线后,用户输入了一个带emoji的URL,程序直接panic。

为什么?因为你的测试用例里没有emoji。

单元测试的局限性:

  • 只能测你想到的场景
  • 边缘情况容易遗漏
  • 维护成本高(每个case都要手写)

模糊测试的优势:

  • 自动生成海量测试数据
  • 能发现你想不到的边缘情况
  • 特别擅长找安全漏洞(SQL注入、缓冲区溢出等)

实战案例:一个看似简单的字符串反转函数

我们来写个最简单的函数:反转字符串。

1func Reverse(s string) string {
2    b := []byte(s)
3    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
4        b[i], b[j] = b[j], b[i]
5    }
6    return string(b)
7}

看起来没问题吧?我们写个单元测试:

 1func TestReverse(t *testing.T) {
 2    testcases := []struct {
 3        in, want string
 4    }{
 5        {"Hello, world", "dlrow ,olleH"},
 6        {" ", " "},
 7        {"!12345", "54321!"},
 8    }
 9    for _, tc := range testcases {
10        rev := Reverse(tc.in)
11        if rev != tc.want {
12            t.Errorf("Reverse: %q, want %q", rev, tc.want)
13        }
14    }
15}

运行测试:

1$ go test
2PASS
3ok      example/fuzz  0.013s

完美!可以上线了吧?

别急,让模糊测试来搞搞事情。

第一次模糊测试:发现UTF-8问题

我们把单元测试改成模糊测试:

 1func FuzzReverse(f *testing.F) {
 2    // 提供种子语料库
 3    testcases := []string{"Hello, world", " ", "!12345"}
 4    for _, tc := range testcases {
 5        f.Add(tc)
 6    }
 7    
 8    // 模糊测试的核心逻辑
 9    f.Fuzz(func(t *testing.T, orig string) {
10        rev := Reverse(orig)
11        doubleRev := Reverse(rev)
12        
13        // 反转两次应该得到原字符串
14        if orig != doubleRev {
15            t.Errorf("Before: %q, after: %q", orig, doubleRev)
16        }
17        
18        // 如果原字符串是有效UTF-8,反转后也应该是
19        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
20            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
21        }
22    })
23}

关键区别:

  • 函数名从TestXxx变成FuzzXxx
  • 参数从*testing.T变成*testing.F
  • 调用f.Fuzz()而不是t.Run()
  • 模糊测试会自动生成各种输入

运行模糊测试:

 1$ go test -fuzz=Fuzz
 2fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
 3fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
 4fuzz: minimizing 38-byte failing input file...
 5--- FAIL: FuzzReverse (0.01s)
 6    --- FAIL: FuzzReverse (0.00s)
 7        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"
 8
 9    Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
10    To re-run:
11    go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
12FAIL

炸了! 模糊测试找到了一个会导致失败的输入。

打开生成的语料库文件看看:

1go test fuzz v1
2string("泃")

原来是中文字符!我们的函数按字节反转,但中文字符是多字节的UTF-8编码,按字节反转会导致乱码。

修复第一个Bug:按rune反转

问题找到了,修复很简单:

1func Reverse(s string) string {
2    r := []rune(s)  // 转成rune切片,而不是byte
3    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
4        r[i], r[j] = r[j], r[i]
5    }
6    return string(r)
7}

再测试:

1$ go test
2PASS
3ok      example/fuzz  0.016s

好了,这次应该没问题了吧?

继续模糊测试:

1$ go test -fuzz=Fuzz
2fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed
3fuzz: minimizing 506-byte failing input file...
4--- FAIL: FuzzReverse (0.02s)
5    --- FAIL: FuzzReverse (0.00s)
6        reverse_test.go:33: Before: "\x91", after: "�"
7
8    Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
9FAIL

又炸了! 这次是无效的UTF-8字节序列。

修复第二个Bug:处理无效UTF-8

当输入本身就不是有效的UTF-8时,转成[]rune会被替换成(替换字符),导致反转两次后不等于原字符串。

最终修复版本:

 1func Reverse(s string) (string, error) {
 2    // 先检查输入是否有效
 3    if !utf8.ValidString(s) {
 4        return s, errors.New("input is not valid UTF-8")
 5    }
 6    
 7    r := []rune(s)
 8    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
 9        r[i], r[j] = r[j], r[i]
10    }
11    return string(r), nil
12}

测试代码也要相应修改:

 1f.Fuzz(func(t *testing.T, orig string) {
 2    rev, err1 := Reverse(orig)
 3    if err1 != nil {
 4        return  // 跳过无效输入
 5    }
 6    doubleRev, err2 := Reverse(rev)
 7    if err2 != nil {
 8        return
 9    }
10    
11    if orig != doubleRev {
12        t.Errorf("Before: %q, after: %q", orig, doubleRev)
13    }
14    if utf8.ValidString(orig) && !utf8.ValidString(rev) {
15        t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
16    }
17})

再次运行模糊测试,指定运行30秒:

1$ go test -fuzz=Fuzz -fuzztime 30s
2fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
3fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers
4fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)
5fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)
6...
7fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)
8PASS
9ok      example/fuzz  31.025s

通过了! 30秒内执行了117万次测试,没有发现问题。

模糊测试的核心概念

1. 种子语料库(Seed Corpus)

通过f.Add()提供的初始测试数据,模糊引擎会基于这些数据自动成变异的数据集来提高测试率覆盖。

1f.Add("Hello")
2f.Add("世界")
3f.Add("")

2. 生成的语料库(Generated Corpus)

模糊测试过程中自动生成并保存在testdata/fuzz/目录下的测试数据。

这些数据会在后续的go test中自动使用,作为回归测试。

3. 覆盖率引导(Coverage-Guided)

模糊引擎会智能地生成能触发新代码路径的输入,而不是盲目随机。

这就是为什么你会看到"new interesting"这个指标。

4. 最小化(Minimization)

当发现失败的输入时,模糊引擎会尝试将其简化为最小的可复现版本。

比如把一个1000字节的输入简化成10字节,但依然能触发Bug。

实战技巧:让AI帮你写模糊测试

这是老墨的独家秘籍。

技巧1:让AI生成种子语料库

你:“我有一个解析URL的函数,帮我生成20个边缘情况的测试URL”

AI会给你:

 1testcases := []string{
 2    "http://example.com",
 3    "https://example.com:8080/path?query=value",
 4    "ftp://user:pass@host.com",
 5    "http://[::1]:8080",
 6    "http://example.com/path with spaces",
 7    "http://example.com/emoji😀",
 8    "http://example.com/中文路径",
 9    // ... 更多边缘情况
10}

技巧2:让AI设计测试属性

你:“我的函数是JSON序列化,模糊测试应该检查哪些属性?”

AI会告诉你:

  • 序列化后再反序列化应该得到原对象
  • 输出应该是有效的JSON
  • 不应该panic
  • 特殊字符应该正确转义

技巧3:让AI分析失败的输入

当模糊测试失败时,把失败的输入和错误信息发给AI:

你:“模糊测试失败了,输入是\x91,错误是’invalid UTF-8’,为什么?”

AI会解释:

  • \x91是一个无效的UTF-8字节
  • 你的函数没有处理无效输入
  • 建议添加输入验证

技巧4:让AI优化模糊测试性能

你:“我的模糊测试很慢,每秒只能执行1000次,怎么优化?”

AI会建议:

  • 减少不必要的内存分配
  • 避免在模糊目标中使用全局状态
  • 简化测试逻辑
  • 使用-parallel参数增加并发

常用命令速查

 1# 运行模糊测试(会一直运行直到发现问题或手动停止)
 2go test -fuzz=FuzzXxx
 3
 4# 运行30秒后自动停止
 5go test -fuzz=FuzzXxx -fuzztime 30s
 6
 7# 运行100万次迭代后停止
 8go test -fuzz=FuzzXxx -fuzztime 1000000x
 9
10# 只运行特定的失败用例(用于调试)
11go test -run=FuzzXxx/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
12
13# 增加并发worker数量
14go test -fuzz=FuzzXxx -parallel 16
15
16# 禁用最小化(加快测试速度)
17go test -fuzz=FuzzXxx -fuzzminimizetime 0

模糊测试的最佳实践

1. 模糊目标要快

模糊测试会执行成千上万次,如果每次都很慢,效率会很低。

不好的例子:

1f.Fuzz(func(t *testing.T, input string) {
2    // 每次都连接数据库,太慢了
3    db, _ := sql.Open("mysql", "...")
4    defer db.Close()
5    // ...
6})

好的例子:

1// 在外面初始化一次
2db, _ := sql.Open("mysql", "...")
3defer db.Close()
4
5f.Fuzz(func(t *testing.T, input string) {
6    // 直接使用
7    db.Query(input)
8})

2. 避免依赖全局状态

模糊测试是并发执行的,如果依赖全局状态会导致不确定的结果。

不好的例子:

1var counter int
2
3f.Fuzz(func(t *testing.T, input string) {
4    counter++  // 并发不安全
5    // ...
6})

3. 测试属性而不是具体值

模糊测试的输入是随机的,你无法预测具体的输出。

不好的例子:

1f.Fuzz(func(t *testing.T, input string) {
2    output := MyFunc(input)
3    if output != "expected" {  // 你不知道input是什么,怎么知道expected是什么?
4        t.Error("wrong output")
5    }
6})

好的例子:

1f.Fuzz(func(t *testing.T, input string) {
2    output := Encode(input)
3    decoded := Decode(output)
4    if input != decoded {  // 测试编码解码的可逆性
5        t.Error("encode/decode not reversible")
6    }
7})

4. 合理使用种子语料库

提供一些典型的和边缘的输入作为种子,帮助模糊引擎更快找到问题。

1f.Add("")                    // 空字符串
2f.Add("a")                   // 单字符
3f.Add("Hello, 世界")         // 多语言
4f.Add(strings.Repeat("a", 10000))  // 超长输入

真实案例:老墨的踩坑经历

去年老墨写了个JSON解析器,单元测试覆盖率95%,信心满满上线。

结果第二天就收到告警:服务OOM了。

排查后发现,有个用户上传了一个嵌套层级超过1000层的JSON,我的递归解析器直接栈溢出。

单元测试为什么没发现? 因为我的测试用例最多嵌套10层。

后来加了模糊测试,5分钟就发现了这个问题:

 1func FuzzParseJSON(f *testing.F) {
 2    f.Add(`{"a":1}`)
 3    f.Add(`[1,2,3]`)
 4    
 5    f.Fuzz(func(t *testing.T, input string) {
 6        // 设置最大嵌套深度
 7        _, err := ParseJSON(input, MaxDepth(100))
 8        // 不应该panic
 9        // 如果输入是有效JSON,应该能解析
10    })
11}

模糊测试生成了各种奇葩的嵌套结构,很快就触发了栈溢出。

修复后,再也没出过这个问题。

什么时候用模糊测试?

适合模糊测试的场景:

  • 解析器(JSON、XML、URL等)
  • 编码/解码函数
  • 加密/解密函数
  • 字符串处理函数
  • 网络协议处理
  • 文件格式处理

不适合模糊测试的场景:

  • 需要特定输入顺序的业务逻辑
  • 依赖外部服务的代码
  • 需要复杂初始化的代码
  • 执行很慢的代码

写在最后

模糊测试不是银弹,但它是单元测试的完美补充。

单元测试: 测你想到的场景 模糊测试: 测你没想到的场景

两者结合,才能让你的代码更健壮。

而且,Go 1.18+的原生Fuzzing真的很好用,不需要装任何第三方工具,开箱即用。

配合AI工具,写模糊测试的效率还能再提升10倍。

老墨的建议:

  • 对于核心的、暴露给外部的函数,一定要写模糊测试
  • 在CI/CD中定期运行模糊测试(比如每晚跑1小时)
  • 把发现的失败用例加入回归测试
  • 用AI帮你生成种子语料库和测试属性

你在模糊测试中踩过哪些坑呢?欢迎评论区留言!

极客老墨,继续折腾!


参考资料


相关阅读