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 TypeTarget Coverage
Core DeFi Logic>95%
Access Control / Permissions100%
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 TypeTarget Coverage
Core DeFi Logic>95%
Access Control / Permissions100%
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