При всей важности fuzz тестов все обучающие видео и практические примеры сводятся к двум функциям и паре настроек конфигурации Foundry.
Fuzz тесты, или фаззинг, или нечеткое тестирование, по своей сути, это простой программный подбор таких значений в функции, при которых ее исполнение будет "ломаться".
Возьмём простой пример для наглядности:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Fuzz {
function checkNum(uint8 num) external view returns(bool){
if (num == 100) revert ("EQ 100");
if (num == 0) revert ("EQ 0");
return true;
}
}
Тут у нас простая функция для проверки какого-то вводимого числа. Из возможных 256 вариантов num - два не пройдут проверку. Но писать тест для каждого числа было бы достаточно накладно и долго.
Для этих целей и придумали фаззинг. Посмотрите на простой тест для этой функции.
contract FuzzTest is Test {
Fuzz public fuzz;
function setUp() public {
fuzz = new Fuzz();
}
/// forge-config: default.fuzz.runs = 100
function testFuzz_checkNum(uint8 num) public view {
vm.assume(num != 0);
num = uint8(bound(num, 1, 99));
fuzz.checkNum(num);
}
}Для начала мы создаем переменную и объект контракта в setUp(), затем пишем простой тест.
Фаззинг отличается от юнит тестов тем, что для функции теста мы оставляем аргументы, которые сам Foundry и будет подбирать.
Вспомните наши предыдущие тесты, где были такие функции как:
function test_IncrementEvent() public {}
в которых нет аргументов, и функцию для фаззинга:
function testFuzz_checkNum(uint8 num) public view {}
Тут мы как бы предлагаем Foundry поработать самому и найти такие числа, которые могут поломать функцию checkNum() в нашем контракте.
Если же упростить тестовую функцию до:
function testFuzz_checkNum(uint8 num) public view {
fuzz.checkNum(num);
}то при вызове теста, через forge test, мы получим примерно такую запись в терминале:
Running 1 test for test/Counter.t.sol:FuzzTest
[FAIL. Reason: EQ 100 Counterexample: calldata=0x6bee15a90000000000000000000000000000000000000000000000000000000000000064, args=[100]] testFuzz_checkNum(uint8) (runs: 95, μ: 8571, ~: 8571)
Обратите внимание на args=[100] - то значение, которые было найдено через фаззинг, и которое ломало вызов наш вызов.
(runs: 95, μ: 8571, ~: 8571) - runs - означает количество "пробегов" Foundry по нашему тесты до момента, когда он нашел значение "на вылет", μ: 8571 - среднее количество потраченного газа, ~: 8571 - значение по газу, которое потратили на "средний" тест. Например, у нас было проведено 95 прогонов, они были проранжированы по мере потребления газа от 1 до 95, и вот под ~ - будет тест находящийся на 48 позиции, т.е. средний из всех 95.
Но вернемся к записи в терминале. Сейчас у нас тест провалился, поэтому добавим новый читкод vm.assume().
vm.assume(num != 0); - будет означать, что наш тест должен "предполагать", что подставленное значение не должно быть равным нулю. Если такое случается, то тест не проваливается, а продолжает цикл.
num = uint8(bound(num, 1, 99)); - эта запись означает, что мы как бы ставим в рамки num значение от 1 до 99. И все значения, которые будет подбирать Foundry будут преобразовываться в числа в промежутке от 1 до 99.
Также стоит сказать пару слов об inline-config. Это такие настройки, которые мы указываем перед тестом через комментарии.
/// forge-config: default.fuzz.runs = 100
В этом случаем мы устанавливаем "пробег" тестов на количестве 100, в то время, как значение по умолчанию равно 256.
Конечно, мы можем установить и 100_000, но проведение такого теста займет куда больше времени, особенно, если вызываемая функция будет намного сложнее, чем в данном примере.
Также есть еще одна настройка:
/// forge-config: default.fuzz.max-test-rejects = 500
которая устанавливает количество "fail" теста перед тем, как он может считаться окончательно проваленным.