大家好,我是极客老墨。
单元测试只能测试你想到的情况。空字符串、负数、超长输入,这些边界情况很容易遗漏。模糊测试能自动生成大量随机输入,帮你发现那些没想到的 Bug。
这篇就聊聊 Go 的模糊测试,看看它是怎么帮我们提升代码质量的。
什么是模糊测试
模糊测试(Fuzzing)是一种自动化测试技术,通过生成随机输入来发现程序的异常。
核心思想
1// 传统单元测试:测试已知输入
2func TestAdd(t *testing.T) {
3 if Add(2, 3) != 5 {
4 t.Error("2 + 3 should be 5")
5 }
6 if Add(0, 0) != 0 {
7 t.Error("0 + 0 should be 0")
8 }
9}
10
11// 模糊测试:测试大量随机输入
12func FuzzAdd(f *testing.F) {
13 f.Fuzz(func(t *testing.T, a, b int) {
14 result := Add(a, b)
15 // 检查属性而不是具体值
16 if a > 0 && b > 0 && result <= 0 {
17 t.Errorf("Add(%d, %d) = %d, overflow?", a, b, result)
18 }
19 })
20}
区别:
- 单元测试:测试已知的输入和输出
- 模糊测试:测试未知的输入,检查程序属性
能发现什么问题
- 崩溃和 panic:空指针、数组越界
- 整数溢出:加法、乘法溢出
- 无限循环:特定输入导致死循环
- 内存泄漏:特定输入导致内存不释放
- 逻辑错误:边界条件处理不当
编写第一个 Fuzz 测试
从一个简单的例子开始。
被测函数
1// reverse.go
2package stringutil
3
4// Reverse 反转字符串
5func Reverse(s string) string {
6 b := []byte(s)
7 for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
8 b[i], b[j] = b[j], b[i]
9 }
10 return string(b)
11}
Fuzz 测试
1// reverse_test.go
2package stringutil
3
4import "testing"
5
6func FuzzReverse(f *testing.F) {
7 // 添加种子输入(可选)
8 f.Add("hello")
9 f.Add("世界")
10 f.Add("")
11
12 // Fuzz 函数
13 f.Fuzz(func(t *testing.T, s string) {
14 // 属性:反转两次应该等于原字符串
15 reversed := Reverse(s)
16 doubleReversed := Reverse(reversed)
17
18 if s != doubleReversed {
19 t.Errorf("Reverse(Reverse(%q)) = %q, want %q",
20 s, doubleReversed, s)
21 }
22 })
23}
要点:
- 函数名以
Fuzz开头 - 参数是
*testing.F - 使用
f.Add()添加种子输入 - 使用
f.Fuzz()定义测试逻辑
运行测试
1# 运行模糊测试
2go test -fuzz=FuzzReverse
3
4# 限制运行时间
5go test -fuzz=FuzzReverse -fuzztime=30s
6
7# 限制迭代次数
8go test -fuzz=FuzzReverse -fuzztime=10000x
9
10# 停止测试:Ctrl+C
发现问题
运行后可能会发现问题:
--- FAIL: FuzzReverse (0.03s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:15: Reverse(Reverse("🙂")) = "", want "🙂"
Failing input written to testdata/fuzz/FuzzReverse/abc123
问题:[]byte 不能正确处理多字节字符(如 emoji)。
修复代码
1// 修复:使用 rune 而不是 byte
2func Reverse(s string) string {
3 r := []rune(s)
4 for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
5 r[i], r[j] = r[j], r[i]
6 }
7 return string(r)
8}
支持的类型
Fuzz 函数的参数类型有限制。
基本类型
1func FuzzBasicTypes(f *testing.F) {
2 f.Add("hello", 42, true, 3.14)
3
4 f.Fuzz(func(t *testing.T, s string, n int, b bool, f float64) {
5 // 测试逻辑
6 })
7}
支持的类型:
string,[]byteint,int8,int16,int32,int64uint,uint8,uint16,uint32,uint64float32,float64bool
不支持的类型
1// ❌ 不支持结构体
2func FuzzStruct(f *testing.F) {
3 f.Fuzz(func(t *testing.T, user User) {
4 // 编译错误
5 })
6}
7
8// ✅ 可以拆分成基本类型
9func FuzzUser(f *testing.F) {
10 f.Fuzz(func(t *testing.T, name string, age int) {
11 user := User{Name: name, Age: age}
12 // 测试逻辑
13 })
14}
添加种子语料库
种子输入可以提高测试效率。
基本用法
1func FuzzParseURL(f *testing.F) {
2 // 添加典型输入
3 f.Add("http://example.com")
4 f.Add("https://google.com")
5
6 // 添加边界情况
7 f.Add("")
8 f.Add("invalid")
9 f.Add("http://")
10
11 // 添加特殊字符
12 f.Add("http://example.com/path?query=value#fragment")
13
14 f.Fuzz(func(t *testing.T, rawURL string) {
15 // 测试逻辑
16 })
17}
从文件加载
1func FuzzFromFile(f *testing.F) {
2 // 从文件读取种子
3 data, _ := os.ReadFile("testdata/seeds.txt")
4 lines := strings.Split(string(data), "\n")
5
6 for _, line := range lines {
7 f.Add(line)
8 }
9
10 f.Fuzz(func(t *testing.T, input string) {
11 // 测试逻辑
12 })
13}
种子语料库目录
testdata/
└── fuzz/
└── FuzzParseURL/
├── seed1
├── seed2
└── abc123 # 发现问题的输入
要点:
- 种子输入会被保存到
testdata/fuzz/目录 - 发现问题的输入会自动保存
- 下次运行会重新测试这些输入(回归测试)
检查属性而非具体值
模糊测试的核心是检查程序的属性。
不好的例子
1// ❌ 检查具体值(和单元测试没区别)
2func FuzzAdd(f *testing.F) {
3 f.Fuzz(func(t *testing.T, a, b int) {
4 if Add(a, b) != a+b {
5 t.Error("wrong")
6 }
7 })
8}
好的例子
1// ✅ 检查属性
2func FuzzAdd(f *testing.F) {
3 f.Fuzz(func(t *testing.T, a, b int) {
4 result := Add(a, b)
5
6 // 属性 1:交换律
7 if Add(a, b) != Add(b, a) {
8 t.Error("addition should be commutative")
9 }
10
11 // 属性 2:结合律
12 c := 10
13 if Add(Add(a, b), c) != Add(a, Add(b, c)) {
14 t.Error("addition should be associative")
15 }
16
17 // 属性 3:单位元
18 if Add(a, 0) != a {
19 t.Error("0 should be identity element")
20 }
21 })
22}
常见属性
1// 幂等性:多次调用结果相同
2func FuzzIdempotent(f *testing.F) {
3 f.Fuzz(func(t *testing.T, s string) {
4 r1 := Process(s)
5 r2 := Process(s)
6 if r1 != r2 {
7 t.Error("should be idempotent")
8 }
9 })
10}
11
12// 可逆性:编码后解码应该相等
13func FuzzReversible(f *testing.F) {
14 f.Fuzz(func(t *testing.T, data []byte) {
15 encoded := Encode(data)
16 decoded := Decode(encoded)
17 if !bytes.Equal(data, decoded) {
18 t.Error("should be reversible")
19 }
20 })
21}
22
23// 不变性:某些属性不应该改变
24func FuzzInvariant(f *testing.F) {
25 f.Fuzz(func(t *testing.T, s string) {
26 result := Transform(s)
27 if len(result) > len(s)*2 {
28 t.Error("result too long")
29 }
30 })
31}
实战示例
JSON 解析
1func FuzzJSONParse(f *testing.F) {
2 f.Add(`{"name":"Alice","age":30}`)
3 f.Add(`{"name":"Bob"}`)
4 f.Add(`[]`)
5 f.Add(`null`)
6
7 f.Fuzz(func(t *testing.T, data string) {
8 var result interface{}
9
10 // 不应该 panic
11 err := json.Unmarshal([]byte(data), &result)
12
13 // 如果解析成功,再次序列化应该不出错
14 if err == nil {
15 _, err := json.Marshal(result)
16 if err != nil {
17 t.Errorf("Failed to re-marshal: %v", err)
18 }
19 }
20 })
21}
URL 解析
1func FuzzParseURL(f *testing.F) {
2 f.Add("http://example.com")
3 f.Add("https://google.com/path?query=value")
4 f.Add("ftp://invalid.com")
5
6 f.Fuzz(func(t *testing.T, rawURL string) {
7 u, err := url.Parse(rawURL)
8
9 // 如果解析成功,String() 应该返回有效 URL
10 if err == nil {
11 s := u.String()
12 _, err := url.Parse(s)
13 if err != nil {
14 t.Errorf("Parse(%q).String() = %q, re-parse failed: %v",
15 rawURL, s, err)
16 }
17 }
18 })
19}
字符串处理
1func FuzzSanitize(f *testing.F) {
2 f.Add("hello world")
3 f.Add("<script>alert('xss')</script>")
4 f.Add("")
5
6 f.Fuzz(func(t *testing.T, input string) {
7 result := Sanitize(input)
8
9 // 属性 1:不应该包含危险字符
10 if strings.Contains(result, "<script>") {
11 t.Error("should remove script tags")
12 }
13
14 // 属性 2:不应该比输入更长
15 if len(result) > len(input) {
16 t.Error("sanitized string should not be longer")
17 }
18
19 // 属性 3:幂等性
20 if Sanitize(result) != result {
21 t.Error("should be idempotent")
22 }
23 })
24}
数学运算
1func FuzzDivide(f *testing.F) {
2 f.Add(10, 2)
3 f.Add(0, 1)
4 f.Add(-10, 3)
5
6 f.Fuzz(func(t *testing.T, a, b int) {
7 // 避免除零
8 if b == 0 {
9 return
10 }
11
12 result, err := Divide(a, b)
13 if err != nil {
14 t.Errorf("Divide(%d, %d) returned error: %v", a, b, err)
15 }
16
17 // 属性:结果乘以除数应该接近被除数
18 if result*b != a && result*b+1 != a && result*b-1 != a {
19 t.Errorf("Divide(%d, %d) = %d, but %d * %d != %d",
20 a, b, result, result, b, a)
21 }
22 })
23}
避免常见陷阱
避免 panic
1func FuzzSafe(f *testing.F) {
2 f.Fuzz(func(t *testing.T, index int, data []byte) {
3 // ❌ 可能 panic
4 // value := data[index]
5
6 // ✅ 检查边界
7 if index < 0 || index >= len(data) {
8 return
9 }
10 value := data[index]
11
12 // 测试逻辑
13 _ = value
14 })
15}
避免无限循环
1func FuzzLoop(f *testing.F) {
2 f.Fuzz(func(t *testing.T, n int) {
3 // ❌ 可能无限循环
4 // for i := 0; i < n; i++ { ... }
5
6 // ✅ 限制迭代次数
7 maxIterations := 1000
8 if n > maxIterations {
9 n = maxIterations
10 }
11
12 for i := 0; i < n; i++ {
13 // 测试逻辑
14 }
15 })
16}
避免资源泄漏
1func FuzzResource(f *testing.F) {
2 f.Fuzz(func(t *testing.T, filename string) {
3 // ✅ 使用 defer 确保资源释放
4 file, err := os.Open(filename)
5 if err != nil {
6 return
7 }
8 defer file.Close()
9
10 // 测试逻辑
11 })
12}
持续集成
在 CI 中运行模糊测试。
短时间运行
1# 在 CI 中运行 10 秒
2go test -fuzz=. -fuzztime=10s
3
4# 或运行 1000 次迭代
5go test -fuzz=. -fuzztime=1000x
回归测试
1# 只运行已发现的崩溃输入(不生成新输入)
2go test
3
4# 这会测试 testdata/fuzz/ 目录下的所有输入
GitHub Actions 示例
1name: Fuzz Testing
2
3on: [push, pull_request]
4
5jobs:
6 fuzz:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v3
10 - uses: actions/setup-go@v4
11 with:
12 go-version: '1.21'
13
14 - name: Run fuzz tests
15 run: go test -fuzz=. -fuzztime=30s
16
17 - name: Run regression tests
18 run: go test
最佳实践
1. 从简单开始
1// 先测试简单函数
2func FuzzSimple(f *testing.F) {
3 f.Fuzz(func(t *testing.T, s string) {
4 result := strings.ToUpper(s)
5 // 简单属性检查
6 if len(result) != len(s) {
7 t.Error("length should not change")
8 }
9 })
10}
2. 添加有意义的种子
1func FuzzWithSeeds(f *testing.F) {
2 // 典型输入
3 f.Add("normal input")
4
5 // 边界情况
6 f.Add("")
7 f.Add("a")
8 f.Add(strings.Repeat("x", 10000))
9
10 // 特殊字符
11 f.Add("hello\x00world")
12 f.Add("🙂😀")
13
14 f.Fuzz(func(t *testing.T, s string) {
15 // 测试逻辑
16 })
17}
3. 检查多个属性
1func FuzzMultipleProperties(f *testing.F) {
2 f.Fuzz(func(t *testing.T, s string) {
3 result := Process(s)
4
5 // 属性 1:不应该 panic
6 // 属性 2:结果长度合理
7 if len(result) > len(s)*10 {
8 t.Error("result too long")
9 }
10
11 // 属性 3:幂等性
12 if Process(result) != result {
13 t.Error("should be idempotent")
14 }
15 })
16}
4. 定期运行长时间测试
1# 本地运行长时间测试
2go test -fuzz=. -fuzztime=1h
3
4# 或在夜间 CI 中运行
5. 保存发现的问题
1# 提交 testdata/fuzz/ 目录
2git add testdata/fuzz/
3git commit -m "Add fuzz test cases"
老墨总结
模糊测试的 5 个关键点:
- 自动生成输入:测试大量随机输入,发现边界情况的 Bug
- 检查属性:不检查具体值,而是检查程序的属性(幂等性、可逆性等)
- 种子语料库:添加典型输入和边界情况,提高测试效率
- 回归测试:发现的问题会自动保存,下次运行会重新测试
- CI 集成:在 CI 中运行短时间测试,本地运行长时间测试
实战建议:
- 模糊测试不能替代单元测试,两者互补
- 从简单函数开始,逐步扩展到复杂逻辑
- 添加有意义的种子输入
- 在 CI 中运行短时间测试(10-30秒)
- 定期运行长时间测试(1小时以上)
模糊测试能发现你没想到的 Bug,是提升代码质量的利器。
你用模糊测试发现过什么有趣的 Bug?有什么经验分享?欢迎评论区聊聊。
极客老墨,继续折腾!
练习题
- 为字符串反转函数编写模糊测试,检查反转两次是否等于原字符串
- 为 JSON 序列化/反序列化编写模糊测试,检查可逆性
- 为 URL 解析函数编写模糊测试,检查解析后再转字符串是否一致
- 为整数除法函数编写模糊测试,避免除零并检查结果正确性
- 为字符串清理函数编写模糊测试,检查是否移除了危险字符
- 为自己项目中的一个函数编写模糊测试,运行 1 小时看能否发现问题