Fuzz-тестирование: как ломать вход мусором и находить то, что руками не поймать
Ручные тест-кейсы проверяют то, что ты придумал. А баги живут ровно там, где ты НЕ придумал: в кривом, огромном, пустом, невалидном вводе, до которого нормальный человек не додумается. Фаззинг — это способ автоматически завалить программу таким мусором и посмотреть, где она сломается.
Звучит как «просто вводить всякую дичь», но за этим стоит вполне инженерная механика. Разберём по-человечески.
Что такое фаззинг
Фаззер генерирует тысячи и миллионы вариантов ввода — случайных, мутированных из реальных примеров или собранных по грамматике — скармливает их программе и следит, не упадёт ли она. Ключевое отличие от обычного теста: ты не проверяешь «правильный ли ответ». Ты ловишь факт поломки — краш, зависание, необработанное исключение, срабатывание санитайзера памяти, таймаут.
Поэтому у фаззинга другой оракул (что считать багом). Варианты: сам факт падения/санитайзера; дифференциальный — скормить один вход двум реализациям (например, двум JSON-парсерам) и сравнить; property-based — проверять инварианты, которые обязаны держаться на ЛЮБОМ входе (разобрал → собрал = то же самое; сортировка не теряет элементы).
Виды — от тупого к умному
Тупой (dumb) фаззинг. Кидаем случайные байты или мутируем валидный пример наугад. Быстро заводится, но глубоко в логику не залезает — отскакивает на первой же проверке формата.
Coverage-guided (умный). libFuzzer, AFL++ следят за покрытием кода: вход, открывший новую ветку, оставляют и мутируют дальше. Так фаззер эволюционно доходит до глубоко спрятанных путей, куда наугад попасть нереально. Это рабочая лошадка серьёзного фаззинга.
Property-based. Hypothesis, fast-check, jqwik генерируют входы под твои свойства и, найдя падение, сжимают его до минимального контрпримера («ломается на пустой строке» вместо «ломается на вот этой простыне из 900 символов»). Это фаззинг, который реально удобно держать в CI на бизнес-логике.
Где QA это реально применяет
Не только «для сишников с санитайзерами». Смотри на свои входные границы:
- Парсеры и десериализация — JSON/XML/CSV/protobuf, всё, что принимает данные извне. Классика падений.
- Загрузка файлов — битые/огромные/поддельные форматы, неверные magic bytes, глубоко вложенный XML (billion laughs).
- API-эндпоинты — фаззить тела и параметры по OpenAPI-схеме: часто вылезает 500 там, где должен быть аккуратный 400.
- Поля ввода и формы — unicode/эмодзи/RTL, null-байты, сверхдлинные строки, отрицательные и граничные числа.
- Чистая бизнес-логика — пагинация, конвертация валют/дат, скидки: property-based ловит переполнения и «потерянные» элементы.
Что фаззинг находит, а ручные кейсы — почти никогда
Краши на пустом и на гигантском вводе, integer overflow, off-by-one, необработанные исключения (500 вместо 400), утечки и повреждения памяти, зависания на патологическом вводе, рассинхрон двух реализаций. Всё это — «а кто ж так введёт?» — ровно то, что прилетает на проде от реального мира и злоумышленника.
Инструменты
Coverage-guided: libFuzzer, AFL++ (нативщина), go-fuzz, Jazzer (JVM), Atheris (Python), cargo-fuzz (Rust).
Property-based: Hypothesis (Python), fast-check (JS/TS), jqwik (Java), PropEr/QuickCheck.
API по схеме: Schemathesis (по OpenAPI), RESTler, Burp Intruder. Для непрерывного фаззинга опенсорса — OSS-Fuzz / ClusterFuzz.
Как подступиться (практика)
- Начни с property-based на чистую логику — Hypothesis/fast-check дёшевы и живут прямо в CI.
- Для API возьми Schemathesis по OpenAPI — минимум усилий, сразу ловит необработанные 500.
- Дай хорошие сиды — реальные валидные примеры резко ускоряют coverage-guided фаззер.
- Включи санитайзеры (ASan/UBSan) для нативного кода — иначе баг молча проглотится и фаззер его не заметит.
- Каждый крэш → регресс-тест: сохрани падающий вход в репозиторий, чтобы баг не вернулся.
- Метрика — покрытие и найденные краши, а не «прогнали N миллионов раз». Миллион прогонов по одной ветке ничего не значит.
Почитать: Google — OSS-Fuzz · Schemathesis (API по OpenAPI) · Hypothesis (property-based, Python) · AFL++