Перейти к содержанию

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 выбранного тега. Оглавление курса.

Оглавление серии