ethereum/contract-testing-strategy
Unit / Fuzz / Invariant / Fork Testing Strategies for Smart Contracts
ethereumguide👥 Communityconfidence highhealth 100%
v1.0.0·Updated 3/20/2026
Testing Pyramid
┌─────────────┐
│ Fork Test │ ← Tests against real on-chain state
┌┴─────────────┴┐
│ Invariant Test │ ← System invariant verification
┌┴───────────────┴┐
│ Fuzz Testing │ ← Random input testing
┌┴─────────────────┴┐
│ Unit Testing │ ← Basic functionality verification
└───────────────────┘
1. Unit Testing (Foundry)
contract VaultTest is Test {
Vault vault;
ERC20Mock token;
address alice = makeAddr("alice");
function setUp() public {
token = new ERC20Mock();
vault = new Vault(address(token));
token.mint(alice, 1000e18);
vm.prank(alice);
token.approve(address(vault), type(uint256).max);
}
function test_deposit() public {
vm.prank(alice);
vault.deposit(100e18);
assertEq(vault.balanceOf(alice), 100e18);
}
// Test revert
function test_RevertWhen_DepositZero() public {
vm.prank(alice);
vm.expectRevert(Vault.ZeroAmount.selector);
vault.deposit(0);
}
// Test event
function test_EmitDeposit() public {
vm.expectEmit(true, false, false, true);
emit Deposit(alice, 100e18); // Expected event
vm.prank(alice);
vault.deposit(100e18);
}
}
2. Fuzz Testing
// Foundry auto-generates random inputs (256 runs by default)
function testFuzz_deposit(uint256 amount) public {
// Constrain input to valid range
amount = bound(amount, 1, 1000e18);
token.mint(alice, amount);
vm.prank(alice);
vault.deposit(amount);
assertEq(vault.balanceOf(alice), amount);
assertEq(token.balanceOf(address(vault)), amount);
}
// Multi-parameter fuzz
function testFuzz_transferAndWithdraw(uint256 depositAmt, uint256 withdrawAmt) public {
depositAmt = bound(depositAmt, 1e18, 1000e18);
withdrawAmt = bound(withdrawAmt, 1, depositAmt); // Cannot exceed deposit amount
vm.startPrank(alice);
vault.deposit(depositAmt);
vault.withdraw(withdrawAmt);
vm.stopPrank();
assertEq(vault.balanceOf(alice), depositAmt - withdrawAmt);
}
Increase fuzz run count:
# foundry.toml
[fuzz]
runs = 10000 # Default is 256; 10000+ recommended for high-risk contracts
3. Invariant Testing (Most Powerful)
Invariants: Conditions the system must always satisfy regardless of what operations occur.
// Handler: defines the set of valid operations
contract VaultHandler is CommonBase, StdCheats, StdUtils {
Vault vault;
ERC20Mock token;
uint256 public totalDeposited;
constructor(Vault _vault, ERC20Mock _token) {
vault = _vault; token = _token;
}
function deposit(uint256 amount) public {
amount = bound(amount, 1, 1e30);
token.mint(msg.sender, amount);
vm.prank(msg.sender);
token.approve(address(vault), amount);
vm.prank(msg.sender);
vault.deposit(amount);
totalDeposited += amount;
}
function withdraw(uint256 amount) public {
amount = bound(amount, 0, vault.balanceOf(msg.sender));
if (amount == 0) return;
vm.prank(msg.sender);
vault.withdraw(amount);
totalDeposited -= amount;
}
}
// Invariant test contract
contract VaultInvariantTest is Test {
Vault vault;
VaultHandler handler;
function setUp() public {
vault = new Vault(...);
handler = new VaultHandler(vault, token);
targetContract(address(handler)); // Foundry randomly calls handler functions
}
// Invariant 1: vault token balance = sum of all deposits
function invariant_vaultBalanceMatchesDeposits() public {
assertEq(token.balanceOf(address(vault)), handler.totalDeposited());
}
// Invariant 2: cannot withdraw more than deposited
function invariant_noFreeWithdrawal() public {
assertGe(handler.totalDeposited(), 0);
}
}
4. Fork Testing (Mainnet State)
contract ForkTest is Test {
address constant UNISWAP_V3_POOL = 0x...;
address constant WHALE = 0xWHALE_ADDRESS;
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC"), 19000000);
// Can now interact directly with real contracts
}
function test_swapOnRealUniswap() public {
// Impersonate a whale
vm.prank(WHALE);
// Interact with real Uniswap V3
ISwapRouter(UNISWAP_ROUTER).exactInputSingle(...);
}
function test_aaveLiquidation() public {
// Simulate price crash → trigger liquidation
vm.mockCall(CHAINLINK_ORACLE, abi.encodeWithSelector(latestRoundData.selector),
abi.encode(0, 1000e8, 0, block.timestamp, 0)); // ETH price drops to $1000
// Execute liquidation
aavePool.liquidationCall(...);
}
}
Coverage Targets
| Contract Type | Target Coverage |
|---|---|
| Core DeFi Logic | >95% |
| Access Control / Permissions | 100% |
| Utility / Helper Functions | >80% |
| View Functions | >70% |
forge coverage --report lcov
genhtml lcov.info -o coverage-report
Pre-Audit Checklist
- All public/external functions have unit tests
- Fuzz tests cover all numeric inputs
- Critical invariants have Invariant tests
- Fork tests verify integration with mainnet protocols
- Gas snapshots compared between versions
Smart Contract Testing Strategy
Testing Pyramid
┌─────────────┐
│ Fork Test │ ← Tests against real on-chain state
┌┴─────────────┴┐
│ Invariant Test │ ← System invariant verification
┌┴───────────────┴┐
│ Fuzz Testing │ ← Random input testing
┌┴─────────────────┴┐
│ Unit Testing │ ← Basic functionality verification
└───────────────────┘
1. Unit Testing (Foundry)
contract VaultTest is Test {
Vault vault;
ERC20Mock token;
address alice = makeAddr("alice");
function setUp() public {
token = new ERC20Mock();
vault = new Vault(address(token));
token.mint(alice, 1000e18);
vm.prank(alice);
token.approve(address(vault), type(uint256).max);
}
function test_deposit() public {
vm.prank(alice);
vault.deposit(100e18);
assertEq(vault.balanceOf(alice), 100e18);
}
// Test revert
function test_RevertWhen_DepositZero() public {
vm.prank(alice);
vm.expectRevert(Vault.ZeroAmount.selector);
vault.deposit(0);
}
// Test event
function test_EmitDeposit() public {
vm.expectEmit(true, false, false, true);
emit Deposit(alice, 100e18); // Expected event
vm.prank(alice);
vault.deposit(100e18);
}
}
2. Fuzz Testing
// Foundry auto-generates random inputs (256 runs by default)
function testFuzz_deposit(uint256 amount) public {
// Constrain input to valid range
amount = bound(amount, 1, 1000e18);
token.mint(alice, amount);
vm.prank(alice);
vault.deposit(amount);
assertEq(vault.balanceOf(alice), amount);
assertEq(token.balanceOf(address(vault)), amount);
}
// Multi-parameter fuzz
function testFuzz_transferAndWithdraw(uint256 depositAmt, uint256 withdrawAmt) public {
depositAmt = bound(depositAmt, 1e18, 1000e18);
withdrawAmt = bound(withdrawAmt, 1, depositAmt); // Cannot exceed deposit amount
vm.startPrank(alice);
vault.deposit(depositAmt);
vault.withdraw(withdrawAmt);
vm.stopPrank();
assertEq(vault.balanceOf(alice), depositAmt - withdrawAmt);
}
Increase fuzz run count:
# foundry.toml
[fuzz]
runs = 10000 # Default is 256; 10000+ recommended for high-risk contracts
3. Invariant Testing (Most Powerful)
Invariants: Conditions the system must always satisfy regardless of what operations occur.
// Handler: defines the set of valid operations
contract VaultHandler is CommonBase, StdCheats, StdUtils {
Vault vault;
ERC20Mock token;
uint256 public totalDeposited;
constructor(Vault _vault, ERC20Mock _token) {
vault = _vault; token = _token;
}
function deposit(uint256 amount) public {
amount = bound(amount, 1, 1e30);
token.mint(msg.sender, amount);
vm.prank(msg.sender);
token.approve(address(vault), amount);
vm.prank(msg.sender);
vault.deposit(amount);
totalDeposited += amount;
}
function withdraw(uint256 amount) public {
amount = bound(amount, 0, vault.balanceOf(msg.sender));
if (amount == 0) return;
vm.prank(msg.sender);
vault.withdraw(amount);
totalDeposited -= amount;
}
}
// Invariant test contract
contract VaultInvariantTest is Test {
Vault vault;
VaultHandler handler;
function setUp() public {
vault = new Vault(...);
handler = new VaultHandler(vault, token);
targetContract(address(handler)); // Foundry randomly calls handler functions
}
// Invariant 1: vault token balance = sum of all deposits
function invariant_vaultBalanceMatchesDeposits() public {
assertEq(token.balanceOf(address(vault)), handler.totalDeposited());
}
// Invariant 2: cannot withdraw more than deposited
function invariant_noFreeWithdrawal() public {
assertGe(handler.totalDeposited(), 0);
}
}
4. Fork Testing (Mainnet State)
contract ForkTest is Test {
address constant UNISWAP_V3_POOL = 0x...;
address constant WHALE = 0xWHALE_ADDRESS;
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC"), 19000000);
// Can now interact directly with real contracts
}
function test_swapOnRealUniswap() public {
// Impersonate a whale
vm.prank(WHALE);
// Interact with real Uniswap V3
ISwapRouter(UNISWAP_ROUTER).exactInputSingle(...);
}
function test_aaveLiquidation() public {
// Simulate price crash → trigger liquidation
vm.mockCall(CHAINLINK_ORACLE, abi.encodeWithSelector(latestRoundData.selector),
abi.encode(0, 1000e8, 0, block.timestamp, 0)); // ETH price drops to $1000
// Execute liquidation
aavePool.liquidationCall(...);
}
}
Coverage Targets
| Contract Type | Target Coverage |
|---|---|
| Core DeFi Logic | >95% |
| Access Control / Permissions | 100% |
| Utility / Helper Functions | >80% |
| View Functions | >70% |
forge coverage --report lcov
genhtml lcov.info -o coverage-report
Pre-Audit Checklist
- All public/external functions have unit tests
- Fuzz tests cover all numeric inputs
- Critical invariants have Invariant tests
- Fork tests verify integration with mainnet protocols
- Gas snapshots compared between versions
- slither / aderyn static analysis shows no high-severity warnings