В прошлой части мы познакомились с Foundry, создали новый проект и освоили самые базовые команды для тестирования. Сегодня нам предстоит окунуться чуть поглубже, освоить автоматическое форматирование кода (forge fmt), научится отслеживать качество тестирования наших контрактов (forge coverage), выводить подробные логи (vvvv), управлять временем (warp, roll) и деньгами(deal, hoax).

Поехали!
Поехали!

Исходный код к данной части курса

Ссылка на мой аккаунт в Хабр Карьере (буду рад знакомству)

Форматирование

Форматирование кода - это процесс приведения кода (без изменений в логике работы) в состояние, при котором процесс работы с ним максимально упрощается. Разработчики сообщества Solidity описали основные правила написания кода в документе под названием "Solidity Style Guide". Foundry поможет нам автоматически следовать многим из заданных правил за счёт инструмента fmt

Предлагаю немного побаловаться и исправить наш контракт Counter.sol так, чтобы его было невозможно читать (BadCounter.sol):

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract Counter is Ownable { uint256 public number; event Incremented(uint256 indexed number); error ZeroNumber(); constructor() Ownable() {}
    /**
     * Функция для установления нового значения number
     * Доступна для вызова только owner'y
     * @param newNumber не должен быть равен 0
     */
    
    
    
    function setNumber(uint256 newNumber) public       onlyOwner               {
                    if (newNumber == 0) {revert ZeroNumber();
        }
        
        
        number= 
        
        
        newNumber;
    }

    /**
     * Увеличивает значение number на 1
     * Инициирует событие Incremented
     */


    function increment ()         public {number++;
         
        
                    emit                                                                             Incremented(
                        number);}
}

Если у вас есть лишние $20`000, советую задеплоить данный контракт в мейннет и отдать на аудит компании, типа Certik и посмотреть, как они отреагируют.

Вводим в консоль волшебную команду:

$ forge fmt

И код магическим образом отформатировался!


В прошлой части я упомянул, что рекомендую пользоваться VS code в качестве редактора кода и подключить плагин для Solidity.

Вы можете настроить автоматическое форматирование при каждом сохранении файла:

заходим в настройки плагина
заходим в настройки плагина
В качестве форматтера выбираем "forge"
В качестве форматтера выбираем "forge"

Теперь, чтобы подключить авто-форматирование нажмите Control + Shift + P или, Command + Shift + P (Mac) чтобы открыть палитру команд, введите, setting а затем выберите Preferences: Open User Settings параметр.

Найдите format on save настройку и установите флажок.

Поздравляю, теперь при каждом сохранении будет срабатывать форматирование и исправлять ваши лишние табуляции, пробелы и прочее.

Данное форматирование закрывает далеко не все требования по коду, поэтому я крайне советую ознакомиться с "Solidity Style Guide" и запомнить основные правила

Coverage и console.log()

В больших проектах зачастую сложно отследить качество написанных тестов. В смарт-контрактах (по-хорошему) должна быть протестирована КАЖДАЯ строка кода, иначе есть риск в будущем потерять около $60 миллионов и создать новый Ethereum Classic.
В Foundry есть довольно интересный инструмент под названием coverage:

$ forge coverage

В нашем проекте мы увидим что-то подобное:

Результат работы команды coverage
Результат работы команды coverage

В данной сводной таблице содержится информация о том, какая часть кода воспроизводилась в процессе тестирования. В нашем случае в прошлый раз в тестах мы проверили 100% нашего контракта и это очень неплохо.
Помимо данной таблицы мы можем выгрузить данные в специальный файл lcov.info, который позволит в интерактивном режиме отслеживать качество протестированного контракта

$ forge coverage --report lcov

После данной команды заходим в интересующий наш контракт, открываем палитру команд и выбираем Coverage Gutters: Display Coverage

Теперь слева от кода у нас должны появится цветовые обозначения, в данном случае у нас всё зелёное, т.к. мы всё протестировали.

Не стоит проводить тестирование, основываясь только на результатах команды coverage! В будущем, когда мы будем разбирать инвариантное и fuzz-тестирование, вы поймёте, что некоторые функции и строчки кода приходится тестировать несколько раз разными сценариями, чтобы удостовериться в их работоспособности.


Теперь давайте разберёмся, а как же нам отлаживать контракты и тесты к ним.

Для начала добавим ошибку в тест:

    /**
     * Специально создаём ошибку в тесте
     */
    function testRevertIfNumberIsZero() public {
        // vm.expectRevert(Counter.ZeroNumber.selector);
        vm.startPrank(owner);
        counter.setNumber(0);
        vm.stopPrank();
    }

Теперь при запуске тестов мы сможем увидеть ошибку и краткое описание причины:

В данном случае у нас очень удобный пример, потому что нам известно, какая ошибка произошла (ZeroNumber) и тест проводится всего на одном контракте, поэтому найти и исправить её не составит проблем. Однако очень часто вам придётся тестировать многоуровневые контракты и многие ошибки будут пустыми, т.е. без описания и инициироваться они могут в любом контракте, который хотя бы косвенно принимает участие в тестировании.

Здесь мы можем вызвать только один проблемный тест и расширить вывод логов в консоли с помощью ключа -vvv

$ forge test --match-test testRevertIfNumberIsZero -vvv
Трейсинг нашего теста
Трейсинг нашего теста

Здесь мы можем увидеть последовательность вызовов функций (трейсинг) и в какой функции у нас произошла ошибка, удобно!

ключи:
-vv отображает ручные логи (console.log)
-vvv отображает полный трейсинг только тестов с ошибкой
-vvvv отображает полный трейсинг всех тестов

Также стоит обратить внимание на цвета в трейсинге:
-
Зелёная строка означает, что вызов прошёл успешно
-
Красная строка означает, что вызов был прерван ошибкой
-
Синяя строка означает вызов чит-кода (например startPrank)
-
Голубая строка означает вызов логирования
-
Жёлтая строка означает деплой контракта

Иногда, чтобы понять проблему, нужно вывести логи прямо из контракта и такой функционал у Foundry тоже есть!
Обновим контракт Counter.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

//НЕ ИСПОЛЬЗОВАТЬ ДЛЯ РЕЛИЗА
//Импортируем console для вывода логов прямо из контракта
import {console} from "forge-std/Test.sol";

contract Counter is Ownable {
    uint256 public number;

    event Incremented(uint256 indexed number);

    error ZeroNumber();

    constructor() Ownable() {
        console.log("It's Counter contract, my owner is ", owner());
    }

    /**
     * Функция для установления нового значения number
     * Доступна для вызова только owner'y
     * @param newNumber не должен быть равен 0
     */
    function setNumber(uint256 newNumber) public onlyOwner {
        if (newNumber == 0) {
            console.log("This function is going to fail!");
            revert ZeroNumber();
        }
        console.log("Setting number - ", newNumber);
        number = newNumber;
    }

    /**
     * Увеличивает значение number на 1
     * Инициирует событие Incremented
     */
    function increment() public {
        console.log("Incrementing number - ", number);
        number++;
        emit Incremented(number);
    }
}

Данный метод используется ТОЛЬКО ДЛЯ ОТЛАДКИ И ТЕСТИРОВАНИЯ

При релизе в наших контрактах не должно быть никакой привязки к контрактам и методам forge

Как вы можете понять, метод console.log(), может принимать несколько различных значений с различными типами данных (числа, адреса, строки и т.д.)
Проверим наши логи:

forge test -vv
Вывод тестирования с логами
Вывод тестирования с логами

Здесь ярко демонстрируется тестирование в вакууме, про которую я говорил в прошлой части, т.е. в каждом тесте состояние контракта обнуляется и он деплоится заново (в функции setUp).

Управление деньгами и временем (warp, roll, deal, hoax)

Нам часто приходится работать с нативной валютой, тестированием функций receive() и прочего. В такие моменты нам бы не помешало наколдовать себе эфиров для тестов.
Создадим функцию receive() в нашем контракте:

    /**
     * Стандартный метод на принятие нативной валюты, выводим в лог информацию об отправителе
     * и о количестве отправленного эфира
     */
    receive() external payable {
        console.log("Transfer  ", msg.value, " from: ", msg.sender);
    }

А теперь напишем два теста, один с использованием deal(), другой с использованием hoax():

    /**
     * Используем метод vm.deal()
     * который устанавливает
     * соответсвующий баланс на соответсвующий адрес
     *
     * Метод deal() может также принимать три параметра
     * (address token, address to, uint256 give)
     * В таком случае мы отправим не нативную валюту,
     * а ERC-20 токены соответвующего адреса
     */
    function testSendEthWithDeal() public {
        address user = makeAddr("user");
        //Устанавливаем на user баланс со значением 1 ether
        vm.deal(user, 1 ether);
        assertEq(user.balance, 1 ether);

        //Отправляем на адрес контракта 1 ether от имени user и сверяем балансы
        vm.startPrank(user);
        (bool success,) = address(counter).call{value: 1 ether}("");
        vm.stopPrank();

        assertEq(success, true);
        assertEq(address(counter).balance, 1 ether);
        assertEq(user.balance, 0);
    }

    /**
     * метод hoax() или аналогичный startHoax()
     * объединяет в себе методы deal() и prank()
     * т.е. метод изменяет баланс выбранного адреса
     * и устанавливает его инициатором на ближайший вызов
     */
    function testSendEthWithHoax() public {
        address user = makeAddr("user");
        //Устанавливаем на user баланс со значением 1 ether и вызываем prank()
        hoax(user, 1 ether);
        (bool success,) = address(counter).call{value: 1 ether}("");

        assertEq(success, true);
        assertEq(address(counter).balance, 1 ether);
        assertEq(user.balance, 0);
    }

Резюмируем:

deal(address who, uint256 newBalance) - меняет баланс выбранному пользователю
(может работать с ERC-20 токенами).

hoax(address who, uint256 give) - меняет баланс и запускает метод prank(who).


Давайте усложним задачу и добавим ограничение по времени, чтобы отправлять деньги в контракт можно было только 1 раз в день:

...
    uint256 public lastTransferTime;
...
    /**
     * Стандартный метод на принятие нативной валюты, выводим в лог информацию об отправителе
     * и о количестве отправленного эфира
     */
    receive() external payable {
        //Ставим ограничение по сроку пополнения баланса контракта
        require((block.timestamp >= lastTransferTime + 1 days) || lastTransferTime == 0, "Try later!");
        
        lastTransferTime = block.timestamp;

        console.log("Transfer  ", msg.value, " from: ", msg.sender);
    }

А теперь протестируем работу данного ограничения:

    /**
     * Проверяем временное ограничение, которое должно сработать
     * Если между последним и настоящим переводом прошло меньше
     * 1 дня
     */
    function testSendEth2timesWithoutWaiting() public {
        address user = makeAddr("user");

        hoax(user, 1 ether);
        (bool success1,) = address(counter).call{value: 1 ether}("");

        hoax(user, 1 ether);
        (bool success2,) = address(counter).call{value: 1 ether}("");

        assertEq(success1, true);

        //Второй вызов должен закончится ошибкой
        assertEq(success2, false);
        assertEq(address(counter).balance, 1 ether);
        assertEq(user.balance, 1 ether);
    }

    /**
     * Специальный метод vm.warp() позволяет управлять временем
     * засчёт изменения параметра block.timestamp
     * В данном примере мы меняем время последнего блока
     * на 1 день вперёд и ещё раз делаем вызов, который
     * на этот раз должен пройти успешно
     */
    function testSendEth2timesWithWrap() public {
        address user = makeAddr("user");

        hoax(user, 1 ether);
        (bool success1,) = address(counter).call{value: 1 ether}("");

        //Устанавливаем значение block.timestamp
        vm.warp(block.timestamp + 1 days);
        hoax(user, 1 ether);
        (bool success2,) = address(counter).call{value: 1 ether}("");

        assertEq(success1, true);

        //Второй вызов должен пройти успешно
        assertEq(success2, true);
        assertEq(address(counter).balance, 2 ether);
        assertEq(user.balance, 0);
    }

Резюмируем:

- метод vm.wrap(uint) меняет block.timestamp на указанное значение

Также есть другие вспомогательные методы:

- метод
skip(uint) увеличивает block.timestamp на указанное значение
- метод
rewind(uint) уменьшает block.timestamp на указанное значение
- метод
roll(uint) меняет block.number на указанное значение

Заключение

Отлично! Теперь у нас полноценно настроена среда для разработки, подключено авто-форматирование, освоены инструменты по отладке и логированию контрактов, а также изучены методы управления временем (вообще говоря, параметрами блока) и деньгами

Так держать!

Это вы!
Это вы!

Комментарии (3)


  1. aleshina_mari
    03.10.2023 18:58

    А про fuzz-тестирование будет статья?


    1. andreysWeb Автор
      03.10.2023 18:58

      Да, есть в планах!


  1. pnaydanovgoo
    03.10.2023 18:58

    На мой взгляд начинающему разбираться с Foundry статья сэкономит время. Еще можно про тестирование на forke сети рассказать в следующей части