Настройка автоматического тестирования смарт-контрактов
Автоматическое тестирование — это то, что отделяет контракты с $10 тыс. TVL от контрактов с $100 млн TVL. Не потому что аудит заменяет тесты, а потому что без тестового покрытия аудит обходится в 3–5 раз дороже и находит меньше проблем: аудитор тратит время на понимание базового поведения контракта вместо глубокого анализа edge cases.
Хорошая тестовая инфраструктура — это Foundry для быстрых unit/fuzz тестов + coverage отчёты + CI pipeline, который не даёт мёрджить код с падающими тестами.
Foundry как основной инструмент
Foundry — современный стандарт для тестирования Solidity. Тесты пишутся на Solidity (не JavaScript), что даёт прямой доступ к EVM internals без ABI-слоя. Компиляция и запуск тестов занимают секунды против минут у Hardhat для больших проектов.
# Установка
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Структура проекта
forge init my-project
# src/ - контракты
# test/ - тесты
# script/ - деплой скрипты
# lib/ - зависимости (git submodules)
Базовая структура теста
// test/Token.t.sol
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract TokenTest is Test {
MyToken token;
address alice = makeAddr("alice"); // детерминированный адрес из label
address bob = makeAddr("bob");
function setUp() public {
token = new MyToken(1_000_000e18); // деплой перед каждым тестом
token.transfer(alice, 1000e18);
}
function test_transfer_success() public {
vm.prank(alice); // следующий вызов — от alice
token.transfer(bob, 100e18);
assertEq(token.balanceOf(bob), 100e18);
assertEq(token.balanceOf(alice), 900e18);
}
function test_transfer_revertIfInsufficientBalance() public {
vm.prank(alice);
vm.expectRevert("ERC20: transfer amount exceeds balance");
token.transfer(bob, 2000e18); // больше чем у alice
}
}
vm — это cheat code interface от Foundry (forge-std/Test.sol). Ключевые cheat codes:
-
vm.prank(addr)— следующий вызов от addr -
vm.startPrank(addr)/vm.stopPrank()— все последующие вызовы от addr -
vm.warp(timestamp)— установитьblock.timestamp -
vm.roll(blockNumber)— установитьblock.number -
vm.deal(addr, amount)— установить ETH баланс -
vm.expectEmit(...)— ожидать конкретное событие -
vm.expectRevert(...)— ожидать revert с сообщением
Fuzz-тестирование
Fuzz-тестирование — автоматическая генерация тест-кейсов. Foundry запускает функцию тысячи раз с разными случайными входными данными, ища нарушение инварианта.
// Fuzz test: функция с параметрами = fuzz test
function testFuzz_deposit_neverOverflows(uint256 amount) public {
// bound ограничивает диапазон входных данных
amount = bound(amount, 1, 1_000_000e18);
deal(address(token), alice, amount);
vm.prank(alice);
token.approve(address(vault), amount);
vm.prank(alice);
vault.deposit(amount);
// Инвариант: shares всегда > 0 при deposit > 0
assertGt(vault.balanceOf(alice), 0);
// Инвариант: total assets растёт на amount
assertEq(vault.totalAssets(), amount);
}
Foundry по умолчанию запускает 256 итераций. В CI рекомендуется 1000–10000 для critical функций:
# foundry.toml
[fuzz]
runs = 1000
seed = "0x1" # воспроизводимость при одинаковом seed
bound(amount, min, max) — крайне важная функция. Без неё fuzz генерирует много нулей и максимальных значений uint256, которые часто fail по очевидным причинам, игнорируя интересный middle range.
Invariant-тестирование
Invariant тесты — более мощный инструмент. Foundry случайно вызывает любые функции контракта в любом порядке и проверяет что инвариант никогда не нарушается.
contract VaultInvariantTest is Test {
Vault vault;
ERC20Mock token;
VaultHandler handler;
function setUp() public {
token = new ERC20Mock();
vault = new Vault(address(token));
handler = new VaultHandler(vault, token);
// Указываем Foundry работать только через handler
targetContract(address(handler));
}
// Этот тест запускается после каждой случайной последовательности действий
function invariant_totalAssetsMatchBalances() public {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"Total assets mismatch"
);
}
function invariant_sharePriceNeverDecreases() public {
// Если vault не потерял средства, цена share не должна падать
assertGe(vault.pricePerShare(), handler.lastRecordedPrice());
}
}
// Handler контролирует допустимые действия
contract VaultHandler is Test {
Vault vault;
ERC20Mock token;
uint256 public lastRecordedPrice;
function deposit(uint256 amount) external {
amount = bound(amount, 1, 1e30);
token.mint(address(this), amount);
token.approve(address(vault), amount);
vault.deposit(amount, address(this));
lastRecordedPrice = vault.pricePerShare();
}
function withdraw(uint256 shares) external {
shares = bound(shares, 0, vault.balanceOf(address(this)));
if (shares > 0) vault.withdraw(shares, address(this), address(this));
}
}
Invariant тесты находят сложные межфункциональные баги, которые unit тесты пропускают. ERC-4626 vault инварианты (totalAssets = баланс контракта, share price монотонна) — классический пример.
Fork тестирование
Fork тесты работают с реальным state mainnet, что позволяет тестировать интеграцию с реальными Uniswap, Aave, Chainlink.
contract ForkTest is Test {
uint256 mainnetFork;
address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
function setUp() public {
// Форк mainnet на конкретном блоке (детерминированность)
mainnetFork = vm.createFork(vm.envString("ETH_RPC_URL"), 19_500_000);
vm.selectFork(mainnetFork);
}
function test_swapOnFork() public {
address whale = 0x40ec5B33f54e0E8A33A975908C5BA1c14e5BbbDf; // USDC whale
vm.prank(whale);
IERC20(USDC).transfer(address(this), 10_000e6);
IERC20(USDC).approve(UNISWAP_V3_ROUTER, 10_000e6);
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: USDC,
tokenOut: WETH,
fee: 500,
recipient: address(this),
deadline: block.timestamp + 60,
amountIn: 10_000e6,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
});
uint256 amountOut = ISwapRouter(UNISWAP_V3_ROUTER).exactInputSingle(params);
assertGt(amountOut, 0);
}
}
RPC кэширование: Foundry кэширует RPC ответы локально (~/.foundry/cache/). Повторный запуск fork тестов на том же блоке не делает новых RPC запросов.
Coverage и качественные метрики
# Генерация coverage отчёта
forge coverage --report lcov
# Подробный отчёт в terminal
forge coverage --report summary
Coverage показывает % строк/веток/функций охваченных тестами. Целевые показатели для production контрактов:
| Тип контракта | Line coverage | Branch coverage |
|---|---|---|
| Core logic (vault, AMM) | >95% | >90% |
| Peripheral (router, helper) | >85% | >75% |
| Admin/governance | >90% | >85% |
Но coverage — не единственная метрика. 100% line coverage при отсутствии assertion-ов в тестах ничего не гарантирует. Нужно считать количество assert* и vm.expectRevert на функцию.
CI/CD интеграция
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run tests
run: forge test --fork-url ${{ secrets.ETH_RPC_URL }} -vvv
env:
ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }}
- name: Check coverage
run: |
forge coverage --report summary | tee coverage.txt
# Fail если line coverage < 90%
python3 -c "
import re, sys
with open('coverage.txt') as f:
content = f.read()
match = re.search(r'Lines.*?(\d+\.\d+)%', content)
if match and float(match.group(1)) < 90:
print(f'Coverage {match.group(1)}% < 90%')
sys.exit(1)
"
Дополнительные инструменты
Slither (статический анализ, Trail of Bits): находит известные паттерны уязвимостей. Запускается за секунды, интегрируется в CI.
pip install slither-analyzer
slither src/ --exclude-dependencies
Echidna (property-based fuzzer, Trail of Bits): более мощный fuzzer для специфических инвариантов. Написан на Haskell, требует конфигурации на Solidity.
Halmos (formal verification): symbolically проверяет все возможные пути исполнения. Находит уязвимости, которые statistical fuzzing пропускает. Требует значительных вычислительных ресурсов.
Оптимальная связка для большинства проектов: Foundry (unit + fuzz + invariant) + Slither (CI) + coverage gate. Echidna и Halmos — для критических компонентов после основной разработки.
Настройка полной тестовой инфраструктуры с CI: 1–2 недели для проекта среднего размера. Написание comprehensive тестов для существующей codebase — 3–6 недель в зависимости от размера и сложности.







