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

Тестирование Solidity в Foundry: требования к реестру

В первом уроке мы определили структуру лаборатории и версии. Теперь нужен объект исследования. Возьмём реестр текущих выпусков учебной библиотеки: издатель объявляет отпечаток курса, а читатель узнаёт, активна ли публикация. Результатом урока станет полный базовый контракт и таблица его разрешённых переходов.

Мы начинаем с требований, чтобы будущая проверка отвечала на вопрос о поведении приложения. Если просто повторить реализацию в утверждении, можно закрепить ту же ошибку двумя способами. Требование формулируется независимо: например, деактивация должна выключить запись и сохранить её последний отпечаток. Затем сравниваем с этим правилом действительную программу.

Устанавливаем границы реестра

Номер курса обозначим courseId, отпечаток выпуска — contentHash, доступность — active. Для примеров используем номер 17. Контракт не хранит учебный текст, не проверяет знания читателя и не принимает оплату. Он отвечает на один вопрос: какие сведения о текущем выпуске объявлены уполномоченным издателем.

Адрес издателя задаётся при создании экземпляра и затем не меняется. Такое ограничение облегчает первую модель полномочий, но имеет последствие: потерянный ключ нельзя заменить встроенной функцией этого контракта. Возможность передачи роли и восстановление изучаются в отдельном курсе прав и токенов. Не будем приписывать базовому примеру возможности будущего расширения.

Повторная публикация того же номера разрешена. Она заменяет отпечаток и снова активирует курс. Это прикладное решение: реестр хранит текущий выпуск, а не неизменяемый архив всех версий. Если позднее понадобится отдельная запись каждой редакции, создадим явно другую модель. Сейчас тест не должен считать разрешённое обновление нарушением.

Полный исходник приложения

Сохраним следующий код в src/CourseCatalog.sol. Это базовая программа, которую последующие сценарии будут создавать в проверяющей среде.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

contract CourseCatalog {
    error Unauthorized();
    error InvalidCourse();
    error NotActive();

    struct Course {
        bytes32 contentHash;
        bool active;
    }

    address public immutable publisher;
    mapping(uint256 => Course) public courses;

    event Published(uint256 indexed courseId, bytes32 contentHash);
    event Deactivated(uint256 indexed courseId);

    constructor(address publisher_) {
        if (publisher_ == address(0)) revert Unauthorized();
        publisher = publisher_;
    }

    modifier onlyPublisher() {
        if (msg.sender != publisher) revert Unauthorized();
        _;
    }

    function publish(uint256 courseId, bytes32 contentHash)
        external
        onlyPublisher
    {
        if (courseId == 0 || contentHash == bytes32(0)) {
            revert InvalidCourse();
        }
        courses[courseId] = Course(contentHash, true);
        emit Published(courseId, contentHash);
    }

    function deactivate(uint256 courseId) external onlyPublisher {
        if (!courses[courseId].active) revert NotActive();
        courses[courseId].active = false;
        emit Deactivated(courseId);
    }
}

Публичный mapping предоставляет getter courses(courseId). Для структуры он возвращает её доступные поля, здесь — отпечаток и флаг. Вызов getter не даёт права записывать произвольную структуру. Изменение происходит через две внешние функции, и обе используют одну проверку отправителя.

Ключ, которого ещё нет, возвращает стандартные значения: нулевые байты и false. Сама такая запись не доказывает, что курс удаляли. Это может быть номер, который никто не публиковал. Для различения истории нужен журнал событий или дополнительная модель существования. Первые проверки будут работать с текущим состоянием, поэтому не станут угадывать прошлое по одному флагу.

Записываем переходы независимо от кода

Представим ненулевой отпечаток H1. Из состояния «записи нет» издатель выполняет публикацию номера 17. Ожидаем H1 и true. Читатель не может сделать тот же переход. Если издатель деактивирует курс, ожидаем H1 и false, а не нулевой отпечаток. Повторная деактивация отклоняется.

Исходное состояние Действие Ожидаемое состояние
Записи нет Издатель публикует H1 H1, активна
Активна с H1 Издатель публикует H2 H2, активна
Активна с H1 Издатель деактивирует H1, неактивна
Неактивна с H1 Издатель публикует H2 H2, активна
Любое состояние Чужой адрес меняет запись Откат, состояние сохраняется

Эта таблица описывает смысл операций. Она не повторяет порядок каждой строки исходника. Именно поэтому по ней можно написать сценарий для другой реализации того же договора: например, getter и структура могут быть устроены иначе, но разрешённый результат обязан сохраняться.

Обратите внимание на последнее требование. Сценарий должен наблюдать и отказ, и отсутствие изменения. Один откат иногда достаточен для unit-проверки, но в более сложной последовательности полезно сравнить состояние до и после. Так мы отличим отказ неподходящего действия от случайной ошибки чтения или неправильной подготовки фикстуры.

Уточняем причины отказа

Номер 0 и нулевой отпечаток запрещены. Отклонённая публикация не должна частично менять запись. В модификаторе проверяются права, затем в теле функции — данные. Поэтому чужой адрес с неверными аргументами получает Unauthorized: он не проходит первую границу. Издатель с теми же аргументами получает InvalidCourse.

Зависимость ошибки от контекста важна для точного сценария. Если автор хотел проверить нулевой номер, но забыл назначить издателя, проверка общего отката может выглядеть успешной. На самом деле ограничение номера не было достигнуто. Позже мы используем ожидание конкретной ошибки и явного отправителя, чтобы такая подмена не проходила незаметно.

Нулевой издатель в конструкторе также отклоняется. Это отдельный сценарий создания, а не вызов метода уже существующего объекта. Проверка конструктора должна ожидать, что экземпляр вообще не появится. Нельзя проверять его getter после ожидаемого отказа, как будто создание всё-таки завершилось.

Событие и текущее состояние

Успешная публикация создаёт Published с номером и отпечатком. Успешная деактивация создаёт Deactivated с номером. Веб-индексатор использует эти сведения для истории, но текущий статус читает из реестра. Старое событие с H1 не противоречит getter с H2, если между ними была новая публикация.

Проверки поэтому разделят два результата: изменение полей и правильное событие. Если проверить только событие, можно не заметить ошибочную запись. Если проверить только поля, индексатор может получить неверный номер. Это разные части одного прикладного договора, и каждой нужен понятный критерий.

Сам отпечаток в этом уроке рассматривается как заранее подготовленное ненулевое значение. Выбор алгоритма и канонических байтов манифеста относится к курсу публикации IPFS. Фикстура проверки может использовать удобное значение, но оно не становится автоматически форматом всех файлов библиотеки.

Теперь есть сохранённый исходник и требования, по которым можно судить о поведении. Компиляция и исполнение отсутствуют: таблицы задают ожидаемые эффекты. В следующем уроке создадим независимый экземпляр перед каждым сценарием и разберём, почему предыдущая проверка не должна готовить состояние для следующей.

Описание getter и областей состояния находится в документации Solidity 0.8.30. Оглавление курса связывает требования с последующими видами проверок.

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