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

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Тестирование смарт-контрактов (unit-тесты)
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1258
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1170
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    873
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1092
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    563
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    830

Тестирование смарт-контрактов (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), используем форк 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, simple AMM) — 2-3 рабочих дня на полный тест-сьют с фаззингом.

Перед сдачей прогоняем forge test -vvv и forge coverage. Отчёт по покрытию идёт вместе с кодом. Если покрытие упало ниже порогов — выясняем почему перед деплоем, не после.