Тестирование смарт-контрактов (интеграционные тесты)
Unit-тесты показывают, что функция transfer() корректно обновляет балансы. Интеграционные тесты показывают, что эта функция в связке с approve(), allowance() и контрактом-агрегатором не создаёт race condition при параллельных вызовах в одном блоке. Разница принципиальная, особенно когда протокол взаимодействует с Uniswap V3, Aave V3 и Chainlink в одной транзакции.
Где unit-тесты заканчиваются и начинаются проблемы
Классический сценарий: контракт стейкинга с reward-дистрибьюцией прошёл unit-тесты на 100%. Каждая функция работает корректно в изоляции. На mainnet через неделю после деплоя обнаруживается: если пользователь вызывает compound() и withdraw() в одном блоке (через batch-транзакцию или через контракт-агрегатор), reward-расчёт берёт snapshot баланса до compound(), но применяет его после — пользователь получает двойное вознаграждение за один epoch.
Это интеграционный баг. Он возникает только при конкретном порядке вызовов в рамках одного блока. Unit-тесты его не поймают по определению, потому что тестируют функции изолированно.
Ещё один паттерн: контракт корректно работает с ERC-20 токенами, которые следуют стандарту. Но в реальном DeFi-ландшафте встречаются fee-on-transfer токены (USDT на некоторых чейнах), rebase-токены (stETH), токены с blacklist (USDC). Интеграционный тест должен проверить, что контракт не делает предположений о том, что amount в Transfer event равен фактически полученному количеству.
Как строится интеграционное тестирование
Mainnet fork как основа среды
Реалистичные интеграционные тесты требуют реальных состояний протоколов. Мы форкаем mainnet через Hardhat или Foundry с конкретным block number и тестируем взаимодействие с живыми контрактами Uniswap, Aave, Curve — не с моками.
// hardhat.config.ts
networks: {
hardhat: {
forking: {
url: process.env.ALCHEMY_URL,
blockNumber: 19500000, // фиксируем блок для воспроизводимости
}
}
}
Фиксация block number критична. Без неё тесты недетерминированы: цены, ликвидность, состояние пулов меняются, и тест может проходить сегодня и падать завтра по причинам, не связанным с кодом.
Сценарии взаимодействия, которые тестируем
Multi-step DeFi сценарии. Например, для yield aggregator: deposit → approve LP-токен → stake в gauge → harvest → compound. Каждый шаг вызывает внешние контракты. Тест проверяет финальное состояние, а не промежуточные шаги.
Атаки через flash loan. Имитируем flash loan attack через Aave V3 flashLoanSimple() прямо в тесте: занимаем токены, пробуем манипулировать ценой в AMM, вызываем целевой контракт, возвращаем займ. Если контракт использует spot price из DEX без TWAP — он уязвим. Тест должен это зафиксировать.
Reentrancy через callback. ERC-721 имеет onERC721Received(), ERC-1155 — onERC1155Received(). Если контракт делает state change после transferFrom(), а callback вызывает контракт снова — это reentrancy. Пишем специальный контракт-атакер в тесте, который реализует этот callback злонамеренно.
Sandwich атаки и MEV. Тестируем слипедж-защиту: что происходит, если между approve() и swap() кто-то двигает цену в пуле. Используем hardhat_setStorageAt для прямой манипуляции состоянием пула в тесте.
| Тип теста | Инструмент | Что покрывает |
|---|---|---|
| Unit | Hardhat / Foundry | Изолированная логика функций |
| Integration (local mock) | Hardhat | Взаимодействие между своими контрактами |
| Integration (mainnet fork) | Hardhat / Foundry | Взаимодействие с реальными протоколами |
| Fuzzing | Echidna / Foundry forge fuzz | Инвариантные нарушения |
| Formal verification | Certora Prover | Математические свойства |
Foundry vs Hardhat для интеграционных тестов
Foundry быстрее: интеграционный тест-сьют на 200 кейсов выполняется за 15-30 секунд против 3-5 минут в Hardhat. Это важно при TDD и частых итерациях.
Hardhat удобнее для сложных сценариев с JavaScript-логикой: манипуляция блоками через evm_mine, точный контроль газа через eth_estimateGas с overrides, интеграция с реальными SDK протоколов (Uniswap SDK, Aave SDK).
Мы используем оба. Foundry — для быстрого прогона property-based тестов и fuzz. Hardhat — для сложных многошаговых сценариев с реальными протоколами.
Типичные ошибки, которые выловили в реальных проектах
Предположение о порядке событий в одном блоке. Если контракт использует block.number для расчёта наград, а два вызова происходят в одном блоке — block.number одинаков для обоих. Контракт должен использовать block.timestamp или отдельный счётчик.
Моки вместо реальных токенов. Мок-токен всегда возвращает true на transfer(). USDT на Ethereum не возвращает значение вовсе (не соответствует ERC-20 стандарту). Тест с моком пройдёт, деплой на mainnet с USDT — нет.
Игнорирование gas limit на функции. Интеграционный тест должен замерять gas consumption для каждого сценария. Если агрегатор вызывает 10 пулов Curve в одной транзакции, это может упереться в block gas limit при определённых состояниях пулов.
Нет тестов на edge case токенов. Fee-on-transfer (PAXG, STA), rebase (stETH, aTokens), pausable (USDC), с blacklist — каждая категория требует отдельного тест-сьюта.
Процесс и сроки
Интеграционное тестирование существующего протокола: 2-3 рабочих дня. Включает: анализ контрактов, определение критических путей взаимодействия, написание тест-сьюта, прогон с форком mainnet, отчёт по найденным проблемам.
Параллельная разработка интеграционных тестов вместе с контрактами — закладываем в общую смету проекта, обычно 30-40% от времени разработки. Стоимость рассчитывается индивидуально.







