Go 1.18 Fuzz 测试:发现隐藏 Bug 的利器
Go 1.18 引入了一个令人兴奋的新特性:Fuzz 测试(模糊测试)。这是一种自动化的软件测试技术,通过生成大量随机输入来发现程序中的隐藏 Bug。
传统测试只能覆盖你能想到的场景,而 Fuzz 测试能发现你从未想过的边界情况。
什么是 Fuzz 测试?
Fuzz 测试的核心思想很简单:
- 自动生成大量随机输入
- 用这些输入执行你的代码
- 观察是否出现崩溃、panic 或其他异常行为
传统测试 vs Fuzz 测试:
// 传统测试:你决定测试什么
func TestParse(t *testing.T) {
tests := []string{
"hello",
"world",
"",
"123",
}
for _, input := range tests {
result := Parse(input)
// 验证结果
}
}
// Fuzz 测试:让机器决定测试什么
func FuzzParse(f *testing.F) {
f.Fuzz(func(t *testing.T, input string) {
result := Parse(input)
// 验证结果的某些属性
})
}
基础用法
第一个 Fuzz 测试
package main
import (
"strings"
"testing"
)
// Reverse 反转字符串
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 TestReverse(t *testing.T) {
tests := []struct {
input, want string
}{
{"hello", "olleh"},
{"", ""},
{"a", "a"},
}
for _, tt := range tests {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// Fuzz 测试
func FuzzReverse(f *testing.F) {
// 提供种子语料库(seed corpus)
f.Add("hello")
f.Add("")
f.Add("a")
f.Add("Hello, 世界")
f.Fuzz(func(t *testing.T, input string) {
reversed := Reverse(input)
// 验证属性:反转两次应该得到原字符串
doubleReversed := Reverse(reversed)
if doubleReversed != input {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q",
input, doubleReversed, input)
}
// 验证属性:长度应该相同
if len(reversed) != len(input) {
t.Errorf("len(Reverse(%q)) = %d, want %d",
input, len(reversed), len(input))
}
})
}
运行 Fuzz 测试
# 运行一次(使用种子语料库)
go test -fuzz=FuzzReverse
# 持续运行(直到发现 bug 或手动停止)
go test -fuzz=FuzzReverse -fuzztime=30s
# 限制 CPU 使用
go test -fuzz=FuzzReverse -parallel=4
实战:发现真实 Bug
案例 1:JSON 解析器
package main
import (
"encoding/json"
"testing"
)
type Config struct {
Name string `json:"name"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
}
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
err := json.Unmarshal(data, &cfg)
if err != nil {
return nil, err
}
// 验证端口范围
if cfg.Port < 0 || cfg.Port > 65535 {
return nil, fmt.Errorf("invalid port: %d", cfg.Port)
}
return &cfg, nil
}
func FuzzParseConfig(f *testing.F) {
// 种子语料库
f.Add([]byte(`{"name":"test","port":8080,"enabled":true}`))
f.Add([]byte(`{}`))
f.Add([]byte(`{"name":"","port":0,"enabled":false}`))
f.Fuzz(func(t *testing.T, data []byte) {
cfg, err := ParseConfig(data)
// 如果解析成功,验证约束
if err == nil {
if cfg.Port < 0 || cfg.Port > 65535 {
t.Errorf("Invalid port accepted: %d", cfg.Port)
}
}
})
}
运行后发现:
--- FAIL: FuzzParseConfig (0.05s)
--- FAIL: FuzzParseConfig (0.00s)
parse_test.go:35: Invalid port accepted: 99999
Failing input written to testdata/fuzz/FuzzParseConfig/...
原来 JSON 可以包含超出范围的端口号!
案例 2:URL 解析
package main
import (
"net/url"
"testing"
)
func ExtractDomain(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
return u.Hostname(), nil
}
func FuzzExtractDomain(f *testing.F) {
f.Add("https://example.com")
f.Add("http://localhost:8080/path")
f.Add("ftp://files.example.com")
f.Fuzz(func(t *testing.T, input string) {
domain, err := ExtractDomain(input)
// 如果解析成功,域名不应该包含非法字符
if err == nil && domain != "" {
for _, r := range domain {
if r == ' ' || r == '\n' || r == '\r' {
t.Errorf("Domain contains invalid character: %q", domain)
}
}
}
})
}
案例 3:数学运算
package main
import (
"math"
"testing"
)
func SafeDivide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
result := a / b
if math.IsInf(result, 0) || math.IsNaN(result) {
return 0, fmt.Errorf("result is not finite")
}
return result, nil
}
func FuzzSafeDivide(f *testing.F) {
f.Add(10.0, 2.0)
f.Add(0.0, 1.0)
f.Add(-5.0, 3.0)
f.Fuzz(func(t *testing.T, a, b float64) {
result, err := SafeDivide(a, b)
if err == nil {
// 验证结果的数学性质
if math.IsInf(result, 0) || math.IsNaN(result) {
t.Errorf("Result is not finite: %v / %v = %v", a, b, result)
}
// 验证逆运算(考虑浮点精度)
reconstructed := result * b
if math.Abs(reconstructed-a) > 1e-10*math.Abs(a) {
t.Errorf("Reconstruction failed: (%v / %v) * %v = %v, want %v",
a, b, b, reconstructed, a)
}
}
})
}
高级技巧
1. 自定义 Fuzz 类型
package main
import (
"testing"
"time"
)
type DateRange struct {
Start time.Time
End time.Time
}
func (dr DateRange) Valid() bool {
return !dr.End.Before(dr.Start)
}
func FuzzDateRange(f *testing.F) {
// 使用 int64 作为 Unix 时间戳
f.Add(int64(1609459200), int64(1640995200)) // 2021-01-01 to 2022-01-01
f.Fuzz(func(t *testing.T, start, end int64) {
// 限制范围避免溢出
if start < 0 || start > 4102444800 { // 2100-01-01
return
}
if end < 0 || end > 4102444800 {
return
}
dr := DateRange{
Start: time.Unix(start, 0),
End: time.Unix(end, 0),
}
// 验证 Valid() 方法的正确性
if dr.Valid() && dr.End.Before(dr.Start) {
t.Errorf("Valid() returned true but End < Start: %v", dr)
}
if !dr.Valid() && !dr.End.Before(dr.Start) {
t.Errorf("Valid() returned false but End >= Start: %v", dr)
}
})
}
2. 结构化输入
package main
import (
"testing"
)
type HTTPRequest struct {
Method string
Path string
Headers map[string]string
Body string
}
func ValidateRequest(req *HTTPRequest) error {
validMethods := map[string]bool{
"GET": true, "POST": true, "PUT": true,
"DELETE": true, "PATCH": true,
}
if !validMethods[req.Method] {
return fmt.Errorf("invalid method: %s", req.Method)
}
if len(req.Path) == 0 || req.Path[0] != '/' {
return fmt.Errorf("invalid path: %s", req.Path)
}
return nil
}
func FuzzValidateRequest(f *testing.F) {
f.Add("GET", "/", "", "")
f.Add("POST", "/api/users", "application/json", `{"name":"test"}`)
f.Fuzz(func(t *testing.T, method, path, contentType, body string) {
req := &HTTPRequest{
Method: method,
Path: path,
Headers: map[string]string{
"Content-Type": contentType,
},
Body: body,
}
err := ValidateRequest(req)
// 如果验证通过,检查约束
if err == nil {
if len(req.Path) == 0 || req.Path[0] != '/' {
t.Errorf("Invalid path accepted: %q", req.Path)
}
}
})
}
3. 使用语料库
package main
import (
"os"
"path/filepath"
"testing"
)
func FuzzParseWithCorpus(f *testing.F) {
// 从文件加载语料库
corpusDir := "testdata/corpus"
files, _ := filepath.Glob(filepath.Join(corpusDir, "*.json"))
for _, file := range files {
data, err := os.ReadFile(file)
if err == nil {
f.Add(data)
}
}
f.Fuzz(func(t *testing.T, data []byte) {
// 你的解析逻辑
Parse(data)
})
}
最佳实践
1. 验证不变量(Invariants)
func FuzzSort(f *testing.F) {
f.Fuzz(func(t *testing.T, input []int) {
sorted := Sort(input)
// 不变量 1:长度不变
if len(sorted) != len(input) {
t.Errorf("Length changed")
}
// 不变量 2:有序
for i := 1; i < len(sorted); i++ {
if sorted[i] < sorted[i-1] {
t.Errorf("Not sorted at index %d", i)
}
}
// 不变量 3:包含所有元素
inputMap := make(map[int]int)
for _, v := range input {
inputMap[v]++
}
for _, v := range sorted {
inputMap[v]--
}
for _, count := range inputMap {
if count != 0 {
t.Errorf("Elements changed")
}
}
})
}
2. 处理 Panic
func FuzzNoPanic(f *testing.F) {
f.Fuzz(func(t *testing.T, input string) {
defer func() {
if r := recover(); r != nil {
t.Errorf("Panic: %v", r)
}
}()
// 你的代码不应该 panic
Process(input)
})
}
3. 限制资源使用
func FuzzWithLimits(f *testing.F) {
f.Fuzz(func(t *testing.T, size int, data []byte) {
// 限制输入大小
if size < 0 || size > 10000 {
return
}
if len(data) > 1000000 { // 1MB
return
}
Process(data[:size])
})
}
4. 集成到 CI/CD
# .github/workflows/fuzz.yml
name: Fuzz Tests
on: [push, pull_request]
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Run fuzz tests
run: |
go test -fuzz=FuzzParse -fuzztime=30s ./...
go test -fuzz=FuzzValidate -fuzztime=30s ./...
- name: Upload corpus
uses: actions/upload-artifact@v3
with:
name: fuzz-corpus
path: testdata/fuzz/
管理 Fuzz 语料库
语料库结构
testdata/
└── fuzz/
└── FuzzParse/
├── abc123 # 自动生成的测试用例
├── def456
└── ghi789 # 发现 bug 的输入
清理语料库
# 查看语料库大小
du -sh testdata/fuzz/
# 删除旧的语料库
rm -rf testdata/fuzz/FuzzParse/*
# 保留发现 bug 的用例
git add testdata/fuzz/FuzzParse/*-bug-*
分享语料库
# 导出语料库
tar -czf corpus.tar.gz testdata/fuzz/
# 导入语料库
tar -xzf corpus.tar.gz
实际案例:发现安全漏洞
SQL 注入检测
package main
import (
"strings"
"testing"
)
func SanitizeSQL(input string) string {
// 简单的 SQL 注入防护
replacements := map[string]string{
"'": "''",
";": "",
"--": "",
}
result := input
for old, new := range replacements {
result = strings.ReplaceAll(result, old, new)
}
return result
}
func FuzzSanitizeSQL(f *testing.F) {
f.Add("Robert'); DROP TABLE Students;--")
f.Add("admin' --")
f.Add("1' OR '1'='1")
f.Fuzz(func(t *testing.T, input string) {
sanitized := SanitizeSQL(input)
// 检查是否还有危险字符
dangerous := []string{"';", "'; --", "OR 1=1"}
for _, d := range dangerous {
if strings.Contains(sanitized, d) {
t.Errorf("Dangerous pattern not sanitized: %q in %q", d, sanitized)
}
}
})
}
路径遍历检测
package main
import (
"path/filepath"
"testing"
)
func SafeJoin(base, userPath string) (string, error) {
// 清理路径
cleaned := filepath.Clean(userPath)
// 拼接路径
full := filepath.Join(base, cleaned)
// 验证结果仍在 base 目录下
rel, err := filepath.Rel(base, full)
if err != nil {
return "", err
}
// 检查是否尝试逃出 base
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path traversal detected")
}
return full, nil
}
func FuzzSafeJoin(f *testing.F) {
f.Add("/var/www", "index.html")
f.Add("/var/www", "../etc/passwd")
f.Add("/var/www", "./../../etc/passwd")
f.Fuzz(func(t *testing.T, base, userPath string) {
if base == "" {
return
}
result, err := SafeJoin(base, userPath)
if err == nil {
// 验证结果确实在 base 下
rel, _ := filepath.Rel(base, result)
if strings.HasPrefix(rel, "..") {
t.Errorf("Path traversal not detected: %q + %q = %q",
base, userPath, result)
}
}
})
}
总结
Fuzz 测试是 Go 1.18 带来的强大工具:
优势:
- 自动发现 Bug:找到你从未想到的边界情况
- 持续运行:可以在 CI 中长时间运行
- 积累语料库:发现的 Bug 会成为永久测试用例
- 提高覆盖率:覆盖传统测试难以触及的代码路径
最佳实践:
- 验证不变量而非具体输出
- 提供有意义的种子语料库
- 处理 panic 和异常
- 限制资源使用避免超时
- 集成到 CI/CD 流程
适用场景:
- 解析器(JSON、XML、URL 等)
- 数据验证和清理
- 加密和安全相关代码
- 数学运算和算法
- 网络协议处理
记住:Fuzz 测试不是替代传统测试,而是补充。传统测试验证已知行为,Fuzz 测试发现未知问题。
让你的代码经受 Fuzz 测试的考验,才能真正做到坚如磐石!
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。