Foundry: допущения и ограничение диапазона fuzzing
В предыдущем уроке положительное свойство исключило нулевой номер и отпечаток. Теперь рассмотрим ситуацию, где нам нужны небольшие номера для учебной последовательности. Если просто потребовать, чтобы случайный uint256 находился между 1 и 20, почти все входы окажутся отброшенными.
Сравним два инструмента: vm.assume задаёт допустимость выбранного входа, а вспомогательный bound преобразует число в заданный диапазон. Подготовим отдельное свойство независимости двух курсов. Сценарий пока не исполняется; мы обсуждаем устройство генерации, а не полученную статистику.
Допущение не преобразует величину
Вызов vm.assume(condition) сообщает среде, что конкретный вариант подходит свойству, только если условие истинно. Сам номер остаётся прежним. Поэтому допущение id != 0 не заменяет ноль единицей: оно исключает такой вход из рассматриваемой положительной области.
Для всего uint256 исключение одной величины обычно не создаёт проблему огромного отбрасывания. Однако требование id <= 20 && id > 0 оставляет чрезвычайно малую часть пространства. Генератор может столкнуться с лимитом отклонённых вариантов и не получить содержательную выборку для заданного свойства.
Увеличение лимита отбрасывания не исправляет плохо выбранную модель. Сначала нужно понять, почему свойство ограничено двадцатью номерами и можно ли получить их прямым преобразованием. Если небольшой диапазон нужен только ради удобства примера, он не должен незаметно заменить общую проверку полного контракта.
Bound задаёт новое значение
В forge-std доступен помощник bound(value, min, max). Он возвращает величину внутри включительных границ. Исходный аргумент генератора и используемый номер после такого преобразования могут различаться; это следует отражать в именах переменных и будущем разборе контрпримера.
function testFuzzSmallCatalog(
uint256 rawId,
bytes32 digest
) public {
uint256 id = bound(rawId, 1, 20);
vm.assume(digest != bytes32(0));
vm.prank(publisher);
catalog.publish(id, digest);
assertCourse(id, digest, true);
}
Здесь rawId — вход функции, а id — прикладной ключ. Переименование помогает избежать ошибки, когда публикация использует преобразованное значение, а getter случайно читает исходное. Сравнение тогда исследовало бы несовпадение подготовки, а не требование сохранения записи.
Края 1 и 20 входят в область. Не стоит описывать преобразование как обещание равномерного перебора всех чисел: это способ получить допустимую величину, а свойства распределения генератора требуют отдельного анализа. Для нашего сценария существенны ясная область и отсутствие почти полного отбрасывания.
Два курса не должны мешать друг другу
Разовьём пример. Хотим опубликовать два разных номера и убедиться, что каждый хранит собственный отпечаток. Преобразуем оба ключа в небольшой диапазон и исключим только их совпадение. Такое допущение связано с самим свойством независимости, а не с произвольной прихотью автора.
function testFuzzDistinctCoursesStayIndependent(
uint256 rawFirst,
uint256 rawSecond,
bytes32 firstDigest,
bytes32 secondDigest
) public {
uint256 firstId = bound(rawFirst, 1, 20);
uint256 secondId = bound(rawSecond, 1, 20);
vm.assume(firstId != secondId);
vm.assume(firstDigest != bytes32(0));
vm.assume(secondDigest != bytes32(0));
vm.startPrank(publisher);
catalog.publish(firstId, firstDigest);
catalog.publish(secondId, secondDigest);
vm.stopPrank();
assertCourse(firstId, firstDigest, true);
assertCourse(secondId, secondDigest, true);
}
Отпечатки не обязаны различаться. Два курса могут ссылаться на одинаковые байты; базовый договор этого не запрещает. Если исключить равенство хешей без причины, мы потеряем допустимую вариацию и создадим ложное впечатление дополнительного ограничения контракта.
Совпадающие номера относятся к другому свойству: замене выпуска. Оно сохранено в предыдущем уроке. Таким образом, исключение одной комбинации здесь сопровождается отдельным сценарием там, где эта комбинация имеет смысл. Мы не оставляем поведение равных ключей без объяснения.
Проверяем смысл каждого ограничения
| Ограничение | Причина | Где рассматривается исключённое |
|---|---|---|
| Ненулевой отпечаток | Условие успешной публикации | Отрицательный сценарий нулевого хеша |
| Разные номера | Свойство независимости записей | Сценарий замены одного курса |
Диапазон 1..20 |
Удобная малая лаборатория | Свойство полного диапазона из урока 11 |
Последняя строка особенно важна. Контракт разрешает номера намного больше двадцати. Наш малый сценарий не должен становиться единственным положительным fuzz-свойством. Граничная проверка максимального номера и функция без ограничения диапазона продолжают выражать исходный договор.
Если будущий автор обнаружит ошибку на большом ключе, уменьшение диапазона ради успешного отчёта будет скрывать дефект. Корректный путь — сохранить воспроизводящий вход, разобраться с реализацией и добавить точный регрессионный сценарий. Подробно этот процесс появится в двадцать четвёртом уроке.
Остаток от деления не всегда заменяет помощник
Для простого диапазона 1..20 можно было бы написать rawId % 20 + 1. Но перенос похожей формулы на произвольные границы требует аккуратно вычислять ширину, учитывать включительность и не допускать переполнения. Например, выражение max - min + 1 при полном диапазоне uint256 не помещается в тот же тип.
Помощник из зафиксированной версии библиотеки избавляет учебный сценарий от собственного универсального преобразователя. Это не повод забыть про область: аргументы min и max всё равно должны иметь смысл и правильный порядок. Мы используем один проверяющий инструмент для ограниченного сценария, а не вводим новую арифметику в прикладной контракт.
Если читатель хочет изучать сам алгоритм распределения, это уже отдельное свойство утилиты. Тогда понадобятся граничные случаи её входов и ожидаемые отображения. В курсе реестра достаточно понимать, что возвращается допустимый номер и именно этот номер используется во всех дальнейших действиях. Разделение целей удерживает пример от ненужной сложности.
Не исправляем входы незаметно
Распространённая ошибка — менять digest на постоянное ненулевое значение, не объясняя этого. Функция внешне принимает произвольный хеш, но фактически исследует только одну фикстуру. Для положительной области можно явно исключить ноль; если нужен детерминированный хеш, функцию лучше назвать обычным примером.
В ограниченном handler позднее мы будем преобразовывать случайную величину в номер и иногда заменять нулевой отпечаток допустимым учебным значением. Там цель другая: построить содержательную последовательность успешных действий. Такая подготовка должна быть видна в коде и не подменять отдельные проверки валидации.
При чтении будущего контрпримера записываем и исходные параметры, и полученные прикладные значения. Например, большой rawFirst может превратиться в небольшой ключ. Повторение только преобразованного значения в исходном fuzz-входе не всегда воспроизводит тот же ход преобразования, поэтому отчёт должен сохранять контекст функции.
Теперь область каждого свойства связана с его назначением, а общий диапазон контракта остаётся представлен отдельными сценариями. В следующем уроке параметризуем отправителя. Допущения описаны в справочнике assume, реализация помощника — в StdUtils выбранного тега. Оглавление курса.