单元测试只能测试你想到的情况,而模糊测试能帮你发现你没想到的边界情况。

1. 什么是模糊测试?

模糊测试 (Fuzzing) 是一种自动化测试技术,通过生成大量随机或半随机的输入数据来测试程序,寻找崩溃、panic、死循环等异常。

传统测试 vs 模糊测试

// 传统单元测试:测试已知的输入
func TestAdd(t *testing.T) {
    if Add(2, 3) != 5 {
        t.Error("2 + 3 should be 5")
    }
}

// 模糊测试:测试大量随机输入
func FuzzAdd(f *testing.F) {
    f.Fuzz(func(t *testing.T, a, b int) {
        result := Add(a, b)
        // 检查属性而不是具体值
        if result < a && result < b {
            t.Errorf("Add(%d, %d) = %d, should be >= both", a, b, result)
        }
    })
}

2. 编写 Fuzz 测试

2.1 基础示例

假设我们有一个解析 URL 的函数:

// url.go
package myurl

import (
    "fmt"
    "strings"
)

func ParseURL(rawURL string) (string, error) {
    if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
        return "", fmt.Errorf("invalid protocol")
    }
    return rawURL, nil
}

编写 Fuzz 测试:

// url_test.go
package myurl

import (
    "testing"
)

func FuzzParseURL(f *testing.F) {
    // 添加种子语料库(可选)
    f.Add("http://example.com")
    f.Add("https://google.com")
    f.Add("ftp://invalid.com")

    // Fuzz 函数
    f.Fuzz(func(t *testing.T, rawURL string) {
        result, err := ParseURL(rawURL)

        // 检查:不应该 panic
        // 检查:如果没有错误,结果应该等于输入
        if err == nil && result != rawURL {
            t.Errorf("ParseURL(%q) = %q, want %q", rawURL, result, rawURL)
        }
    })
}

2.2 运行 Fuzz 测试

# 运行模糊测试(会一直运行直到发现问题或手动停止)
go test -fuzz=FuzzParseURL

# 限制运行时间
go test -fuzz=FuzzParseURL -fuzztime=30s

# 限制迭代次数
go test -fuzz=FuzzParseURL -fuzztime=10000x

2.3 查看结果

如果发现问题,Go 会自动保存导致崩溃的输入:

testdata/fuzz/FuzzParseURL/
├── 1234567890abcdef  # 导致问题的输入
└── ...

3. 支持的类型

Fuzz 函数的参数只能是以下类型:

  • string, []byte
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64
  • float32, float64
  • bool
func FuzzMultipleTypes(f *testing.F) {
    f.Add("hello", 42, true)

    f.Fuzz(func(t *testing.T, s string, n int, b bool) {
        // 测试逻辑
    })
}

4. 实战示例

4.1 测试 JSON 解析

func FuzzJSONParse(f *testing.F) {
    f.Add(`{"name":"Alice","age":30}`)
    f.Add(`{"name":"Bob"}`)

    f.Fuzz(func(t *testing.T, data string) {
        var result map[string]interface{}

        // 不应该 panic
        json.Unmarshal([]byte(data), &result)

        // 如果解析成功,再次序列化应该不出错
        if result != nil {
            _, err := json.Marshal(result)
            if err != nil {
                t.Errorf("Failed to re-marshal: %v", err)
            }
        }
    })
}

4.2 测试字符串处理

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

func FuzzReverse(f *testing.F) {
    f.Add("hello")
    f.Add("世界")

    f.Fuzz(func(t *testing.T, s string) {
        // 属性:反转两次应该等于原字符串
        reversed := Reverse(s)
        doubleReversed := Reverse(reversed)

        if s != doubleReversed {
            t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleReversed, s)
        }
    })
}

5. 最佳实践

5.1 添加种子语料库

func FuzzMyFunc(f *testing.F) {
    // 添加已知的边界情况
    f.Add("")           // 空字符串
    f.Add("a")          // 单字符
    f.Add("hello world") // 正常情况
    f.Add(strings.Repeat("x", 10000))  // 超长字符串

    f.Fuzz(func(t *testing.T, input string) {
        // ...
    })
}

5.2 检查属性而非具体值

// ❌ 不好:检查具体值
f.Fuzz(func(t *testing.T, a, b int) {
    if Add(a, b) != a+b {  // 这和单元测试没区别
        t.Error("wrong")
    }
})

// ✅ 好:检查属性
f.Fuzz(func(t *testing.T, a, b int) {
    result := Add(a, b)
    // 属性:结果应该大于等于两个输入
    if a >= 0 && b >= 0 && result < a {
        t.Error("result should be >= a")
    }
})

5.3 避免 panic

func FuzzDivide(f *testing.F) {
    f.Fuzz(func(t *testing.T, a, b int) {
        // 避免除零 panic
        if b == 0 {
            return
        }

        result := a / b
        // 检查属性
    })
}

6. 持续集成中的 Fuzz 测试

# 在 CI 中运行短时间的 Fuzz 测试
go test -fuzz=. -fuzztime=10s

# 或者只运行回归测试(使用已发现的崩溃输入)
go test

7. 总结

  • 模糊测试能发现边界情况的 Bug
  • 使用 f.Add() 添加种子语料库
  • 检查属性而非具体值
  • 在 CI 中运行短时间的 Fuzz 测试

思考题: 为什么模糊测试不能替代单元测试?两者应该如何配合使用?


相关阅读