Skip to content

Fuzz Testing

单元测试回答「我想到的输入,函数行为是否正确」;Fuzz 测试则回答「没想到的输入,函数会不会崩」。Go 1.18 起原生支持 Fuzz Testing,无需任何第三方框架。本篇将系统讲解 Fuzz 的原理、FuzzXxx 函数编写、种子语料库、运行参数,以及实战案例。

一、Fuzz Testing 简介

Fuzz Testing(模糊测试,简称 Fuzz)是一种自动化测试技术,它的核心思想是:

  1. 开发者提供一个「种子输入」(seed corpus)和一段处理逻辑。
  2. Fuzz 引擎基于种子,自动生成大量「变形输入」(变异、组合、随机)。
  3. 把每个输入喂给被测函数,观察是否触发 panic、死循环、未捕获错误、不符合契约的行为。
  4. 一旦发现问题,自动把触发问题的输入保存到语料库,方便后续复现。

与单元测试对比:

维度单元测试Fuzz 测试
输入来源开发者枚举引擎自动生成 + 变异
覆盖广度有限(只能测想到的)无限(可发现意想不到的边界)
主要目标验证已知行为发现未知 bug
运行时长秒级通常分钟到小时,甚至持续运行
触发失败断言失败panic、内存越界、超时、契约违反

Fuzz 在以下场景特别有用:

  • 解析器:JSON、XML、CSV、protobuf 解析。
  • 网络协议处理:TCP/UDP 数据包解析。
  • 加密/压缩:解压任意输入是否安全。
  • 正则表达式:恶意构造的正则是否导致 ReDoS。
  • 字符串处理:UTF-8 边界、控制字符、超长输入。

二、Go 1.18+ 原生 Fuzz 支持

Go 在 1.18 版本把 Fuzz 集成进了 go test 工具链,提供了:

  1. testing.F 类型:类似 testing.Ttesting.B,专门给 Fuzz 用。
  2. FuzzXxx 函数约定:以 Fuzz 开头、参数为 *testing.T*testing.F
  3. f.Add(...):添加种子输入。
  4. f.Fuzz(func, ...):注册 Fuzz 函数。
  5. 语料库自动持久化:失败的输入保存到 testdata/fuzz/<TestName>/
  6. go test -fuzz:启动持续 Fuzz 模式。

要求 Go 1.18 或更高版本。运行 Fuzz 需要:

bash
go test -fuzz=FuzzName

注意:go test(不带 -fuzz)也会运行 Fuzz 函数中的「种子语料」部分(每个种子跑一次),相当于把种子当成普通单元测试执行。-fuzz 才会启动「持续生成新输入」的真正 Fuzz 模式。

三、Fuzz 函数编写:FuzzXxx

来看一个最小例子。被测代码:

go
package parser

import "strconv"

// ParseAge 解析年龄字符串,要求 0 <= age <= 150。
func ParseAge(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, err
	}
	if n < 0 || n > 150 {
		return 0, ErrInvalidAge
	}
	return n, nil
}

// ErrInvalidAge 表示年龄超出合理范围。
var ErrInvalidAge = newAgeError()

type ageError struct{}

func (e *ageError) Error() string { return "invalid age" }

func newAgeError() *ageError { return &ageError{} }

Fuzz 测试:

go
package parser

import (
	"strconv"
	"testing"
)

func FuzzParseAge(f *testing.F) {
	// 添加种子语料:覆盖正常与异常路径
	seeds := []string{
		"0", "1", "18", "100", "150",
		"-1", "151", "99999",
		"", "abc", "12.5", "0x10", "1e3",
		"-0", "+1", " 10", "10 ",
	}
	for _, s := range seeds {
		f.Add(s)
	}

	f.Fuzz(func(t *testing.T, s string) {
		age, err := ParseAge(s)
		if err != nil {
			// 出错时 age 必须是 0(这是我们维护的不变式)
			if age != 0 {
				t.Errorf("err 时 age 应为 0,但得到 %d", age)
			}
			return
		}

		// 成功时 age 必须在合法范围内
		if age < 0 || age > 150 {
			t.Errorf("age %d 超出 [0, 150]", age)
		}

		// 不变式:往返应该可序列化回字符串
		if strconv.Itoa(age) != s {
			// 这不是 bug,但可以记录
			// 比如 "01" 会被解析为 1,再 Format 回 "1"
			// 只有当输入是无前导零的纯数字时才严格往返
			// 这里不做严格断言,演示「不要过度断言」
		}
	})
}

注意几个关键点:

  1. f.Add(s) 添加种子输入,参数类型必须与 f.Fuzz 的回调签名匹配。
  2. f.Fuzz(func(t *testing.T, s string) {...}) 注册实际执行的函数。
  3. 不要让 Fuzz 函数对所有输入都断言:Fuzz 会生成任意输入,无法期望「所有输入都返回正常」。Fuzz 主要检测 panic、超时、契约违反,而不是「输入语义正确」。

四、fuzzing 函数签名

f.Fuzz 的回调函数签名必须严格匹配:

go
func(t *testing.T, args ...T)

其中 T 必须是以下类型之一(Go 当前支持的 Fuzz 参数类型):

  • string
  • []byte
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64
  • float32, float64
  • bool

可以组合多个参数,例如:

go
f.Fuzz(func(t *testing.T, a string, b int, c bool) {
    // ...
})

f.Add 也必须按相同顺序提供参数:

go
f.Add("seed1", 42, true)

不支持自定义类型:你只能用上面的原始类型。如果被测函数接受 MyString,需要先把 string 转成 MyString,再传入。

五、种子语料库

种子语料库(seed corpus)是 Fuzz 测试的起点。引擎会基于这些种子做变异(mutation),生成大量新输入。种子质量直接影响 Fuzz 的有效性。

1. 内联种子:f.Add

最简单的方式:

go
func FuzzXxx(f *testing.F) {
	f.Add("hello")
	f.Add("")
	f.Add("123")
	f.Add(strings.Repeat("a", 1000))
	f.Fuzz(func(t *testing.T, s string) {
		// ...
	})
}

2. 文件语料库:testdata/fuzz/

更结构化的方式是把种子写到 testdata/fuzz/<TestName>/ 目录下,每个文件一个种子。文件格式:

text
go test fuzz v1
[]byte("hello")

或:

text
go test fuzz v1
int(42)
string("hello")

每个种子文件第一行必须是 go test fuzz v1,后面每行一个参数值。例如:

文件 testdata/fuzz/FuzzParseAge/seed1

text
go test fuzz v1
string("0")

文件 testdata/fuzz/FuzzParseAge/seed2

text
go test fuzz v1
string("-1")

go test 会自动读取这些文件作为种子。

3. 失败语料自动持久化

当 Fuzz 发现一个新输入会触发失败,它会把该输入写到 testdata/fuzz/<TestName>/ 下,下次再跑 go test 时(即使不带 -fuzz)也会自动跑这个输入,确保 bug 不再回归。

来看一个具体案例。假设有这样一个有 bug 的解析函数:

go
package parser

import "strings"

// SplitFirst 把字符串按 sep 分割,返回第一部分。
// Bug:当 sep 是空字符串时会 panic。
func SplitFirst(s, sep string) string {
	idx := strings.Index(s, sep)
	return s[:idx]
}

Fuzz 测试:

go
package parser

import "testing"

func FuzzSplitFirst(f *testing.F) {
	f.Add("hello world", " ")
	f.Add("a,b,c", ",")
	f.Add("no-sep", "|")
	f.Fuzz(func(t *testing.T, s, sep string) {
		// 期望:函数不 panic
		_ = SplitFirst(s, sep)
	})
}

运行:

bash
go test -fuzz=FuzzSplitFirst -fuzztime=10s

很快就会发现 sep=""strings.Index 返回 0,s[:0] 不会 panic,但其他边界可能 panic。Fuzz 会把触发问题的输入保存下来:

text
--- FAIL: FuzzSplitFirst (0.00s)
    parser_test.go:12: panic: runtime error: slice bounds out of range [: -1] with length 5

    Failing input written to testdata/fuzz/FuzzSplitFirst/abc123

打开 testdata/fuzz/FuzzSplitFirst/abc123

text
go test fuzz v1
string("no-sep-here")
string("z")

意思是:当 s="no-sep-here"sep="z" 时会 panic,因为 strings.Index 返回 -1,s[:-1] 越界。

修复:

go
func SplitFirst(s, sep string) string {
	if sep == "" {
		return s
	}
	idx := strings.Index(s, sep)
	if idx < 0 {
		return s
	}
	return s[:idx]
}

下次运行 go test(不带 -fuzz)会自动跑 testdata/fuzz/FuzzSplitFirst/ 下的所有种子,确认 bug 已修复。

六、运行 Fuzz 测试

1. 默认运行:跑种子

不带 -fuzz 参数,go test 只会跑种子语料,相当于把每个种子当一次单元测试:

bash
go test -run=^FuzzParseAge$

这适合日常 CI:快速跑一遍,确保已知的失败用例不再回归。

2. 启动持续 Fuzz

-fuzz 才会进入真正的 Fuzz 模式,持续生成新输入:

bash
go test -fuzz=FuzzParseAge

这会一直跑,直到 Ctrl+C 或超时。建议加 -fuzztime

bash
# 跑 30 秒后退出
go test -fuzz=FuzzParseAge -fuzztime=30s

# 跑 100 万次迭代后退出
go test -fuzz=FuzzParseAge -fuzztime=100000x

3. Fuzz 相关参数

参数作用
-fuzz regexp启动 Fuzz 模式,只跑匹配的 Fuzz 函数
-fuzztime durationFuzz 持续时间(默认无限)
-fuzzminimizetime失败后最小化输入的时间
-run regexp即使 -fuzz 启动,也只跑匹配的种子单元测试
-fuzzcache sizeFuzz 缓存大小

4. 并发 Fuzz

默认情况下,-fuzz 只运行一个 Fuzz 函数,且使用单 goroutine。Fuzz 引擎内部已经做了并行(基于 worker),通常不需要手动管理。

5. 持续集成中的 Fuzz

CI 中通常:

  • 每次 PR:跑种子语料(快速),不启动持续 Fuzz。
  • 定时任务(每天/每小时):启动持续 Fuzz 跑 5-10 分钟,发现新 bug。

GitHub Actions 示例:

yaml
name: fuzz-nightly
on:
  schedule:
    - cron: '0 2 * * *'

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go test -fuzz=FuzzParseAge -fuzztime=5m

七、分析 Fuzz 发现的问题

Fuzz 发现问题时,输出大致如下:

text
--- FAIL: FuzzParseAge (0.00s)
    --- FAIL: FuzzParseAge (0.00s)
        parser_test.go:25: age -1 超出 [0, 150]
        
    Failing input written to testdata/fuzz/FuzzParseAge/abc123def456

    To re-run this specific input:
    go test -run=FuzzParseAge/abc123def456

分析步骤:

  1. 查看触发输入:打开 testdata/fuzz/FuzzParseAge/abc123def456,看到具体输入值。
  2. 复现:用 go test -run=FuzzParseAge/abc123def456 单独跑这个用例。
  3. 理解 root cause:手工把输入传给函数,看它如何走到错误路径。
  4. 修复:补上边界判断或修正逻辑。
  5. 回归:再跑一遍,确保失败用例不再 fail。

不要删除失败的种子文件。它是回归测试的一部分,下次跑测试会自动验证 bug 没有重现。即使你已经修复,也保留这个文件。

八、Fuzz 与单元测试的结合

Fuzz 不是单元测试的替代品,而是补充。两者的分工:

工具目的何时用
单元测试验证已知边界与逻辑所有函数,主力
Fuzz发现未知 bug解析、协议、字符串、加密等
基准测试性能基线热点路径

实战中,一个函数通常这样组织测试:

go
package parser

import (
	"strconv"
	"testing"
)

// 单元测试:明确边界
func TestParseAge(t *testing.T) {
	cases := []struct {
		input string
		want  int
		err   bool
	}{
		{"0", 0, false},
		{"18", 18, false},
		{"150", 150, false},
		{"151", 0, true},
		{"-1", 0, true},
		{"abc", 0, true},
	}
	for _, c := range cases {
		age, err := ParseAge(c.input)
		if c.err {
			if err == nil {
				t.Errorf("期望错误,input=%q", c.input)
			}
			continue
		}
		if err != nil {
			t.Errorf("未期望错误 input=%q: %v", c.input, err)
			continue
		}
		if age != c.want {
			t.Errorf("input=%q, got %d, want %d", c.input, age, c.want)
		}
	}
}

// Fuzz 测试:发现意外输入
func FuzzParseAge(f *testing.F) {
	f.Add("0")
	f.Add("150")
	f.Add("-1")
	f.Add("99999999999999999999") // 大整数
	f.Add("0x10")
	f.Add("1_000") // Go 1.13 数字分隔符

	f.Fuzz(func(t *testing.T, s string) {
		age, err := ParseAge(s)
		// 不变式 1:出错时 age 必须是 0
		if err != nil && age != 0 {
			t.Errorf("err 时 age 应为 0,input=%q age=%d", s, age)
		}
		// 不变式 2:成功时 age 必须在 [0, 150]
		if err == nil && (age < 0 || age > 150) {
			t.Errorf("age 越界 input=%q age=%d", s, age)
		}
		// 不变式 3:成功时 strconv.Itoa 应该能还原
		if err == nil {
			roundTrip := strconv.Itoa(age)
			if _, e := strconv.Atoi(roundTrip); e != nil {
				t.Errorf("往返失败 input=%q age=%d", s, age)
			}
		}
	})
}

九、实战 Fuzz 案例

下面给出两个完整的实战案例。

1. 字符串解析器 Fuzz

被测代码:一个简单的「key=value」解析器。

go
package kvparse

import (
	"errors"
	"strings"
)

var (
	ErrInvalidFormat = errors.New("invalid format")
	ErrEmptyKey      = errors.New("empty key")
)

// Parse 解析 "key=value" 格式字符串。
// 支持多个用逗号分隔的键值对,例如 "a=1,b=2,c=3"。
func Parse(s string) (map[string]string, error) {
	if s == "" {
		return map[string]string{}, nil
	}

	result := make(map[string]string)
	for _, pair := range strings.Split(s, ",") {
		idx := strings.Index(pair, "=")
		if idx < 0 {
			return nil, ErrInvalidFormat
		}
		key := pair[:idx]
		value := pair[idx+1:]
		if key == "" {
			return nil, ErrEmptyKey
		}
		result[key] = value
	}
	return result, nil
}

单元测试 + Fuzz:

go
package kvparse_test

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"example.com/kvparse"
)

func TestParse(t *testing.T) {
	t.Run("empty", func(t *testing.T) {
		m, err := kvparse.Parse("")
		require.NoError(t, err)
		assert.Empty(t, m)
	})

	t.Run("single", func(t *testing.T) {
		m, err := kvparse.Parse("a=1")
		require.NoError(t, err)
		assert.Equal(t, "1", m["a"])
	})

	t.Run("multiple", func(t *testing.T) {
		m, err := kvparse.Parse("a=1,b=2,c=3")
		require.NoError(t, err)
		assert.Equal(t, "1", m["a"])
		assert.Equal(t, "2", m["b"])
		assert.Equal(t, "3", m["c"])
	})

	t.Run("invalid format", func(t *testing.T) {
		_, err := kvparse.Parse("no_equals_sign")
		assert.Error(t, err)
	})

	t.Run("empty key", func(t *testing.T) {
		_, err := kvparse.Parse("=value")
		assert.Error(t, err)
	})

	t.Run("value with equals", func(t *testing.T) {
		m, err := kvparse.Parse("key=value=with=equals")
		require.NoError(t, err)
		assert.Equal(t, "value=with=equals", m["key"])
	})
}

func FuzzParse(f *testing.F) {
	// 种子
	seeds := []string{
		"",
		"a=1",
		"a=1,b=2,c=3",
		"=",
		"a=",
		"=b",
		"a=b,c=d",
		"key=value=with=equals",
		",,",
		"a=1,",
		",a=1",
		"=" + ",",
	}
	for _, s := range seeds {
		f.Add(s)
	}

	f.Fuzz(func(t *testing.T, s string) {
		// 不变式:函数不 panic(最关键)
		// Fuzz 主要就是测「不崩」
		m, err := kvparse.Parse(s)
		if err != nil {
			// 出错时 m 必须是 nil
			if m != nil {
				t.Errorf("err 时 m 应为 nil, input=%q", s)
			}
			return
		}

		// 不变式:m 非 nil
		if m == nil {
			t.Errorf("成功时 m 不应为 nil, input=%q", s)
		}

		// 不变式:所有键都非空
		for k := range m {
			if k == "" {
				t.Errorf("键不应为空, input=%q", s)
			}
		}
	})
}

跑 Fuzz:

bash
# 跑种子
go test -run=FuzzParse

# 启动持续 Fuzz,跑 1 分钟
go test -fuzz=FuzzParse -fuzztime=1m

很可能发现的 bug:包含 = 但前后有空字符串的输入行为是否符合预期?包含 , 但前后无 = 的输入应该如何处理?这些都是 Fuzz 帮你发现的设计模糊点。

2. JSON 解析 Fuzz

被测代码:解析一个 JSON 格式的配置。

go
package config

import (
	"encoding/json"
	"fmt"
)

type Config struct {
	Name    string `json:"name"`
	Version string `json:"version"`
	Timeout int    `json:"timeout"`
	Enabled bool   `json:"enabled"`
}

func Parse(data []byte) (Config, error) {
	var c Config
	if err := json.Unmarshal(data, &c); err != nil {
		return Config{}, fmt.Errorf("invalid json: %w", err)
	}
	if c.Name == "" {
		return Config{}, fmt.Errorf("name is required")
	}
	if c.Timeout < 0 {
		return Config{}, fmt.Errorf("timeout must be non-negative")
	}
	return c, nil
}

单元测试 + Fuzz:

go
package config_test

import (
	"encoding/json"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"example.com/config"
)

func TestParse(t *testing.T) {
	t.Run("valid", func(t *testing.T) {
		data := []byte(`{"name":"app","version":"1.0","timeout":30,"enabled":true}`)
		c, err := config.Parse(data)
		require.NoError(t, err)
		assert.Equal(t, "app", c.Name)
		assert.Equal(t, "1.0", c.Version)
		assert.Equal(t, 30, c.Timeout)
		assert.True(t, c.Enabled)
	})

	t.Run("missing name", func(t *testing.T) {
		data := []byte(`{"version":"1.0"}`)
		_, err := config.Parse(data)
		require.Error(t, err)
	})

	t.Run("negative timeout", func(t *testing.T) {
		data := []byte(`{"name":"app","timeout":-1}`)
		_, err := config.Parse(data)
		require.Error(t, err)
	})

	t.Run("invalid json", func(t *testing.T) {
		_, err := config.Parse([]byte(`not json`))
		require.Error(t, err)
	})
}

func FuzzParse(f *testing.F) {
	seeds := [][]byte{
		[]byte(`{"name":"app","version":"1.0","timeout":30,"enabled":true}`),
		[]byte(`{"name":"app"}`),
		[]byte(`{}`),
		[]byte(``),
		[]byte(`null`),
		[]byte(`[]`),
		[]byte(`"string"`),
		[]byte(`123`),
		[]byte(`{"name":"app","timeout":-1}`),
		[]byte(`{"name":"","timeout":100}`),
		[]byte(`{"name":"\u0000\u0001"}`), // 控制字符
	}
	for _, s := range seeds {
		f.Add(s)
	}

	f.Fuzz(func(t *testing.T, data []byte) {
		// 不变式:不 panic
		c, err := config.Parse(data)
		if err != nil {
			// 出错时 c 应该是零值(或至少 Name 为空,由调用方判断)
			if c.Name != "" {
				t.Errorf("err 时 Name 应为空,input=%q", data)
			}
			return
		}

		// 成功时 Name 必须非空(业务规则)
		if c.Name == "" {
			t.Errorf("成功时 Name 必须非空, input=%q", data)
		}

		// 成功时 Timeout 必须 >= 0
		if c.Timeout < 0 {
			t.Errorf("Timeout 应 >= 0, input=%q", data)
		}

		// 不变式:成功解析的 JSON,重新序列化应能再次解析
		reMarshalled, mErr := json.Marshal(c)
		if mErr != nil {
			t.Errorf("重新序列化失败: %v, input=%q", mErr, data)
			return
		}
		_, pErr := config.Parse(reMarshalled)
		if pErr != nil {
			t.Errorf("重新解析失败: %v, input=%q, remarshalled=%q", pErr, data, reMarshalled)
		}
	})
}

跑 Fuzz 几秒,会发现的潜在问题:

  • 输入 nulljson.Unmarshal 会把 c 设置为零值,Name 为空,会被业务校验拒绝——这是正确的。
  • 输入 123:JSON 数字也能 unmarshal 到 struct,但所有字段都是零值——会失败。
  • 输入包含超大整数:{"timeout": 99999999999999999999} 会触发 int 溢出,json.Unmarshal 会返回错误。
  • 输入包含嵌套结构(如把 name 字段值写成 JSON 对象而非字符串)会因类型不匹配报错。

这些都是 Fuzz 帮你验证「函数对所有输入都有定义良好的行为」的体现。

十、Fuzz 最佳实践

1. 种子要覆盖分支

种子至少要覆盖每个分支:

  • 正常路径(几个典型输入)
  • 边界(空、最大、最小)
  • 异常路径(错误格式)
  • 特殊字符(控制字符、emoji、超长字符串)
go
f.Add("")
f.Add("hello")
f.Add(strings.Repeat("a", 10000))
f.Add("\x00\x01\x02") // 控制字符
f.Add("🎉 unicode emoji")

2. 不变式是 Fuzz 的核心

Fuzz 函数不应该断言「具体值」,而应断言「不变式」——对任何输入都成立的事实:

  • 函数不 panic。
  • 出错时返回零值。
  • 成功时数据满足业务约束(如年龄在 [0, 150])。
  • 往返一致性:解析后再序列化,能再被解析。
  • 内存安全:无越界、无泄漏。
  • 时间安全:在合理时间内返回(用 -fuzztime 控制)。

如果不变式太严苛(如「输入长度必须等于输出长度」),Fuzz 会反复触发假阳性。要找到「对任意输入都应成立」的最弱条件。

3. 不要在 Fuzz 里调用其他需要种子的库

Fuzz 引擎优化的是当前函数的输入,如果你在 Fuzz 函数里调用了 json.Unmarshal 又自己解析结果,输入空间会被放大,效率下降。只 Fuzz 自己的函数

4. 持续 Fuzz 不要跑太久

默认 Fuzz 会无限运行。在本地开发时,建议 -fuzztime=30s1m,避免占用 CPU。CI 中可以跑 5-10 分钟。

5. Fuzz 不是单元测试的替代

不能因为「我跑过 Fuzz」就跳过单元测试。Fuzz 发现的 bug 通常是「未定义行为」,而单元测试验证的是「已定义行为」。两者互补,不可替代。

6. 测试代价高的函数不适合 Fuzz

如果函数执行需要 1 秒,Fuzz 一分钟只能跑 60 个输入,意义不大。Fuzz 适合「快」的函数(微秒级)。

7. 复杂对象用「参数序列化」

如果想 Fuzz 一个复杂对象(如配置结构体),把它的输入表示为 []bytestring,在 Fuzz 函数里反序列化:

go
func FuzzProcessConfig(f *testing.F) {
	f.Add([]byte(`{"key":"value"}`))
	f.Fuzz(func(t *testing.T, data []byte) {
		var cfg Config
		if err := json.Unmarshal(data, &cfg); err != nil {
			return // 无效 JSON 跳过
		}
		// 测试 ProcessConfig(cfg)
	})
}

十一、Fuzz 的局限

Fuzz 不是银弹,它的局限:

  1. 只能发现「会导致失败」的 bug:逻辑错误(如算错)不会被 Fuzz 发现,因为它不知道正确答案。
  2. 覆盖率引导,但有上限:Fuzz 引擎会优先探索新代码路径,但深度嵌套的逻辑可能难以触发。
  3. 速度依赖函数性能:慢函数 Fuzz 效率低。
  4. 结构化输入需要预编码:Fuzz 引擎不会生成「合法的 JSON」或「合法的 SQL」,只会做字节级变异。
  5. 不支持所有类型:自定义类型、接口、chan 都不能作为 Fuzz 参数。

十二、小结

本篇系统讲解了 Go Fuzz Testing,核心要点:

  1. Fuzz 是单元测试的补充:发现未知输入导致的 panic、契约违反。
  2. FuzzXxx(f *testing.F):Fuzz 函数约定,参数为 *testing.F
  3. f.Add(...):添加种子输入,参数类型必须是支持的原始类型。
  4. f.Fuzz(func(t *testing.T, args...), ...):注册回调,参数类型与 Add 对应。
  5. 种子语料库:内联 f.Addtestdata/fuzz/ 文件,失败的输入会自动持久化。
  6. 运行go test -fuzz=FuzzX -fuzztime=30s 启动持续 Fuzz;不带 -fuzz 只跑种子。
  7. 不变式是核心:Fuzz 函数应断言「对所有输入都成立的事实」,而不是具体值。
  8. 限制:不能发现逻辑错误,对慢函数效率低,不支持自定义类型。

下一篇我们将进入集成测试与 testcontainers-go,学习如何用真实的数据库容器做更接近生产的集成测试。


Fuzz 是「自动化测试」的极致形态——把开发者从「枚举所有边界」的繁琐中解放出来,让机器帮你探索未知的输入空间。把 Fuzz 加入你的 CI,给关键解析函数加一份 5 分钟的定时 Fuzz,是提升代码健壮性的高性价比投资。