Тестування смарт-контрактів (інтеграційні тесты)
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 проти 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% від часу розробки. Вартість розраховується індивідуально.







