Setup of Automated Smart Contract Testing
Automated testing separates contracts with $10K TVL from those with $100M TVL. Not because audit replaces tests, but without test coverage audit costs 3–5x more and finds fewer issues: auditor spends time understanding basic behavior instead of analyzing edge cases.
Good test infrastructure — Foundry for fast unit/fuzz tests + coverage reports + CI pipeline preventing merge with failing tests.
Foundry as primary tool
Foundry — modern Solidity testing standard. Tests written in Solidity (not JavaScript), direct EVM internals access without ABI layer. Compilation and test execution in seconds vs minutes for Hardhat on large projects.
# Installation
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Project structure
forge init my-project
# src/ - contracts
# test/ - tests
# script/ - deploy scripts
# lib/ - dependencies (git submodules)
Basic test structure
// 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");
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);
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);
}
}
Key vm cheat codes:
-
vm.prank(addr)— next call from addr -
vm.startPrank(addr)/vm.stopPrank()— all subsequent calls from addr -
vm.warp(timestamp)— setblock.timestamp -
vm.roll(blockNumber)— setblock.number -
vm.deal(addr, amount)— set ETH balance -
vm.expectEmit(...)— expect specific event -
vm.expectRevert(...)— expect revert with message
Fuzz testing
Fuzz testing — automatic test case generation. Foundry runs function thousands of times with different random inputs, searching for invariant violations.
// Fuzz test: function with parameters = fuzz test
function testFuzz_deposit_neverOverflows(uint256 amount) public {
// bound limits input data range
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);
// Invariant: shares always > 0 when deposit > 0
assertGt(vault.balanceOf(alice), 0);
// Invariant: total assets grows by amount
assertEq(vault.totalAssets(), amount);
}
Foundry runs 256 iterations by default. In CI recommend 1000–10000 for critical functions:
# foundry.toml
[fuzz]
runs = 1000
seed = "0x1" # reproducibility with same seed
bound(amount, min, max) is critical. Without it, fuzz generates many zeros and max uint256 values, often failing obviously, missing interesting middle range.
Invariant testing
Invariant tests are more powerful. Foundry randomly calls any contract functions in any order and checks invariants never violated.
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);
// Tell Foundry to work only via handler
targetContract(address(handler));
}
// Runs after each random action sequence
function invariant_totalAssetsMatchBalances() public {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"Total assets mismatch"
);
}
}
// Handler controls allowed actions
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 tests find complex inter-function bugs unit tests miss. ERC-4626 vault invariants (totalAssets = contract balance, share price monotonic) — classic example.
Fork testing
Fork tests work with real mainnet state, allowing integration testing with real Uniswap, Aave, Chainlink.
contract ForkTest is Test {
uint256 mainnetFork;
address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
function setUp() public {
// Fork mainnet at specific block (determinism)
mainnetFork = vm.createFork(vm.envString("ETH_RPC_URL"), 19_500_000);
vm.selectFork(mainnetFork);
}
function test_swapOnFork() public {
address whale = 0x40ec5B33f54e0E8A33A975908C5BA1c14e5BbbDf;
vm.prank(whale);
IERC20(USDC).transfer(address(this), 10_000e6);
IERC20(USDC).approve(UNISWAP_V3_ROUTER, 10_000e6);
// ... swap logic
uint256 amountOut = ISwapRouter(UNISWAP_V3_ROUTER).exactInputSingle(params);
assertGt(amountOut, 0);
}
}
RPC caching: Foundry caches RPC responses locally (~/.foundry/cache/). Rerunning fork tests on same block doesn't make new RPC requests.
Coverage and quality metrics
# Generate coverage report
forge coverage --report lcov
# Detailed terminal report
forge coverage --report summary
Coverage shows % of lines/functions/branches covered. Target metrics for production contracts:
| Contract type | Line coverage | Branch coverage |
|---|---|---|
| Core logic (vault, AMM) | >95% | >90% |
| Peripheral (router, helper) | >85% | >75% |
| Admin/governance | >90% | >85% |
But coverage isn't everything. 100% line coverage without assertions doesn't guarantee anything. Count assert* and vm.expectRevert per function.
CI/CD integration
# .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
- name: Check coverage
run: |
forge coverage --report summary | tee coverage.txt
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)
"
Additional tools
Slither (static analysis, Trail of Bits): find known vulnerability patterns. Runs in seconds, integrates in CI.
Echidna (property-based fuzzer, Trail of Bits): more powerful fuzzer for specific invariants.
Halmos (formal verification): symbolically check all execution paths. Finds vulnerabilities statistical fuzzing misses.
Optimal setup for most projects: Foundry (unit + fuzz + invariant) + Slither (CI) + coverage gate. Echidna and Halmos for critical components.
Setup complete testing infrastructure: 1–2 weeks for medium project. Writing comprehensive tests for existing codebase — 3–6 weeks depending on size and complexity.







