Skip to main content
6 min read R.S.

Smart Contract Testing: From Unit Tests to Fuzzing

Testing smart contracts is not optional quality assurance. It is the only safety net before an irreversible deployment. This post describes our testing strategy — from simple unit tests through integration tests to fuzzing — with concrete code examples using Foundry.

The Testing Pyramid for Smart Contracts

              /\
             /  \        Formal Verification
            /----\       (Invariants mathematically proven)
           /      \
          / Fuzzing \    Property-Based + Fuzz Testing
         /----------\    (Random inputs, thousands of runs)
        /            \
       / Integration  \  Multi-contract interaction
      /----------------\
     /                  \
    /    Unit Tests      \ Individual functions in isolation
   /______________________\

The foundation is unit tests: fast, isolated, deterministic. Each layer above finds bugs that the one below cannot.

Unit Tests with Foundry

Foundry (forge) is our preferred testing framework. Tests are written in Solidity — no JavaScript translation layer, no ABI encoding surprises.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Vault.sol";

contract VaultTest is Test {
    Vault vault;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vault = new Vault();
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_Deposit() public {
        vm.prank(alice);
        vault.deposit{value: 1 ether}();

        assertEq(vault.balances(alice), 1 ether);
        assertEq(address(vault).balance, 1 ether);
    }

    function test_Withdraw() public {
        // Setup
        vm.prank(alice);
        vault.deposit{value: 5 ether}();

        // Act
        uint256 balanceBefore = alice.balance;
        vm.prank(alice);
        vault.withdraw();

        // Assert
        assertEq(alice.balance, balanceBefore + 5 ether);
        assertEq(vault.balances(alice), 0);
    }

    function test_RevertWhen_WithdrawWithoutBalance() public {
        vm.prank(bob);
        vm.expectRevert("No balance");
        vault.withdraw();
    }
}

Key Foundry cheatcodes:

  • vm.prank(addr) — Next call executes as addr
  • vm.deal(addr, amount) — Sets ETH balance
  • vm.warp(timestamp) — Sets block.timestamp
  • vm.roll(blockNumber) — Sets block.number
  • vm.expectRevert(msg) — Expects revert on next call
  • vm.expectEmit() — Expects a specific event

Integration Tests

Unit tests check individual functions. Integration tests check how multiple contracts work together. A typical scenario: a governance token, a voting contract, and a timelock cooperating.

contract GovernanceIntegrationTest is Test {
    GovernanceToken token;
    Governor governor;
    Timelock timelock;

    address proposer = makeAddr("proposer");
    address voter1 = makeAddr("voter1");
    address voter2 = makeAddr("voter2");

    function setUp() public {
        token = new GovernanceToken();
        timelock = new Timelock(1 days, new address[](0), new address[](0));
        governor = new Governor(token, timelock);

        // Fund proposer and voters with tokens
        token.mint(proposer, 100_000e18);
        token.mint(voter1, 500_000e18);
        token.mint(voter2, 400_000e18);

        // Delegation (required for voting power)
        vm.prank(voter1);
        token.delegate(voter1);
        vm.prank(voter2);
        token.delegate(voter2);

        // Advance 1 block (delegation needs checkpoint)
        vm.roll(block.number + 1);
    }

    function test_FullGovernanceFlow() public {
        // 1. Create proposal
        address[] memory targets = new address[](1);
        targets[0] = address(token);
        uint256[] memory values = new uint256[](1);
        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = abi.encodeCall(token.mint, (proposer, 50_000e18));

        vm.prank(proposer);
        uint256 proposalId = governor.propose(
            targets, values, calldatas, "Mint additional tokens"
        );

        // 2. Wait for voting delay
        vm.roll(block.number + governor.votingDelay() + 1);

        // 3. Cast votes
        vm.prank(voter1);
        governor.castVote(proposalId, 1); // For
        vm.prank(voter2);
        governor.castVote(proposalId, 1); // For

        // 4. Wait for voting period
        vm.roll(block.number + governor.votingPeriod() + 1);

        // 5. Queue + Execute
        governor.queue(targets, values, calldatas, keccak256("Mint additional tokens"));
        vm.warp(block.timestamp + 1 days + 1);
        governor.execute(targets, values, calldatas, keccak256("Mint additional tokens"));

        // 6. Verify result
        assertEq(token.balanceOf(proposer), 150_000e18);
    }
}

Fuzz Testing with Foundry

Fuzz tests let Foundry generate random inputs. Instead of testing a few hand-picked values, you verify a property across thousands of runs:

function testFuzz_DepositAndWithdraw(
    uint256 amount
) public {
    // Bounds: sensible value range
    amount = bound(amount, 1, 100 ether);

    vm.deal(alice, amount);
    vm.startPrank(alice);

    vault.deposit{value: amount}();
    assertEq(vault.balances(alice), amount);

    uint256 balanceBefore = alice.balance;
    vault.withdraw();

    assertEq(alice.balance, balanceBefore + amount);
    assertEq(vault.balances(alice), 0);

    vm.stopPrank();
}

// Invariant: vault balance = sum of all user balances
function testFuzz_VaultBalanceInvariant(
    uint256 amountAlice,
    uint256 amountBob
) public {
    amountAlice = bound(amountAlice, 0, 50 ether);
    amountBob = bound(amountBob, 0, 50 ether);

    vm.deal(alice, amountAlice);
    vm.deal(bob, amountBob);

    if (amountAlice > 0) {
        vm.prank(alice);
        vault.deposit{value: amountAlice}();
    }
    if (amountBob > 0) {
        vm.prank(bob);
        vault.deposit{value: amountBob}();
    }

    assertEq(
        address(vault).balance,
        vault.balances(alice) + vault.balances(bob)
    );
}

By default, Foundry runs 256 iterations per fuzz test. For security-critical contracts, we increase to 10,000+:

# foundry.toml
[fuzz]
runs = 10000
seed = 42        # Reproducible
max_test_rejects = 65536

Stateful Fuzzing with Echidna

Foundry fuzzing tests individual function calls. Echidna can generate sequences of calls — it finds bugs that only emerge through a specific sequence of transactions.

// Echidna test contract
contract VaultEchidnaTest {
    Vault vault;

    constructor() {
        vault = new Vault();
    }

    // Echidna calls deposit() and withdraw() in random
    // order with random parameters.
    function deposit() public payable {
        vault.deposit{value: msg.value}();
    }

    function withdraw() public {
        try vault.withdraw() {} catch {}
    }

    // Invariant: vault must never pay out more than deposited
    function echidna_vault_solvent() public view returns (bool) {
        return address(vault).balance >= 0;
        // Echidna tries to break this property
    }
}
# Run Echidna
$ echidna test/VaultEchidna.sol --contract VaultEchidnaTest
# Default: 50,000 transaction sequences

Coverage and CI

Coverage reports show which code paths are untested:

$ forge coverage --report lcov
# Result: src/Vault.sol .......... 100.0% (branches: 94.4%)

# In the CI pipeline (GitHub Actions):
- name: Run tests
  run: forge test --gas-report
- name: Check coverage
  run: |
    forge coverage --report summary
    # Fail if branch coverage < 95%

Our minimum requirements for deployment:

  • 100% line coverage
  • > 95% branch coverage
  • All fuzz tests passed with at least 1,000 runs
  • Stateful fuzzing with at least 50,000 sequences
  • No Slither findings with severity High or Medium

Takeaways

No single testing level is sufficient. Unit tests find obvious bugs quickly. Integration tests uncover interaction problems. Fuzz tests find edge cases no developer would think of. Stateful fuzzing finds sequences that only emerge through specific transaction orderings.

In our experience, the time investment for a complete testing strategy is 40-60% of total development time. That sounds like a lot. Compared to the cost of an exploit on a live contract, it is cheap insurance.