Тестирование смарт-контрактов (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. Отчёт по покрытию идёт вместе с кодом. Если покрытие упало ниже порогов — выясняем почему перед деплоем, не после.







