1.10.2 模糊测试

1.10.2 模糊测试 #

模糊测试(Fuzzing)是 Go 1.18 引入的一个强大的测试特性,它能够自动生成大量的随机输入来测试函数,帮助发现边界情况和潜在的 bug。模糊测试是一种自动化测试技术,通过提供意外的、随机的或格式错误的输入来发现程序中的漏洞和错误。

模糊测试的基本原理 #

什么是模糊测试 #

模糊测试是一种软件测试技术,它通过向程序输入大量随机、无效或意外的数据来发现程序中的错误、崩溃或安全漏洞。Go 的模糊测试框架会:

  1. 生成测试输入:自动生成各种类型的输入数据
  2. 执行测试函数:使用生成的输入运行目标函数
  3. 监控执行结果:检测崩溃、panic 或其他异常行为
  4. 收集有趣的输入:保存能够触发新代码路径的输入
  5. 变异输入:基于有效输入生成新的测试用例

模糊测试的优势 #

// 传统单元测试的局限性
func TestParseEmail(t *testing.T) {
    tests := []struct {
        input    string
        expected bool
    }{
        {"[email protected]", true},
        {"invalid-email", false},
        {"", false},
        {"@example.com", false},
        {"user@", false},
    }

    for _, test := range tests {
        result := IsValidEmail(test.input)
        if result != test.expected {
            t.Errorf("IsValidEmail(%q) = %v, want %v",
                test.input, result, test.expected)
        }
    }
}

// 模糊测试可以发现更多边界情况
func FuzzParseEmail(f *testing.F) {
    // 提供种子输入
    f.Add("[email protected]")
    f.Add("invalid-email")
    f.Add("")

    f.Fuzz(func(t *testing.T, email string) {
        // 模糊测试不应该导致 panic
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("IsValidEmail panicked with input %q: %v", email, r)
            }
        }()

        // 执行函数,确保不会崩溃
        IsValidEmail(email)
    })
}

Go 内置模糊测试框架 #

基本语法 #

Go 的模糊测试函数必须:

  • Fuzz 开头
  • 接受 *testing.F 参数
  • 位于 *_test.go 文件中
package main

import (
    "fmt"
    "regexp"
    "strconv"
    "strings"
    "testing"
    "unicode"
)

// 待测试的函数:验证邮箱格式
func IsValidEmail(email string) bool {
    if email == "" {
        return false
    }

    // 简单的邮箱验证正则表达式
    pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    matched, err := regexp.MatchString(pattern, email)
    if err != nil {
        return false
    }

    return matched
}

// 模糊测试邮箱验证函数
func FuzzIsValidEmail(f *testing.F) {
    // 添加种子输入
    f.Add("[email protected]")
    f.Add("[email protected]")
    f.Add("invalid-email")
    f.Add("@example.com")
    f.Add("user@")
    f.Add("")

    f.Fuzz(func(t *testing.T, email string) {
        // 确保函数不会 panic
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("IsValidEmail panicked with input %q: %v", email, r)
            }
        }()

        result := IsValidEmail(email)

        // 添加一些基本的不变性检查
        if email == "" && result {
            t.Errorf("Empty email should not be valid")
        }

        if !strings.Contains(email, "@") && result {
            t.Errorf("Email without @ should not be valid: %q", email)
        }
    })
}

支持的数据类型 #

Go 模糊测试支持以下基本类型:

func FuzzMultipleTypes(f *testing.F) {
    // 支持的基本类型
    f.Add("string", int(42), int8(1), int16(2), int32(3), int64(4))
    f.Add("test", uint(5), uint8(6), uint16(7), uint32(8), uint64(9))
    f.Add("example", float32(3.14), float64(2.718), true)
    f.Add("data", []byte("hello"))

    f.Fuzz(func(t *testing.T, s string, i int, i8 int8, i16 int16, i32 int32, i64 int64,
        ui uint, ui8 uint8, ui16 uint16, ui32 uint32, ui64 uint64,
        f32 float32, f64 float64, b bool, data []byte) {

        // 测试函数处理各种类型输入的能力
        ProcessMultipleTypes(s, i, i8, i16, i32, i64, ui, ui8, ui16, ui32, ui64, f32, f64, b, data)
    })
}

func ProcessMultipleTypes(s string, i int, i8 int8, i16 int16, i32 int32, i64 int64,
    ui uint, ui8 uint8, ui16 uint16, ui32 uint32, ui64 uint64,
    f32 float32, f64 float64, b bool, data []byte) {

    // 处理各种类型的输入
    fmt.Printf("String: %s, Int: %d, Bool: %t, Data: %s\n", s, i, b, string(data))
}

编写模糊测试用例 #

字符串处理函数的模糊测试 #

package stringutils

import (
    "strings"
    "unicode"
)

// 将字符串转换为标题格式
func ToTitle(s string) string {
    if s == "" {
        return ""
    }

    words := strings.Fields(s)
    for i, word := range words {
        if len(word) > 0 {
            runes := []rune(word)
            runes[0] = unicode.ToUpper(runes[0])
            for j := 1; j < len(runes); j++ {
                runes[j] = unicode.ToLower(runes[j])
            }
            words[i] = string(runes)
        }
    }

    return strings.Join(words, " ")
}

// 反转字符串
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 IsPalindrome(s string) bool {
    // 移除空格并转换为小写
    cleaned := strings.ToLower(strings.ReplaceAll(s, " ", ""))
    return cleaned == Reverse(cleaned)
}
// stringutils_test.go
package stringutils

import (
    "strings"
    "testing"
    "unicode"
    "unicode/utf8"
)

func FuzzToTitle(f *testing.F) {
    // 添加种子输入
    f.Add("hello world")
    f.Add("HELLO WORLD")
    f.Add("Hello World")
    f.Add("")
    f.Add("a")
    f.Add("hello-world")
    f.Add("hello_world")
    f.Add("你好世界")

    f.Fuzz(func(t *testing.T, input string) {
        result := ToTitle(input)

        // 不变性检查
        if input == "" && result != "" {
            t.Errorf("ToTitle of empty string should be empty, got %q", result)
        }

        // 检查结果是否为有效的 UTF-8
        if !utf8.ValidString(result) {
            t.Errorf("ToTitle produced invalid UTF-8: %q", result)
        }

        // 如果输入只包含空格,结果应该为空
        if strings.TrimSpace(input) == "" && result != "" {
            t.Errorf("ToTitle of whitespace-only string should be empty, got %q", result)
        }
    })
}

func FuzzReverse(f *testing.F) {
    f.Add("hello")
    f.Add("")
    f.Add("a")
    f.Add("ab")
    f.Add("abc")
    f.Add("你好")
    f.Add("🙂🙃")

    f.Fuzz(func(t *testing.T, input string) {
        result := Reverse(input)

        // 不变性检查
        if len(input) != len(result) {
            t.Errorf("Reverse changed string length: input %d, result %d",
                len(input), len(result))
        }

        // 双重反转应该得到原字符串
        doubleReverse := Reverse(result)
        if input != doubleReverse {
            t.Errorf("Double reverse failed: input %q, result %q", input, doubleReverse)
        }

        // 检查结果是否为有效的 UTF-8
        if !utf8.ValidString(result) {
            t.Errorf("Reverse produced invalid UTF-8: %q", result)
        }
    })
}

func FuzzIsPalindrome(f *testing.F) {
    f.Add("racecar")
    f.Add("A man a plan a canal Panama")
    f.Add("race a car")
    f.Add("")
    f.Add("a")
    f.Add("Aa")
    f.Add("上海海上")

    f.Fuzz(func(t *testing.T, input string) {
        result := IsPalindrome(input)

        // 空字符串应该是回文
        if input == "" && !result {
            t.Errorf("Empty string should be palindrome")
        }

        // 单字符应该是回文
        if utf8.RuneCountInString(input) == 1 && !result {
            t.Errorf("Single character should be palindrome: %q", input)
        }

        // 如果是回文,反转后应该仍然是回文
        if result {
            reversed := Reverse(input)
            if !IsPalindrome(reversed) {
                t.Errorf("Reversed palindrome should still be palindrome: %q -> %q",
                    input, reversed)
            }
        }
    })
}

数值计算函数的模糊测试 #

package mathutils

import (
    "errors"
    "math"
)

// 计算平方根
func Sqrt(x float64) (float64, error) {
    if x < 0 {
        return 0, errors.New("cannot compute square root of negative number")
    }

    if x == 0 {
        return 0, nil
    }

    // 使用牛顿法计算平方根
    guess := x / 2
    for i := 0; i < 10; i++ {
        guess = (guess + x/guess) / 2
    }

    return guess, nil
}

// 计算阶乘
func Factorial(n int) (int64, error) {
    if n < 0 {
        return 0, errors.New("factorial of negative number is undefined")
    }

    if n > 20 {
        return 0, errors.New("factorial too large")
    }

    if n == 0 || n == 1 {
        return 1, nil
    }

    result := int64(1)
    for i := 2; i <= n; i++ {
        result *= int64(i)
    }

    return result, nil
}

// 最大公约数
func GCD(a, b int) int {
    if a < 0 {
        a = -a
    }
    if b < 0 {
        b = -b
    }

    for b != 0 {
        a, b = b, a%b
    }

    return a
}
// mathutils_test.go
package mathutils

import (
    "math"
    "testing"
)

func FuzzSqrt(f *testing.F) {
    f.Add(4.0)
    f.Add(9.0)
    f.Add(0.0)
    f.Add(1.0)
    f.Add(2.0)
    f.Add(100.0)

    f.Fuzz(func(t *testing.T, x float64) {
        result, err := Sqrt(x)

        if x < 0 {
            // 负数应该返回错误
            if err == nil {
                t.Errorf("Sqrt of negative number should return error: %f", x)
            }
            return
        }

        if err != nil {
            t.Errorf("Sqrt of non-negative number should not return error: %f", x)
            return
        }

        // 检查结果的正确性(允许小的浮点误差)
        if x == 0 && result != 0 {
            t.Errorf("Sqrt(0) should be 0, got %f", result)
        }

        if x > 0 {
            // 验证 result * result ≈ x
            square := result * result
            diff := math.Abs(square - x)
            tolerance := 1e-10

            if diff > tolerance {
                t.Errorf("Sqrt(%f) = %f, but %f^2 = %f (diff: %f)",
                    x, result, result, square, diff)
            }
        }

        // 结果不应该是 NaN 或无穷大
        if math.IsNaN(result) || math.IsInf(result, 0) {
            t.Errorf("Sqrt(%f) returned invalid result: %f", x, result)
        }
    })
}

func FuzzFactorial(f *testing.F) {
    f.Add(0)
    f.Add(1)
    f.Add(5)
    f.Add(10)
    f.Add(20)

    f.Fuzz(func(t *testing.T, n int) {
        result, err := Factorial(n)

        if n < 0 {
            if err == nil {
                t.Errorf("Factorial of negative number should return error: %d", n)
            }
            return
        }

        if n > 20 {
            if err == nil {
                t.Errorf("Factorial of large number should return error: %d", n)
            }
            return
        }

        if err != nil {
            t.Errorf("Factorial of valid number should not return error: %d", n)
            return
        }

        // 检查基本情况
        if n == 0 || n == 1 {
            if result != 1 {
                t.Errorf("Factorial(%d) should be 1, got %d", n, result)
            }
        }

        // 检查递归性质:n! = n * (n-1)!
        if n > 1 {
            prevFactorial, prevErr := Factorial(n - 1)
            if prevErr != nil {
                t.Errorf("Failed to compute factorial of %d", n-1)
                return
            }

            expected := int64(n) * prevFactorial
            if result != expected {
                t.Errorf("Factorial(%d) = %d, expected %d", n, result, expected)
            }
        }

        // 结果应该是正数
        if result <= 0 {
            t.Errorf("Factorial(%d) should be positive, got %d", n, result)
        }
    })
}

func FuzzGCD(f *testing.F) {
    f.Add(12, 8)
    f.Add(0, 5)
    f.Add(5, 0)
    f.Add(1, 1)
    f.Add(-12, 8)
    f.Add(12, -8)

    f.Fuzz(func(t *testing.T, a, b int) {
        result := GCD(a, b)

        // GCD 应该是非负数
        if result < 0 {
            t.Errorf("GCD(%d, %d) should be non-negative, got %d", a, b, result)
        }

        // GCD(0, 0) 应该是 0
        if a == 0 && b == 0 && result != 0 {
            t.Errorf("GCD(0, 0) should be 0, got %d", result)
        }

        // GCD(a, 0) 应该是 |a|
        if b == 0 && a != 0 {
            expected := a
            if expected < 0 {
                expected = -expected
            }
            if result != expected {
                t.Errorf("GCD(%d, 0) should be %d, got %d", a, expected, result)
            }
        }

        // GCD(0, b) 应该是 |b|
        if a == 0 && b != 0 {
            expected := b
            if expected < 0 {
                expected = -expected
            }
            if result != expected {
                t.Errorf("GCD(0, %d) should be %d, got %d", b, expected, result)
            }
        }

        // 交换律:GCD(a, b) = GCD(b, a)
        if GCD(b, a) != result {
            t.Errorf("GCD should be commutative: GCD(%d, %d) != GCD(%d, %d)",
                a, b, b, a)
        }

        // 如果 a 和 b 都不为 0,GCD 应该能整除 a 和 b
        if a != 0 && b != 0 && result > 0 {
            if a%result != 0 {
                t.Errorf("GCD(%d, %d) = %d should divide %d", a, b, result, a)
            }
            if b%result != 0 {
                t.Errorf("GCD(%d, %d) = %d should divide %d", a, b, result, b)
            }
        }
    })
}

模糊测试的实际应用 #

JSON 解析器的模糊测试 #

package jsonparser

import (
    "encoding/json"
    "errors"
    "fmt"
    "strconv"
    "strings"
)

// 简单的 JSON 解析器
type SimpleJSON struct {
    data interface{}
}

func Parse(jsonStr string) (*SimpleJSON, error) {
    var data interface{}
    err := json.Unmarshal([]byte(jsonStr), &data)
    if err != nil {
        return nil, fmt.Errorf("failed to parse JSON: %w", err)
    }

    return &SimpleJSON{data: data}, nil
}

func (sj *SimpleJSON) GetString(key string) (string, error) {
    obj, ok := sj.data.(map[string]interface{})
    if !ok {
        return "", errors.New("not a JSON object")
    }

    value, exists := obj[key]
    if !exists {
        return "", fmt.Errorf("key %q not found", key)
    }

    str, ok := value.(string)
    if !ok {
        return "", fmt.Errorf("value for key %q is not a string", key)
    }

    return str, nil
}

func (sj *SimpleJSON) GetInt(key string) (int, error) {
    obj, ok := sj.data.(map[string]interface{})
    if !ok {
        return 0, errors.New("not a JSON object")
    }

    value, exists := obj[key]
    if !exists {
        return 0, fmt.Errorf("key %q not found", key)
    }

    switch v := value.(type) {
    case float64:
        return int(v), nil
    case int:
        return v, nil
    case string:
        return strconv.Atoi(v)
    default:
        return 0, fmt.Errorf("value for key %q cannot be converted to int", key)
    }
}
// jsonparser_test.go
package jsonparser

import (
    "encoding/json"
    "strings"
    "testing"
)

func FuzzJSONParse(f *testing.F) {
    // 添加有效的 JSON 种子
    f.Add(`{"name": "John", "age": 30}`)
    f.Add(`{"items": [1, 2, 3]}`)
    f.Add(`{"nested": {"key": "value"}}`)
    f.Add(`[]`)
    f.Add(`{}`)
    f.Add(`null`)
    f.Add(`true`)
    f.Add(`false`)
    f.Add(`42`)
    f.Add(`"string"`)

    // 添加一些无效的 JSON 种子
    f.Add(`{`)
    f.Add(`}`)
    f.Add(`{"key": }`)
    f.Add(`{"key": "value",}`)

    f.Fuzz(func(t *testing.T, jsonStr string) {
        sj, err := Parse(jsonStr)

        // 检查标准库的行为一致性
        var stdData interface{}
        stdErr := json.Unmarshal([]byte(jsonStr), &stdData)

        if stdErr != nil {
            // 如果标准库解析失败,我们的解析器也应该失败
            if err == nil {
                t.Errorf("Standard library failed to parse %q, but our parser succeeded", jsonStr)
            }
            return
        }

        // 如果标准库解析成功,我们的解析器也应该成功
        if err != nil {
            t.Errorf("Standard library parsed %q successfully, but our parser failed: %v",
                jsonStr, err)
            return
        }

        // 测试 GetString 和 GetInt 方法不会 panic
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("JSON methods panicked with input %q: %v", jsonStr, r)
            }
        }()

        // 尝试获取一些常见的键
        commonKeys := []string{"name", "age", "id", "value", "key", "data"}
        for _, key := range commonKeys {
            sj.GetString(key)
            sj.GetInt(key)
        }
    })
}

func FuzzJSONGetString(f *testing.F) {
    f.Add(`{"name": "John"}`, "name")
    f.Add(`{"age": 30}`, "age")
    f.Add(`{}`, "missing")
    f.Add(`[]`, "key")

    f.Fuzz(func(t *testing.T, jsonStr, key string) {
        sj, err := Parse(jsonStr)
        if err != nil {
            return // 跳过无效的 JSON
        }

        // GetString 不应该 panic
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("GetString panicked with JSON %q and key %q: %v",
                    jsonStr, key, r)
            }
        }()

        result, err := sj.GetString(key)

        // 如果成功获取到字符串,验证它确实是字符串
        if err == nil && result == "" && !strings.Contains(jsonStr, `"`+key+`": ""`) {
            // 空字符串应该只在 JSON 中确实包含空字符串时返回
            if !strings.Contains(jsonStr, `"`+key+`": ""`) {
                t.Logf("Got empty string for key %q in JSON %q", key, jsonStr)
            }
        }
    })
}

URL 解析器的模糊测试 #

package urlparser

import (
    "errors"
    "net/url"
    "strings"
)

// 简单的 URL 解析器
type URL struct {
    Scheme   string
    Host     string
    Port     string
    Path     string
    Query    map[string]string
    Fragment string
}

func ParseURL(rawURL string) (*URL, error) {
    if rawURL == "" {
        return nil, errors.New("empty URL")
    }

    // 使用标准库解析
    u, err := url.Parse(rawURL)
    if err != nil {
        return nil, err
    }

    // 解析查询参数
    query := make(map[string]string)
    for key, values := range u.Query() {
        if len(values) > 0 {
            query[key] = values[0]
        }
    }

    return &URL{
        Scheme:   u.Scheme,
        Host:     u.Hostname(),
        Port:     u.Port(),
        Path:     u.Path,
        Query:    query,
        Fragment: u.Fragment,
    }, nil
}

func (u *URL) String() string {
    var result strings.Builder

    if u.Scheme != "" {
        result.WriteString(u.Scheme)
        result.WriteString("://")
    }

    if u.Host != "" {
        result.WriteString(u.Host)
        if u.Port != "" {
            result.WriteString(":")
            result.WriteString(u.Port)
        }
    }

    result.WriteString(u.Path)

    if len(u.Query) > 0 {
        result.WriteString("?")
        first := true
        for key, value := range u.Query {
            if !first {
                result.WriteString("&")
            }
            result.WriteString(key)
            result.WriteString("=")
            result.WriteString(value)
            first = false
        }
    }

    if u.Fragment != "" {
        result.WriteString("#")
        result.WriteString(u.Fragment)
    }

    return result.String()
}
// urlparser_test.go
package urlparser

import (
    "net/url"
    "strings"
    "testing"
)

func FuzzParseURL(f *testing.F) {
    // 添加有效的 URL 种子
    f.Add("https://example.com")
    f.Add("http://example.com:8080/path?key=value#fragment")
    f.Add("ftp://user:[email protected]/file.txt")
    f.Add("/relative/path")
    f.Add("?query=only")
    f.Add("#fragment-only")
    f.Add("mailto:[email protected]")

    // 添加一些边界情况
    f.Add("")
    f.Add("://")
    f.Add("http://")
    f.Add("example.com")

    f.Fuzz(func(t *testing.T, rawURL string) {
        parsedURL, err := ParseURL(rawURL)

        // 与标准库行为对比
        stdURL, stdErr := url.Parse(rawURL)

        if rawURL == "" {
            // 空 URL 应该返回错误
            if err == nil {
                t.Errorf("Empty URL should return error")
            }
            return
        }

        if stdErr != nil {
            // 如果标准库解析失败,我们也应该失败
            if err == nil {
                t.Errorf("Standard library failed to parse %q, but our parser succeeded", rawURL)
            }
            return
        }

        if err != nil {
            t.Errorf("Failed to parse valid URL %q: %v", rawURL, err)
            return
        }

        // 验证解析结果的一致性
        if parsedURL.Scheme != stdURL.Scheme {
            t.Errorf("Scheme mismatch for %q: got %q, want %q",
                rawURL, parsedURL.Scheme, stdURL.Scheme)
        }

        if parsedURL.Host != stdURL.Hostname() {
            t.Errorf("Host mismatch for %q: got %q, want %q",
                rawURL, parsedURL.Host, stdURL.Hostname())
        }

        if parsedURL.Port != stdURL.Port() {
            t.Errorf("Port mismatch for %q: got %q, want %q",
                rawURL, parsedURL.Port, stdURL.Port())
        }

        // 测试 String 方法不会 panic
        defer func() {
            if r := recover(); r != nil {
                t.Errorf("String method panicked with URL %q: %v", rawURL, r)
            }
        }()

        urlString := parsedURL.String()

        // 重新解析生成的字符串应该成功
        if urlString != "" {
            _, repParseErr := ParseURL(urlString)
            if repParseErr != nil {
                t.Errorf("Failed to re-parse generated URL string %q: %v",
                    urlString, repParseErr)
            }
        }
    })
}

运行模糊测试 #

# 运行单个模糊测试
go test -fuzz=FuzzParseURL

# 运行所有模糊测试
go test -fuzz=.

# 指定运行时间
go test -fuzz=FuzzParseURL -fuzztime=30s

# 指定最小化时间
go test -fuzz=FuzzParseURL -fuzzminimizetime=10s

# 查看模糊测试覆盖率
go test -fuzz=FuzzParseURL -coverprofile=fuzz.out
go tool cover -html=fuzz.out

模糊测试的最佳实践 #

  1. 提供好的种子输入
func FuzzFunction(f *testing.F) {
    // 提供各种边界情况作为种子
    f.Add("") // 空输入
    f.Add("normal input") // 正常输入
    f.Add("边界情况") // Unicode 输入
    f.Add(strings.Repeat("a", 1000)) // 长输入
}
  1. 检查不变性而非具体值
f.Fuzz(func(t *testing.T, input string) {
    result := ProcessString(input)

    // 好:检查不变性
    if len(input) > 0 && len(result) == 0 {
        t.Error("Non-empty input should not produce empty result")
    }

    // 不好:检查具体值
    // if result != "expected" { ... }
})
  1. 防止 panic
f.Fuzz(func(t *testing.T, input string) {
    defer func() {
        if r := recover(); r != nil {
            t.Errorf("Function panicked: %v", r)
        }
    }()

    ProcessString(input)
})
  1. 限制资源使用
f.Fuzz(func(t *testing.T, input string) {
    // 限制输入大小
    if len(input) > 10000 {
        t.Skip("Input too large")
    }

    ProcessString(input)
})

通过本节的学习,您应该已经掌握了 Go 模糊测试的基本概念和实际应用。模糊测试是发现边界情况和潜在 bug 的强大工具,能够显著提高代码的健壮性和安全性。在下一节中,我们将学习 Go 1.16 引入的嵌入文件系统特性。