大家好,我是极客老墨。
之前写过一篇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帮你生成种子语料库和测试属性
你在模糊测试中踩过哪些坑呢?欢迎评论区留言!
极客老墨,继续折腾!