Тестирование: юнит, форк-тесты и инварианты

Тест проверяет «делает ли код то, что я написал»; инвариант — «остаётся ли система честной всегда».

Инвариант — свойство системы, которое должно выполняться всегда, при любой последовательности допустимых действий (например, «сумма балансов = общему предложению»).

Три уровня тестов

Тестирование DeFi многослойно, и каждый слой ловит свой класс ошибок:

  • Юнит-тесты — проверяют конкретные функции на конкретных входах: «депозит увеличивает баланс», «вывод чужого падает». Основа основ.
  • Форк-тесты — прогоняют код на копии реального состояния сети. Так интеграция с настоящими оракулами, пулами и токенами проверяется в боевых условиях, без деплоя в прод.
  • Инвариант-тесты — самый мощный слой: вы формулируете свойства, а фаззер генерирует случайные последовательности вызовов, пытаясь их нарушить.

Почему инварианты важнее отдельных кейсов

Юнит-тест проверяет сценарии, которые вы придумали. Но взломщик ищет сценарий, который вы не придумали. Инвариант переворачивает задачу: вместо «проверь, что в случае X результат Y» вы говорите «что бы ни случилось, свойство P должно держаться», и инструмент сам ищет контрпример. Хорошие инварианты DeFi: «сумма пользовательских балансов не превышает активов контракта», «никто не может вывести больше, чем внёс», «общее предложение не меняется без mint/burn».

// Инвариант-тест (Foundry): фаззер дёргает функции в случайном порядке
// и после КАЖДОЙ последовательности проверяет свойство
function invariant_solvency() public {
    // активов контракта должно хватать на все обязательства
    assertGe(token.balanceOf(address(vault)), vault.totalLiabilities());
}

Как работает под капотом: инвариант-фаззинг

Движок инвариант-тестов случайно выбирает, какие функции контракта вызвать, с какими аргументами и в каком порядке, прогоняя тысячи таких «прогонов». После каждой последовательности он проверяет все объявленные инварианты. Если хоть один нарушен — выводит точную цепочку вызовов, которая сломала систему. Это близко к тому, как рассуждает атакующий, но на вашей стороне.

Инвариант-прогон:
  случайно: deposit(7) -> borrow(3) -> withdraw(2) -> ...
  после каждого шага: проверить invariant_solvency()
  нарушено? -> показать ломающую последовательность

Покрытие крайних случаев

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

Частые ошибки

  • Только happy-path юнит-тесты. Покрывают задуманное, не задуманное взломщиком.
  • Не формулировать инварианты. Теряете самый сильный слой защиты.
  • Не тестировать на форке. Интеграционные баги всплывут уже в проде.

Итоги

  • Юнит → форк → инвариант: каждый слой ловит свой класс ошибок.
  • Инвариант фиксирует «всегда истинное» свойство; фаззер ищет контрпример.
  • Хорошие инварианты DeFi — про платёжеспособность и сохранение средств.
  • Особое внимание крайним случаям: пустой пул, ноль, экстремальная цена.
Проверьте себя
1. Что такое инвариант в тестировании контракта?
AОдин конкретный тест-кейс
BСвойство, которое должно выполняться всегда при любой последовательности действий
CИмя функции
DЗначение газа
2. Зачем нужны форк-тесты?
AУскоряют компиляцию
BПроверяют код на копии реального состояния сети с настоящими оракулами и пулами
CШифруют тесты
DГенерируют случайность
3. Чем инвариант-тест сильнее обычного юнит-теста?
AОн быстрее
BОн сам ищет последовательность, нарушающую свойство, а не проверяет лишь придуманные сценарии
CОн не нужен
DОн деплоит контракт