JavaRush /Курси /Go SELF /Fuzz‑тести в Go: що таке FuzzXxx

Fuzz‑тести в Go: що таке FuzzXxx

Go SELF
Рівень 31 , Лекція 4
Відкрита

1. Навіщо потрібні fuzz‑тести

Після кількох десятків звичайних тестів може з’явитися відчуття: «Ну все, код уже протестовано». І тут реальність ввічливо киває та підсовує вхід на кшталт " \t \n-0000000000000000000000007", або рядок із нульовими байтами, або просто дуже довгий текст. Саме для таких випадків і вигадали fuzz‑тести.

Якщо дивитися концептуально, fuzzing належить до «random testing»: ми не обираємо 10 заздалегідь відомих варіантів, а запускаємо автоматичну генерацію великої кількості входів, щоб спробувати намацати край. Це особливо корисно в задачах, де «погані входи» трапляються частіше, ніж здається, а помилки проявляються химерно: панікою, зависанням, переповненням або некоректною нормалізацією.

Важливо: fuzz‑тести вбудовано у стандартний go test (підтримка з’явилася в Go 1.18 і надалі розвивалася).

Чим fuzz‑тест відрізняється від unit‑тесту

Коли ви пишете звичайний тест, ви ніби режисер: обираєте сцену, героїв і репліки. Це чудово працює, коли кількість варіантів невелика й зрозуміла. Наприклад, Add(1,2)=3 — прекрасно. Але щойно функція починає приймати рядки, «сцен» стає надто багато: пробіли, табуляції, мінуси, плюси, порожнеча, сміттєві символи, величезна довжина, різні мови, несподівані роздільники.

Fuzz‑тест — це коли ви лишаєтеся режисером лише в одному: формулюєте правило, яке має бути істинним завжди (або «майже завжди» за дотримання передумов). А далі входи вам добирає генератор: він підсовує величезну кількість варіантів і перевіряє, чи не порушили ви це правило.

Хороша ментальна модель така:

  • unit‑тест перевіряє «конкретний приклад → конкретна відповідь»;
  • fuzz‑тест перевіряє «для множини входів виконується властивість (інваріант)».

Ключове слово тут — інваріант.

Інваріант: як придумати перевірку, яку не соромно запускати

Інваріант — це твердження про поведінку функції, яке має залишатися істинним для всіх входів у допустимому діапазоні. Звучить страшно, але на практиці це часто дуже прості речі. Наприклад: «Якщо парсер успішно розпізнав число, то зворотне перетворення туди‑назад не має змінювати значення». Або: «Нормалізатор рядка має бути ідемпотентним: якщо застосувати його двічі, результат має бути таким самим, як після одного разу».

Найчастіша помилка новачка — намагатися зробити інваріант надто «розумним», ніби ви пишете математичну теорему для докторської дисертації. Не треба. Нам потрібні властивості, які водночас:

1) справді зобов’язані виконуватися за контрактом функції,
2) легко перевіряються в коді,
3) мають сенс за великої кількості входів.

Щоб було простіше, ось невелика таблиця з прикладами «здорових» інваріантів:

Тип функції Приклад інваріанта Чому це зручно
Нормалізація рядка
Normalize(s) == Normalize(Normalize(s))
Не треба заздалегідь знати «ідеальний текст» — перевіряємо стабільність результату
Парсинг/форматування
якщо Parse(s) успішний, то Parse(Format(x)) повертає x
Fuzz сам знайде дивні форми запису
Пошук/фільтрація
len(Filter(xs)) <= len(xs)
Найпростіша гарантія, яка допомагає ловити «роздування» результату
Валідація
якщо функція повернула nil, то повторна валідація теж nil
Ловить нестабільні перевірки

Далі візьмемо два дуже практичні приклади для навчального застосунку.

2. Tasker: нормалізація заголовка

Щоб приклади не були абстрактними, уявімо, що в нас є навчальний застосунок Tasker — міні-менеджер завдань. Його завдання просте: зберігати завдання, а користувачеві дозволяти вводити заголовки як завгодно. До сховища ж потрібно класти акуратний, нормалізований варіант.

Наприклад, користувач ввів " купити молоко ", а ми хочемо зберігати "купити молоко". Тобто треба обрізати пробіли з країв і згорнути всі послідовні пробільні символи в один пробіл.

Функція NormalizeTitle

Зробімо функцію NormalizeTitle. Вона спеціально маленька: ідеально підходить, щоб відчути fuzzing без болю.

package tasker

import "strings"

func NormalizeTitle(s string) string {
	parts := strings.Fields(s)
	return strings.Join(parts, " ")
}

strings.Fields ріже рядок за будь-яким whitespace — пробілами, табуляціями, переведеннями рядка, — а Join склеює все назад одним пробілом. Виходить проста й доволі надійна нормалізація.

Звичайним тестом ми могли б перевірити кілька випадків: порожній рядок, пробіли, табуляції, звичайний текст. Але fuzz‑тест дозволяє перевірити це на тисячах варіантів, включно з «кашею», яку ви не придумаєте спеціально. І ще й заощаджує час та нерви.

Каркас FuzzXxx(f *testing.F)

З технічного погляду fuzz‑тест — це функція в *_test.go, яка починається з Fuzz, приймає *testing.F, а всередині викликає f.Fuzz(...). За формою вона трохи ближча до тестового раннера, ніж до звичайного TestXxx.

Спочатку — мінімальний каркас без інваріанта, просто щоб побачити форму:

package tasker

import "testing"

func FuzzNormalizeTitle(f *testing.F) {
	f.Fuzz(func(t *testing.T, s string) {
		_ = NormalizeTitle(s)
	})
}

Навіть такий fuzz‑тест уже корисний, бо він шукає найпростіше зло — паніки. Якщо NormalizeTitle колись упаде на якомусь вході, фаззинг постарається цей вхід знайти.

Але ми можемо — і повинні — додати інваріант. Інакше все перетворюється на перевірку «програма не падає»: корисно, але занадто бідно за змістом.

Seeds (f.Add) та ідемпотентність

Тепер зробімо fuzz‑тест змістовнішим. Почнімо з f.Add(...): це стартові приклади, які фаззинг використовує як «входи-насіння» (seed inputs). Це не звичайні тест-кейси в сенсі «важливі тільки вони». Це радше стартові точки, від яких генератор буде мутувати входи.

Інваріант візьмемо дуже практичний: нормалізація має бути ідемпотентною.

Тобто:

NormalizeTitle(s) == NormalizeTitle(NormalizeTitle(s))

Код:

package tasker

import "testing"

func FuzzNormalizeTitle(f *testing.F) {
	f.Add("")
	f.Add("   ")
	f.Add("купити    молоко")

	f.Fuzz(func(t *testing.T, s string) {
		a := NormalizeTitle(s)
		b := NormalizeTitle(a)
		if a != b {
			t.Fatalf("не ідемпотентно: %q -> %q -> %q", s, a, b)
		}
	})
}

Тут важливо відчути думку: фаззинг буде підсовувати рядки s, зокрема дивні, а ви перевіряєте, що ваш «санітайзер» стабільний. Якщо він нестабільний — це тривожний сигнал: або логіка неправильна, або ви випадково додали ефект, який змінює рядок щоразу. Таке теж буває, особливо в логуванні та генерації ID.

3. Tasker: парсинг ID та round‑trip

Тепер візьмімо ще більш класичний кейс для fuzzing: парсер чисел. Нехай у Tasker завдання мають цілочисельний ID, а користувач вводить його в CLI рядком. Ми хочемо:

  • ParseTaskID(" 42 ") повертав 42;
  • на сміттєвих даних повертав помилку;
  • на валідному вводі працював стабільно.

Функція ParseTaskID

Реалізація:

package tasker

import (
	"strconv"
	"strings"
)

func ParseTaskID(s string) (int, error) {
	s = strings.TrimSpace(s)
	return strconv.Atoi(s)
}

А тепер fuzz‑інваріант. Класика: якщо парсинг успішний, то перетворення туди‑назад не змінює значення.

Інакше кажучи:

  • якщо n, err := ParseTaskID(s) і err == nil,
  • то ParseTaskID(strconv.Itoa(n)) теж успішний і повертає n.

Fuzz‑тест:

package tasker

import (
	"strconv"
	"testing"
)

func FuzzParseTaskID(f *testing.F) {
	f.Add("0")
	f.Add("42")
	f.Add("-7")

	f.Fuzz(func(t *testing.T, s string) {
		n, err := ParseTaskID(s)
		if err != nil {
			return
		}

		s2 := strconv.Itoa(n)
		n2, err2 := ParseTaskID(s2)
		if err2 != nil || n2 != n {
			t.Fatalf("round-trip failed: %q -> %d -> %q -> (%d, %v)", s, n, s2, n2, err2)
		}
	})
}

Зверніть увагу на дуже го-шний підхід: якщо вхід не підходить, а парсер повернув помилку, ми просто виходимо return. Це нормально: фаззинг не зобов’язаний робити валідними всі входи. Наша мета — перевірити властивість на валідній підмножині входів, а не змусити парсер приймати все підряд.

Як запускати фаззинг і що ви побачите

До цього моменту ми писали код, який виглядає майже як звичайний тест. Але запускається він інакше: фаззинг — це окремий режим виконання тестів.

Базова команда така:

go test -fuzz=FuzzNormalizeTitle

Якщо у вас у пакеті багато звичайних тестів, а ви хочете зосередитися саме на фаззингу, часто запускають так — бо -run ви вже знаєте:

go test -run=^$ -fuzz=FuzzNormalizeTitle

Сенс -run=^$ простий: не запускати звичайні Test..., бо регулярний вираз ^$ не збігається з жодною нормальною назвою тесту. Це не обов’язково, але інколи пришвидшує ітерації, коли вам хочеться окремо поганяти фузер.

Щоб не губитися, корисно тримати в голові процес у вигляді схеми:

flowchart TD
    A["входи-насіння: f.Add(...)"] --> B[генератор варіантів входу]
    B --> C[ваша функція]
    C --> D{інваріант виконується?}
    D -->|так| B
    D -->|ні / panic| E[зберегти вхід і показати звіт]

Ваша робота як розробника — обрати інваріант і зробити так, щоб у разі його порушення повідомлення було зрозумілим (t.Fatalf(...)). Інакше ви отримаєте «знайшов баг, але я не зрозумів де», а це майже те саме, що й «не знайшов баг», тільки з додатковими стражданнями.

Що робити, якщо фаззинг знайшов проблему

Найприємніше у фаззингу — не те, що він «спробував тисячу рядків». Найприємніше те, що він може знайти вхід, про який ви б не подумали навіть після третьої чашки кави, і зберегти його так, щоб баг можна було відтворити.

Типовий сценарій виглядає так:

  • ви запускаєте go test -fuzz=FuzzParseTaskID;
  • фузер знаходить вхід, який призводить до падіння або до порушення інваріанта;
  • go test друкує цей вхід і, як правило, зберігає його для повторного відтворення.

І тут важлива інженерна звичка: перетворити знайдений вхід на регресійний тест. Бо фаззинг — штука ймовірнісна: сьогодні він швидко знайшов баг, завтра ви змінили код, і баг проявиться лише через 20 хвилин. Краще зафіксувати.

Найпростіший підхід, який новачку достатньо знати: додати знайдений рядок як ще один seed через f.Add("..."), а інколи ще й як окремий звичайний Test..., якщо вхід короткий і явно важливий. Тоді під час наступного запуску фаззинг одразу почне з цього кейсу.

Приклад: припустімо, фузер знайшов, що рядок "--1" призводить до дивної поведінки. Тоді ви фіксуєте:

func FuzzParseTaskID(f *testing.F) {
	f.Add("--1") // знайдений проблемний випадок, тепер він у нас назавжди
	// ...
}

Це і є принцип: спіймали баг → зробили його відтворюваним → більше не загубите.

4. Типові помилки під час роботи з fuzz‑тестами

Помилка №1: сприймати фаззинг як заміну unit‑тестам.
Fuzz‑тести чудово знаходять несподівані входи та класи падінь, але вони не замінюють звичайні тести зі зрозумілими прикладами. Unit‑тести залишаються вашим «контрактом у лоб»: читабельним, детермінованим, таким, що пояснює бізнес-правила. Фаззинг — це радше crash‑тест і перевірка загальних властивостей.

Помилка №2: писати інваріант, який насправді не зобов’язаний бути істинним.
Наприклад, для нормалізації рядка можна випадково вимагати, щоб результат завжди був непорожнім. Але порожній заголовок завдання може бути валідним входом, який ви окремо обробляєте. Якщо інваріант занадто сильний, фузер буде чесно знаходити «помилки», які помилками не є, а є лише наслідком вашого неправильного формулювання.

Помилка №3: забути про передумови та сваритися на помилки парсингу.
У fuzz‑тесті для парсера нормально написати if err != nil { return }. Якщо ви замість цього робите t.Fatalf на будь-якій помилці, ви перетворюєте фаззинг на беззмістовну перевірку «усі рядки мають бути числами». Вони не мають. Вони мають або розпарситися, або дати акуратну помилку — і все.

Помилка №4: робити недетермінований інваріант.
Якщо всередині перевірки ви використовуєте поточний час, глобальні лічильники, випадкові числа або залежність від порядку ітерації map, фаззинг почне плавати: один і той самий вхід то проходить, то падає. Це один із найнеприємніших видів багів. Інваріант має бути відтворюваним: однаковий вхід — однаковий результат перевірки.

Помилка №5: намагатися запхати у фаззинг «усю систему цілком».
Новачку дуже хочеться: «А давайте фузити весь CLI!» На практиці фаззинг найкраще працює на невеликих чистих функціях: парсингу, нормалізації, кодеках, валідації. Чим менше побічних ефектів — файлів, мережі, оточення — тим більше користі й тим менше загадкових падінь не за призначенням.

1
Опитування
go test глибше, рівень 31, лекція 4
Недоступний
go test глибше
go test глибше
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ