Foundry expectEmit: проверка событий контракта
В предыдущем уроке мы проверили причины отказа. Теперь вернёмся к успешной публикации. Интерфейс может читать текущую запись реестра, но индексатору нужен журнал изменений. Поэтому правильное состояние без правильного события оставляет часть потребителей без необходимой информации.
Добавим сценарий, который ожидает Published именно от нашего экземпляра CourseCatalog, затем сверяет сохранённый выпуск. Это исходник проверки, а не отчёт о прогоне: команды и пример пока не исполнялись. Работа продолжается в test/CourseCatalog.t.sol, используя подготовку пустого реестра из третьего урока.
Что записывается в журнал
Контракт объявляет событие Published(uint256 indexed courseId, bytes32 contentHash). Номер помечен indexed, поэтому участвует в темах журнала. Отпечаток находится в данных события. Адрес испустившего контрактного экземпляра присутствует в записи отдельно; он не является аргументом Published.
Обычное событие также содержит тему с идентификатором сигнатуры. Первая пользовательская индексируемая величина оказывается следующей темой. Нам не требуется вручную вычислять все эти байты: Solidity сформирует журнал при emit, а Foundry сможет сравнить подготовленное ожидание с наблюдаемым событием.
Для объявления ожидаемого события добавим в проверяющий контракт такое же определение. Оно нужно для учебного emit внутри сценария. Мы не создаём второй реестр и не меняем прикладную модель. Оба объявления должны иметь одинаковые типы, порядок и признак indexed.
event Published(uint256 indexed courseId, bytes32 contentHash);
function testPublicationEmitsExpectedEdition() public {
bytes32 digest = keccak256("edition-one");
vm.expectEmit(true, false, false, true, address(catalog));
emit Published(17, digest);
vm.prank(publisher);
catalog.publish(17, digest);
(bytes32 stored, bool active) = catalog.courses(17);
assertEq(stored, digest);
assertTrue(active);
}
Определение события размещаем среди членов CourseCatalogTest, функцию — рядом с остальными сценариями. Во всём курсе используется профиль forge-std 1.9.7. Полная форма expectEmit делает наш выбор проверяемых частей явным и включает адрес источника.
Читаем аргументы ожидания
Первое логическое значение включает сравнение первой пользовательской темы: номера курса. Следующие два выключают проверку тем, которых в данном событии нет. Последнее включает сравнение данных, содержащих отпечаток. Адрес в конце задаёт конкретный источник журнала. Сигнатура обычного события сопоставляется механизмом ожидания.
| Часть записи | Ожидаемая величина | Зачем сравнивать |
|---|---|---|
| Источник | address(catalog) |
Не принять сообщение другого реестра |
| Первая пользовательская тема | 17 |
Связать выпуск с нужным курсом |
| Данные | digest |
Передать индексатору правильный отпечаток |
| Состояние после вызова | (digest, true) |
Убедиться в сохранении публикации |
Событие, испущенное самим сценарием после expectEmit, служит образцом. Оно не является исследуемой публикацией. После образца вызывается настоящий метод реестра с отправителем publisher; именно его журнал должен удовлетворить ожидание.
Порядок здесь отличается от конструкции с prank, поставленным слишком рано. Мы объявляем ожидание, испускаем локальный образец, затем непосредственно перед внешним вызовом устанавливаем отправителя. Читателю проще увидеть, какой вызов относится к разрешённой роли. Между prank и publish не вставлен внешний getter, способный употребить одноразовую настройку.
Событие и состояние отвечают на разные вопросы
Представим ошибочную реализацию, которая сохраняет правильный digest, но в событие кладёт старое значение. Getter покажет актуальную запись, тогда как построенный по журналу каталог отстанет. Сравнение состояния из пятого урока такую ошибку не заметит. Новое ожидание данных журнала предназначено именно для неё.
Другая ошибочная реализация могла бы испустить верное сообщение и забыть обновить запись. Тогда ожидание события удовлетворено, а чтение после вызова обнаружит пустой курс. Поэтому в нашей функции сохранены оба вида утверждений. Их соседство объясняет прикладной договор лучше, чем одно общее сообщение об успехе.
Чтение текущего состояния нельзя всегда заменить восстановлением истории. Журнал относится к выполненным транзакциям и конкретной цепочке. Реорганизации, диапазоны поиска и повторная обработка входят в задачи отдельного курса индексатора. Здесь мы проверяем только локальный договор одного вызова.
Меняем номер и выпуск
Изменим пример: публикуем курс 18 с отпечатком keccak256("edition-two"). Нужно изменить ожидаемое событие, аргументы вызова и последующее чтение согласованно. Если поменять только publish, ожидание номера должно нарушиться. Если поменять только отпечаток в образце, должно нарушиться сравнение данных.
Такой мысленный разбор показывает чувствительность сценария. Проверка имеет смысл, когда мы можем назвать конкретную ошибку, которую она обнаруживает. Простое ожидание любого события без номера и данных не выражает наш договор о выпуске и позволяет неверному сообщению выглядеть приемлемым.
Для деактивации понадобится другое объявление: Deactivated(uint256 indexed courseId). У него нет отпечатка в данных, поскольку базовый контракт сохраняет прошлый хеш в mapping. Сценарий деактивации сначала явно публикует курс, затем ожидает Deactivated(17) и проверяет active == false. Этот переход подробнее разберём в девятом уроке.
Различаем откат и сохранённый журнал
Если транзакция в конечном счёте откатывается, её промежуточные события не становятся сохранённым журналом цепочки. Поэтому нельзя обещать индексатору публикацию только потому, что исполнение дошло до строки emit перед последующей ошибкой. Для успешного прикладного перехода должны завершиться весь вызов и соответствующая транзакция.
В нашей локальной функции исследуемый метод должен завершиться нормально. Отрицательные случаи остаются отдельными сценариями с ожидаемой ошибкой. Это разделение помогает при чтении отчёта: отказ с корректным селектором не описывается как состоявшаяся публикация, а ожидаемое событие относится к успешному изменению. Проверка финальности сетевой транзакции здесь ещё не выполняется.
Не делаем лишних выводов о количестве
Ожидание нужного события не следует описывать как доказательство того, что журнал содержит только одну запись и вообще не содержит дополнительных событий. Если прикладное требование задаёт точное количество, придётся отдельно получить записи и проверить их состав. У нашего текущего сценария более узкая задача: найти и сравнить ожидаемую публикацию от определённого источника.
При нескольких ожидаемых событиях важен порядок. Например, два последовательных выпуска требуют двух образцов и соответствующих действий. Не стоит добавлять такие ожидания на всякий случай: сначала нужно решить, является ли порядок частью договора потребителя. Для одной публикации наш сценарий остаётся коротким и однозначным.
Теперь у реестра описаны два наблюдаемых результата успешной операции: обновлённая запись и сообщение для внешнего наблюдателя. В следующем уроке изучим создание самого экземпляра. Дополнительные формы ожидания приведены в справочнике Foundry, устройство событий — в Solidity 0.8.30. Оглавление курса.