大家好,我是极客老墨。

单元测试只能测试你想到的情况。空字符串、负数、超长输入,这些边界情况很容易遗漏。模糊测试能自动生成大量随机输入,帮你发现那些没想到的 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, []byte
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64
  • float32, float64
  • bool

不支持的类型

 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 个关键点:

  1. 自动生成输入:测试大量随机输入,发现边界情况的 Bug
  2. 检查属性:不检查具体值,而是检查程序的属性(幂等性、可逆性等)
  3. 种子语料库:添加典型输入和边界情况,提高测试效率
  4. 回归测试:发现的问题会自动保存,下次运行会重新测试
  5. CI 集成:在 CI 中运行短时间测试,本地运行长时间测试

实战建议

  • 模糊测试不能替代单元测试,两者互补
  • 从简单函数开始,逐步扩展到复杂逻辑
  • 添加有意义的种子输入
  • 在 CI 中运行短时间测试(10-30秒)
  • 定期运行长时间测试(1小时以上)

模糊测试能发现你没想到的 Bug,是提升代码质量的利器。


你用模糊测试发现过什么有趣的 Bug?有什么经验分享?欢迎评论区聊聊。

极客老墨,继续折腾!

练习题

  1. 为字符串反转函数编写模糊测试,检查反转两次是否等于原字符串
  2. 为 JSON 序列化/反序列化编写模糊测试,检查可逆性
  3. 为 URL 解析函数编写模糊测试,检查解析后再转字符串是否一致
  4. 为整数除法函数编写模糊测试,避免除零并检查结果正确性
  5. 为字符串清理函数编写模糊测试,检查是否移除了危险字符
  6. 为自己项目中的一个函数编写模糊测试,运行 1 小时看能否发现问题

相关阅读