Тестування смарт-контрактів (unit-тесты)
Контракт без тестів — це контракт, який чекає на аудитора, щоб той знайшов те, що ви вже знаєте. Ми бачили проекти, які йшли на аудит з 20% покриттям і отримували назад звіт на 40 сторінок. Більшість цих знахідок були б спіймані базовим тест-сьютом.
Але покриття саме по собі не мета. 100% line coverage з нульовим branch coverage — це ілюзія безпеки. Реальна історія: токен-контракт з тестами на transfer і mint, але без тесту на transfer(address(0), amount). На mainnet-деплое через три дні — баг з втратою токенів. Рядок покритий, гілка — ні.
Чому Foundry витіснив Hardhat для юнит-тестів
Два роки назад більшість проектів писали тесты на JavaScript/TypeScript через Hardhat + Chai. Це працювало. Але Foundry змінив стандарт.
Швидкість. Foundry компілює і запускає тесты нативно через EVM-реалізацію на Rust (revm). Тест-сьют на 200 тестів — 4-8 секунд проти 45-90 секунд на Hardhat. При TDD це принципово.
Fuzz-тестування з коробки. Будь-яка функція з параметрами стає fuzz-тестом:
function testFuzz_transfer(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(amount <= token.balanceOf(alice));
uint256 balanceBefore = token.balanceOf(to);
vm.prank(alice);
token.transfer(to, amount);
assertEq(token.balanceOf(to), balanceBefore + amount);
}
Foundry прогоняє цей тест 256 разів (за замовчуванням, конфігурується) з різними значеннями. У нашій практиці fuzz-тесты знаходили edge cases — зокрема, переповнення при розрахунку наград — які ручні тесты пропускали.
Cheatcodes. vm.prank, vm.warp, vm.roll, vm.deal — маніпуляція станом EVM прямо у тестах на Solidity. Не потрібно обгортати все у JavaScript-промісі.
Архітектура тест-сьюта
Що тестувати в першу чергу
Не починаємо з happy path. Починаємо з інваріантів: що ніколи не повинно порушуватися, незалежно від порядку викликів.
Для ERC-20 токена інваріанти: totalSupply == sum(balances), balanceOf(address(0)) == 0, allowance після approve == вказане значення. Для стейкинг-контракту: totalStaked == sum(userStakes), rewards(user) >= 0.
Invariant-тесты у Foundry (forge test --match-test invariant) запускають послідовності випадкових викликів і перевіряють, що інваріанти витримують. Це потужніше unit-тестів: знаходить порушення, які виникають лише при певній послідовності транзакцій.
Структура тест-файлу
contract TokenTest is Test {
Token token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
token = new Token("Test", "TST", 1_000_000e18);
deal(address(token), alice, 1000e18);
}
// Юнит: конкретний сценарій
function test_transfer_reducesBalance() public {
vm.prank(alice);
token.transfer(bob, 100e18);
assertEq(token.balanceOf(alice), 900e18);
assertEq(token.balanceOf(bob), 100e18);
}
// Граничний випадок
function test_transfer_revertsOnInsufficientBalance() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(bob, 1001e18);
}
// Fuzz
function testFuzz_transfer(uint256 amount) public {
amount = bound(amount, 0, 1000e18);
vm.prank(alice);
token.transfer(bob, amount);
assertEq(token.balanceOf(alice) + token.balanceOf(bob), 1000e18);
}
}
Моки і форк-тесты
Для ізоляції юнит-тестів від зовнішніх контрактів використовуємо mock-контракты — мінімальні реалізації інтерфейсу. Не vm.mockCall (він крихкий), а окремі контракти вроде MockERC20, MockChainlinkOracle.
Для тестів, які залежать від реального стану протоколів (Uniswap V3 pool, Aave lending pool), використовуємо fork mainnet через vm.createFork(rpcUrl). Це не юнит-тест — це інтеграційний тест, і він повільніший. Розділяємо їх у різні директорії (test/unit/, test/integration/) і запускаємо CI окремо.
Метрики покриття і що вони значать
forge coverage видає line, branch, statement і function coverage. Нас цікавить насамперед branch coverage: кожна умова повинна бути перевірена в обох станах.
Реальні цілі за покриттям:
| Тип контракту | Line coverage | Branch coverage |
|---|---|---|
| Критичні (vault, bridge) | 95%+ | 85%+ |
| DeFi (lending, AMM) | 90%+ | 80%+ |
| Допоміжні (utils, helpers) | 80%+ | 70%+ |
| View-only контракты | 75%+ | 60%+ |
Стопроцентного покриття для Solidity добиватися складно — деякі гілки для захисних перевірок вимагають нарушення інваріантів EVM, що у тесті неможливо. Але 85% branch coverage — це досяжно і достатньо для аудиту.
Процес і терміни
Пишемо тесты паралельно з розробкою, не після. Типічний обсяг: на кожні 100 рядків контракту — 150-300 рядків тестів. Для контракту складності 2 (стейкинг, vesting, простий AMM) — 2-3 робочих дні на повний тест-сьют з фаззингом.
Перед здачею прогоняємо forge test -vvv і forge coverage. Звіт за покриттям йде разом з кодом. Якщо покриття впало нижче порогів — з'ясовуємо чому перед деплоєм, не після.







