Модуль: Тестирование · Уровень: 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 и десериализуйте внутри.

Два режима запуска#

  1. Без -fuzz (обычный go test): fuzz-функция выполняется как обычный тест — прогоняется только seed corpus (из f.Add + файлов в testdata/fuzz/...). Это делает fuzz-тесты частью CI как регрессионные тесты, без бесконечной генерации.
  2. С -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).
  • Безопасность: фаззинг как защита для всего, что парсит недоверенный вход (сетевые протоколы, форматы файлов, десериализация).