Модуль: Тестирование · Уровень: Middle+/Senior
TL;DR#
Fuzzing — автоматическая генерация входных данных, нацеленная на расширение покрытия кода (coverage-guided), чтобы найти входы, на которых код паникует, виснет или нарушает инварианты. С Go 1.18 fuzzing встроен в testing: функция FuzzXxx(f *testing.F), seed corpus через f.Add, тело — f.Fuzz(func(t *testing.T, ...){...}). Движок мутирует входы, отслеживая покрытие; найденный падающий вход автоматически минимизируется и сохраняется в testdata/fuzz/FuzzXxx/ как регрессионный кейс. Лучше всего ловит панику на парсерах/декодерах, нарушения round-trip инвариантов (encode→decode) и расхождения с reference-реализацией (differential). Запуск: go test -fuzz=FuzzXxx; без -fuzz fuzz-тест прогоняет только seed corpus как обычный тест.
Теория#
Структура fuzz-теста#
func FuzzParseQuery(f *testing.F) {
// seed corpus — примеры валидных/интересных входов
f.Add("a=1&b=2")
f.Add("")
f.Add("key=%20value")
f.Fuzz(func(t *testing.T, raw string) {
// не должно паниковать ни на каком входе
v, err := ParseQuery(raw)
if err != nil {
return // ошибка — ок, паника/виснет — нет
}
_ = v
})
}f.Add(args...)добавляет seed — стартовые входы. Типы аргументовf.AddДОЛЖНЫ совпадать с типами параметровf.Fuzz(после*testing.T).f.Fuzz(fn)— фаззинг-таргет. Первый параметр всегда*testing.T, дальше — фаззируемые аргументы.- Поддерживаемые типы аргументов:
[]byte,string,int/int8..64,uint/…,float32/64,bool,rune,byte. Структуры/срезы напрямую нельзя — кодируйте сложные входы в[]byteи десериализуйте внутри.
Два режима запуска#
- Без
-fuzz(обычныйgo test): fuzz-функция выполняется как обычный тест — прогоняется только seed corpus (изf.Add+ файлов вtestdata/fuzz/...). Это делает fuzz-тесты частью CI как регрессионные тесты, без бесконечной генерации. - С
-fuzz(go test -fuzz=FuzzParseQuery): запускается движок мутаций, генерирующий новые входы бесконечно (или до-fuzztime=30s/-fuzztime=1000x). Работает с одним пакетом и одной fuzz-функцией за раз.
go test -fuzz=FuzzParseQuery -fuzztime=30s ./...Corpus: seed vs generated#
- Seed corpus — то, что вы пишете руками (
f.Add) и кладёте вtestdata/fuzz/FuzzXxx/. Версионируется в git, обязателен в CI. - Generated corpus — кэш «интересных» входов (расширивших покрытие), который движок копит в
$GOCACHE/fuzz/. Не версионируется, локален, переживает между прогонами на машине разработчика. - Когда фаззер находит падение, он минимизирует вход (урезает до минимального воспроизводящего) и сохраняет файл в
testdata/fuzz/FuzzXxx/<hash>. Этот файл — теперь регрессионный кейс: его нужно закоммитить, и при следующем обычномgo testон автоматически прогонится.
Формат файла corpus:
go test fuzz v1
string("a=1&b=\x00")Что искать фаззингом: классы инвариантов#
1. Отсутствие паники / зависания — базовый минимум для любого парсера, декодера, входа из недоверенной сети.
2. Round-trip (encode/decode идемпотентность):
func FuzzMarshalRoundTrip(f *testing.F) {
f.Add([]byte(`{"a":1}`))
f.Fuzz(func(t *testing.T, data []byte) {
var v Config
if err := json.Unmarshal(data, &v); err != nil {
return
}
out, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal of valid value failed: %v", err)
}
var v2 Config
if err := json.Unmarshal(out, &v2); err != nil {
t.Fatalf("re-unmarshal failed: %v", err)
}
if !reflect.DeepEqual(v, v2) {
t.Errorf("round-trip mismatch: %#v != %#v", v, v2)
}
})
}3. Differential (сравнение с эталоном): новая оптимизированная реализация должна давать тот же результат, что старая/reference на любом входе.
f.Fuzz(func(t *testing.T, in []byte) {
if got, want := FastParse(in), SlowReferenceParse(in); !bytes.Equal(got, want) {
t.Errorf("divergence on %q", in)
}
})4. Инварианты предметной области: результат Sort отсортирован и содержит ту же мультимножество элементов; после Validate всегда выполняется некий предикат, и т.п.
Минимизация#
При падении движок пытается сократить вход, сохраняя факт падения, — это и про размер (меньше байт), и про «простоту» (печатные символы). Минимизированный кейс гораздо удобнее для отладки. Можно отключить/настроить через -fuzzminimizetime.
Интеграция в CI#
- Seed corpus прогоняется любым
go test ./...— бесплатная регрессия. - Активный fuzzing долгий и недетерминированный по времени → выносят в отдельный nightly/scheduled job с
-fuzztime, а не в основной PR-pipeline. Найденные кейсы коммитятся обратно вtestdata. - OSS-Fuzz и подобные сервисы дают непрерывный фаззинг с откормленным corpus.
Подводные камни / gotchas#
- Типы
f.Addобязаны совпадать с сигнатуройf.Fuzz— иначе паника при запуске. - Ограниченный набор фаззируемых типов. Сложные структуры кодируйте в
[]byte/stringи парсите внутри; иначе придётся самому конструировать вход из примитивов. -fuzzгоняет ровно одну функцию в одном пакете. Нельзя зафаззить весь репозиторий разом.- Generated corpus не версионируется — он в
GOCACHE. На другой машине/в CI его нет; воспроизводимость держится на seed + закоммиченных кейсах изtestdata. - Недетерминированность и время. Fuzzing без
-fuzztimeбесконечен. Без бюджета времени он не подходит как блокирующий шаг PR. - Таргет должен быть детерминированным и без сайд-эффектов (сеть, файлы, время, рандом, общее состояние). Иначе «падение» невоспроизводимо и движок не сможет минимизировать.
- Медленный таргет = мало итераций. Фаззинг эффективен на тысячах входов/сек; тяжёлый I/O в таргете убивает coverage-рост.
- Coverage-guided ≠ всезнающий. Фаззер хорош на байтовых/строковых входах с богатой структурой ветвлений; для глубоких семантических инвариантов нужны хорошие seed и проверки (assertions) внутри таргета — без них он лишь проверяет «не паникует».
- OOM/таймауты как «находки». Аллокация по размеру входа без лимита приведёт к OOM на больших мутациях — иногда это реальный баг (DoS), иногда нужно ограничить размер входа в начале таргета.
Вопросы на собеседовании#
В: Чем fuzzing отличается от property-based и table-driven тестов? О: Table-driven проверяет конкретные заранее заданные входы. Property-based проверяет инварианты на случайных входах из заданного генератора. Fuzzing — это coverage-guided генерация: движок мутирует входы, отслеживая покрытие кода, чтобы целенаправленно достигать новых веток и находить crash-входы. На практике Go-фаззинг совмещает оба: вы задаёте инварианты (как в property-based), а движок умно генерирует входы.
В: Что происходит при обычном go test без флага -fuzz?
О: Fuzz-функция выполняется как обычный тест и прогоняет только seed corpus: входы из f.Add и файлы в testdata/fuzz/FuzzXxx/. Генерация новых входов не запускается. Это делает найденные ранее crash-кейсы регрессионными тестами в CI.
В: Где живёт corpus и что коммитить в git?
О: Seed corpus (f.Add + testdata/fuzz/...) версионируется. Generated corpus движок кэширует в $GOCACHE/fuzz и его не коммитят. Когда фаззер находит падение, он минимизирует вход и кладёт файл в testdata/fuzz/FuzzXxx/ — этот файл нужно закоммитить как регрессию.
В: Какие классы багов хорошо ловит fuzzing? О: Паники и зависания на недоверенном входе (парсеры, декодеры), нарушения round-trip (encode→decode≠original), расхождения с эталонной реализацией (differential), нарушения доменных инвариантов (сортировка, валидация). Плюс OOM/DoS на неограниченных по размеру входах.
В: Какие типы можно фаззить и что делать со сложными входами?
О: Примитивы: []byte, string, целые/беззнаковые, float32/64, bool, rune, byte. Структуры напрямую нельзя — кодируют сложный вход в []byte/string и десериализуют внутри таргета, либо собирают из нескольких примитивных аргументов.
В: Почему fuzz-таргет должен быть детерминированным? О: Движку нужно надёжно воспроизводить падение, чтобы минимизировать вход и сохранить регрессию. Недетерминизм (сеть, время, рандом, общее состояние, конкурентность) делает падение невоспроизводимым и ломает минимизацию.
В: Как встроить fuzzing в CI без бесконечного прогона?
О: В PR-pipeline гонять только seed corpus обычным go test (регрессия, быстро). Активный фаззинг с -fuzztime вынести в nightly/scheduled job или внешний сервис (OSS-Fuzz), а найденные кейсы коммитить обратно в testdata.
На что копают на senior+#
- Дизайн таргета: какие инварианты выбрать (round-trip, differential, доменные), как сделать таргет детерминированным и быстрым, как ограничить размер входа против OOM.
- Coverage-guided внутри: понимание, что движок инструментирует код и оптимизирует покрытие; почему seed corpus критичен для проникновения в глубокие ветки.
- Corpus management: разделение seed/generated, версионирование регрессий, кэш в GOCACHE, перенос corpus между машинами.
- Интеграция: место фаззинга в pipeline, nightly vs PR, OSS-Fuzz, бюджеты времени, триаж находок.
- Сравнение инструментов: нативный go-fuzz vs исторический dvyukov/go-fuzz, libFuzzer-подход; когда фаззинг бессилен (семантика без хороших assertions).
- Безопасность: фаззинг как защита для всего, что парсит недоверенный вход (сетевые протоколы, форматы файлов, десериализация).