Тестування смарт-контрактів (unit-тести)

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Тестування смарт-контрактів (unit-тести)
Середній
~2-3 дні
Часті запитання

Напрямки блокчейн-розробки

Етапи блокчейн-розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1308
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1221
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    921
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1149
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    612
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    886

Тестування смарт-контрактів (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. Звіт за покриттям йде разом з кодом. Якщо покриття впало нижче порогів — з'ясовуємо чому перед деплоєм, не після.