Foundry mockCall: подмена внешнего ответа
В предыдущем уроке мы сохранили короткое требование к отключению. Теперь рассмотрим внешнюю зависимость. Базовый CourseCatalog сам не обращается к другим программам, поэтому бессмысленно приписывать ему сценарий внешнего ответа. Создадим отдельный CourseReader, который читает реестр и возвращает только активный ненулевой выпуск.
Для проверки этого адаптера подготовим mockCall. Он позволяет задать ответ выбранного внешнего вызова в локальной среде. Код не исполнялся; ответы ниже являются заданными фикстурами, а не результатом настоящего RPC или действующего реестра.
Определяем договор читателя
Читатель получает адрес зависимости при создании. Его метод activeDigest обращается к getter courses, затем отвергает неактивную запись и нулевой хеш собственной ошибкой Unavailable. Это дополнительный слой представления; он не меняет правила публикации и отключения базового контракта.
Сохраним интерфейс и реализацию в src/CourseReader.sol. Интерфейс описывает только нужный getter. Поэтому адаптер не получает методы записи автоматически и не пытается использовать роль издателя. Здесь речь идёт о трактовке результата чтения, а не о новой системе полномочий.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
interface ICourseCatalog {
function courses(uint256 id) external view
returns (bytes32 contentHash, bool active);
}
contract CourseReader {
ICourseCatalog public immutable catalog;
error Unavailable();
constructor(ICourseCatalog catalog_) {
catalog = catalog_;
}
function activeDigest(uint256 id) external view returns (bytes32) {
(bytes32 digest, bool active) = catalog.courses(id);
if (!active || digest == bytes32(0)) revert Unavailable();
return digest;
}
}
Наш маленький конструктор не подтверждает правильность адреса зависимости. Для полного размещения потребуются проверка конфигурации и происхождения экземпляра. Мы пока выбираем известный локальный реестр в подготовке, чтобы сосредоточиться на реакции читателя на разные ответы.
Подготавливаем отдельный файл сценариев
В test/CourseReader.t.sol создадим собственную подготовку. Зависимость имеет настоящий локальный код, но конкретный getter в первом сценарии будет перехвачен. Таким образом, не нужно притворяться, что у случайного адреса уже имеется рабочая программа.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {CourseCatalog} from "../src/CourseCatalog.sol";
import {CourseReader, ICourseCatalog} from "../src/CourseReader.sol";
contract CourseReaderTest is Test {
CourseCatalog internal catalog;
CourseReader internal reader;
function setUp() public {
catalog = new CourseCatalog(address(0xA11CE));
reader = new CourseReader(ICourseCatalog(address(catalog)));
}
function testActiveResponseIsReturned() public {
bytes32 digest = keccak256("edition-one");
bytes memory callData = abi.encodeWithSelector(
ICourseCatalog.courses.selector, uint256(17)
);
vm.mockCall(address(catalog), callData, abi.encode(digest, true));
assertEq(reader.activeDigest(17), digest);
}
}
Вызов адресуется catalog, данные запроса содержат selector и номер 17, а ответ кодирует два возвращаемых поля. abi.encode(digest, true) соответствует ожидаемой форме getter. Простая конкатенация байтов или один хеш без флага не являются корректным ответом этого интерфейса.
Что именно подменено
Mapping реестра остаётся пустым: mockCall не вызывает publish и не записывает struct. Вместо исполнения выбранного getter локальная среда возвращает подготовленные данные соответствующему обращению. Поэтому результат сценария относится к адаптеру, который обрабатывает ответ, а не к правильности сохранения выпуска реестром.
Фикстура привязана к адресу и calldata. Чтение другого номера не следует объявлять автоматически подменённым той же записью. Если нужен общий ответ по селектору, можно выбрать более широкий способ сопоставления, но он изменит область mock и потребует отдельного объяснения.
Точная форма запроса полезна для обнаружения ошибки маршрутизации. Если читатель обращается к другому адресу или номеру, выбранная подмена не должна подтверждать правильность ответа. При этом само сопоставление не доказывает совместимость с настоящим развёрнутым экземпляром: для неё нужен отдельный интеграционный сценарий.
Меняем активность фикстуры
Рассмотрим ответ (digest, false). Базовый реестр может возвращать его после отключения, сохраняя прежний хеш. Читатель по собственному договору отвергает такой выпуск. Добавим функцию внутри CourseReaderTest с точной собственной ошибкой.
function testInactiveResponseIsUnavailable() public {
bytes32 digest = keccak256("edition-one");
bytes memory callData = abi.encodeWithSelector(
ICourseCatalog.courses.selector, uint256(17)
);
vm.mockCall(address(catalog), callData, abi.encode(digest, false));
vm.expectRevert(CourseReader.Unavailable.selector);
reader.activeDigest(17);
}
Отдельная вариация (0, true) также должна дать Unavailable. Она полезна для устойчивости адаптера к некорректной внешней программе, хотя наш правильный базовый реестр не создаёт такую запись через допустимые методы. Подмена позволяет исследовать реакции на необычные ответы независимо от достижимости в текущей реализации зависимости.
Внешний отказ не равен неактивной записи
Зависимость может отклонить вызов вместо возврата пары. Для моделирования такого результата в выбранном Vm есть mockCallRevert. В нашем читателе ошибка внешнего вызова не перехватывается и должна распространяться наружу. Не следует описывать её как собственную Unavailable, которая относится к нормально декодированному ответу.
Также зависимость может вернуть байты неправильной ABI-формы. Тогда ошибка возникает при декодировании. Это третий случай, отличающийся и от неактивной записи, и от явно заданного внешнего revert. Устойчивость к каждому случаю формулируется отдельно; один общий отказ не подтверждает всю матрицу.
Требуем само обращение к зависимости
Подготовленный ответ не доказывает, что читатель действительно сделал внешний вызов. Ошибочный адаптер мог бы возвращать постоянную фикстуру, случайно совпадающую с ожидаемым digest. Для требования обращения к конкретному адресу можно дополнительно использовать vm.expectCall(address(catalog), callData) перед исследуемым вызовом. Эта форма присутствует в выбранном Vm.
Ожидание вызова и ожидание результата отвечают на разные вопросы. Первое связывает адрес и calldata с наблюдаемым обращением, второе проверяет трактовку возвращённой пары. В нашем учебном адаптере оба полезны: данные выпуска должны происходить от нужной зависимости, а не из случайной константы. Не заявляем точное количество обращений, если используем форму ожидания без отдельного требования числа.
Для нескольких номеров можно подготовить разные mock-ответы и убедиться, что читатель передаёт нужный ключ. Например, активный 17 возвращает первый хеш, а неактивный 18 отклоняется. Такая вариация обнаруживает постоянный номер внутри адаптера. Однако она всё ещё не подтверждает правильность настоящего состояния реестра: обе пары заданы проверяющей средой.
Наконец, изоляция подмен должна совпадать с границами сценария. Не следует переносить установленные mock в интеграционный пример и затем описывать его как исполнение реального getter. В итоговом наборе подготовка каждого сценария независима, а названия указывают, использует ли он заданный ответ или действительные локальные компоненты.
Дополняем mock настоящей интеграцией
Для интеграционного сценария создаём реестр, публикуем выпуск издателем и вызываем activeDigest без подмены. Тогда одновременно исполняются getter зависимости и логика адаптера. Такой сценарий полезен для совместимости локальных компонентов, но всё ещё не доказывает работу RPC, браузера или production-адресов.
Unit-подмена позволяет точно выбрать необычный ответ и понять реакцию читателя. Интеграция показывает взаимодействие реальных локальных реализаций. Эти два вида проверки отвечают на разные вопросы, поэтому сохраняем их происхождение в названии сценария и отчёте.
Сейчас подготовлены адаптер и два исходника с контролируемыми ответами. В следующем уроке создадим отдельного наблюдателя свежести. API подмен доступен в Vm 1.9.7, кодирование ответа — в Solidity ABI. Оглавление курса.