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 asaddrvm.deal(addr, amount)— Sets ETH balancevm.warp(timestamp)— Setsblock.timestampvm.roll(blockNumber)— Setsblock.numbervm.expectRevert(msg)— Expects revert on next callvm.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.