Outils·11 min de lecture·Par Solingo

Smart Contract Testing with Foundry — Complete Guide

Master Foundry testing: unit tests, fuzz testing, invariant testing, fork testing. Write bulletproof smart contracts.

# Smart Contract Testing with Foundry — Complete Guide

Testing is the difference between a secure smart contract and a $100M hack. Foundry is the fastest, most powerful testing framework for Solidity. This guide covers everything from basic unit tests to advanced fuzzing and invariant testing.

Why Test Smart Contracts?

Smart contracts are:

  • Immutable: Can't fix bugs after deployment
  • High-value targets: Often hold millions of dollars
  • Publicly auditable: Anyone can find and exploit vulnerabilities

One bug = permanent loss of funds.

Why Foundry?

Compared to Hardhat (JavaScript tests):

| Feature | Foundry | Hardhat |

|---------|---------|---------|

| Speed | ⚡ 10-100x faster | Slow |

| Language | Solidity | JavaScript |

| Fuzz testing | Built-in | Requires plugins |

| Fork testing | Native | Complex setup |

| Gas reports | Detailed | Basic |

Write tests in Solidity = no context switching.

Setup Foundry

# Install Foundry

curl -L https://foundry.paradigm.xyz | bash

foundryup

# Create new project

forge init my-project

cd my-project

# Project structure

src/ # Smart contracts

test/ # Test files

script/ # Deployment scripts

lib/ # Dependencies

Your First Test

Let's test a simple counter contract:

// src/Counter.sol

pragma solidity ^0.8.20;

contract Counter {

uint256 public count;

function increment() external {

count++;

}

function decrement() external {

require(count > 0, "Cannot decrement below zero");

count--;

}

}

Now the test:

// test/Counter.t.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import "forge-std/Test.sol";

import "../src/Counter.sol";

contract CounterTest is Test {

Counter public counter;

// setUp() runs before each test

function setUp() public {

counter = new Counter();

}

function testInitialCount() public {

assertEq(counter.count(), 0);

}

function testIncrement() public {

counter.increment();

assertEq(counter.count(), 1);

counter.increment();

assertEq(counter.count(), 2);

}

function testDecrement() public {

counter.increment();

counter.decrement();

assertEq(counter.count(), 0);

}

function testCannotDecrementBelowZero() public {

vm.expectRevert("Cannot decrement below zero");

counter.decrement();

}

}

Run tests:

forge test

# Output:

# Running 4 tests for test/Counter.t.sol:CounterTest

# [PASS] testCannotDecrementBelowZero() (gas: 11023)

# [PASS] testDecrement() (gas: 14125)

# [PASS] testIncrement() (gas: 31234)

# [PASS] testInitialCount() (gas: 5412)

# Test result: ok. 4 passed; 0 failed;

Essential Assertions

// Equality

assertEq(a, b);

assertEq(a, b, "Custom error message");

// Boolean

assertTrue(condition);

assertFalse(condition);

// Greater/Less than

assertGt(a, b); // a > b

assertGe(a, b); // a >= b

assertLt(a, b); // a < b

assertLe(a, b); // a <= b

// Approximate equality (for decimals)

assertApproxEqAbs(a, b, maxDelta); // |a - b| <= maxDelta

assertApproxEqRel(a, b, maxPercentDelta); // |a - b| / b <= maxPercentDelta

Foundry Cheatcodes (vm.*)

Cheatcodes are superpowers for testing. They manipulate blockchain state.

vm.prank / vm.startPrank

Impersonate any address:

function testOnlyOwner() public {

address owner = address(this);

address attacker = address(0xBEEF);

// Single call as owner

vm.prank(owner);

contract.withdraw(); // Success

// Multiple calls as attacker

vm.startPrank(attacker);

vm.expectRevert("Ownable: caller is not the owner");

contract.withdraw();

vm.stopPrank();

}

vm.deal

Give ETH to any address:

function testDeposit() public {

address user = makeAddr("user");

vm.deal(user, 100 ether);

vm.prank(user);

contract.deposit{value: 10 ether}();

assertEq(address(contract).balance, 10 ether);

}

vm.expectRevert

Assert a call reverts:

// Expect any revert

vm.expectRevert();

contract.failingFunction();

// Expect specific error message

vm.expectRevert("Insufficient balance");

contract.withdraw(1000);

// Expect custom error

vm.expectRevert(InsufficientBalance.selector);

contract.withdraw(1000);

// Expect custom error with parameters

vm.expectRevert(

abi.encodeWithSelector(InsufficientBalance.selector, 1000, 500)

);

vm.expectEmit

Test events:

function testTransferEmitsEvent() public {

address from = address(1);

address to = address(2);

uint256 amount = 100;

// Expect event: Transfer(from, to, amount)

vm.expectEmit(true, true, false, true);

emit Transfer(from, to, amount);

token.transfer(to, amount);

}

vm.warp / vm.roll

Manipulate time and block number:

function testVestingAfter1Year() public {

uint256 vestingStart = block.timestamp;

// Fast-forward 1 year

vm.warp(vestingStart + 365 days);

uint256 vested = vesting.claimable();

assertEq(vested, 1000 ether);

}

function testBlockNumber() public {

vm.roll(1000000); // Set block.number = 1000000

}

Fuzz Testing

Foundry runs your test with random inputs to find edge cases.

// Regular test: tests ONE case

function testTransfer() public {

token.transfer(address(2), 100);

}

// Fuzz test: tests MANY cases with random amounts

function testFuzzTransfer(uint256 amount) public {

// Foundry will run this 256 times with random amounts

vm.assume(amount <= token.balanceOf(address(this)));

uint256 balanceBefore = token.balanceOf(address(2));

token.transfer(address(2), amount);

assertEq(token.balanceOf(address(2)), balanceBefore + amount);

}

Bound Inputs

function testFuzzDeposit(uint256 amount) public {

// Bound amount between 0.01 and 1000 ETH

amount = bound(amount, 0.01 ether, 1000 ether);

vm.deal(address(this), amount);

contract.deposit{value: amount}();

assertEq(address(contract).balance, amount);

}

Configure Fuzz Runs

# foundry.toml

[profile.default]

fuzz = { runs = 1000 } # Default is 256

[profile.deep]

fuzz = { runs = 10000 } # Deep fuzzing

forge test --fuzz-runs 10000

Invariant Testing

Invariant testing = "This property should ALWAYS be true, no matter what."

Example: Total supply should equal sum of all balances.

// test/invariant/TokenInvariant.t.sol

contract TokenHandler is Test {

Token public token;

address[] public users;

constructor(Token _token) {

token = _token;

// Create 10 test users

for (uint i = 0; i < 10; i++) {

users.push(makeAddr(string(abi.encodePacked("user", i))));

}

}

function mint(uint256 userIndex, uint256 amount) public {

userIndex = bound(userIndex, 0, users.length - 1);

amount = bound(amount, 0, 1000 ether);

vm.prank(users[userIndex]);

token.mint(amount);

}

function transfer(uint256 fromIndex, uint256 toIndex, uint256 amount) public {

fromIndex = bound(fromIndex, 0, users.length - 1);

toIndex = bound(toIndex, 0, users.length - 1);

address from = users[fromIndex];

amount = bound(amount, 0, token.balanceOf(from));

vm.prank(from);

token.transfer(users[toIndex], amount);

}

}

contract TokenInvariantTest is Test {

Token public token;

TokenHandler public handler;

function setUp() public {

token = new Token();

handler = new TokenHandler(token);

targetContract(address(handler));

}

// Invariant: total supply = sum of all balances

function invariant_totalSupplyEqualsBalances() public {

uint256 sumBalances = 0;

for (uint i = 0; i < handler.users.length; i++) {

sumBalances += token.balanceOf(handler.users(i));

}

assertEq(token.totalSupply(), sumBalances);

}

// Invariant: no user balance exceeds total supply

function invariant_balanceNeverExceedsTotalSupply() public {

for (uint i = 0; i < handler.users.length; i++) {

assertLe(token.balanceOf(handler.users(i)), token.totalSupply());

}

}

}

Run invariant tests:

forge test --match-contract Invariant

# Configure runs

[profile.default]

invariant = { runs = 256, depth = 15 }

Fork Testing

Test against live blockchain state (mainnet, Arbitrum, etc).

contract ForkTest is Test {

IERC20 USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);

IUniswapV2Router router = IUniswapV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

function setUp() public {

// Fork Ethereum mainnet at block 18000000

vm.createSelectFork("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", 18000000);

}

function testSwapOnUniswap() public {

address whale = 0x...; // Address with USDC

uint256 amount = 1000e6; // 1000 USDC

vm.prank(whale);

USDC.approve(address(router), amount);

address[] memory path = new address[](2);

path[0] = address(USDC);

path[1] = router.WETH();

vm.prank(whale);

router.swapExactTokensForETH(

amount,

0,

path,

whale,

block.timestamp

);

}

}

Run fork tests:

forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY

# Or use foundry.toml

[rpc_endpoints]

mainnet = "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

arbitrum = "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY"

forge test --fork-url mainnet

Gas Reports

forge test --gas-report

# Output:

| Function | Gas |

|-----------|--------|

| mint | 51234 |

| transfer | 34567 |

| burn | 28901 |

Gas Snapshots

Track gas usage changes over time:

forge snapshot

# Creates .gas-snapshot file

# Check if gas increased

forge snapshot --check

Coverage

forge coverage

# Generate detailed report

forge coverage --report lcov

genhtml lcov.info -o coverage

open coverage/index.html

Advanced Testing Patterns

Test Naming Convention

// Pattern: test_{Function}_{Condition}_{ExpectedOutcome}

function test_transfer_WithSufficientBalance_Succeeds() public {}

function test_transfer_WithInsufficientBalance_Reverts() public {}

function testFuzz_deposit_AlwaysIncreasesBalance(uint256 amount) public {}

function invariant_totalSupplyNeverExceeds() public {}

Helper Functions

contract BaseTest is Test {

function _mintTokens(address to, uint256 amount) internal {

vm.prank(minter);

token.mint(to, amount);

}

function _dealETHAndTokens(address user) internal {

vm.deal(user, 100 ether);

_mintTokens(user, 1000 ether);

}

}

Testing Access Control

function testOnlyOwnerCanMint() public {

address notOwner = makeAddr("notOwner");

vm.prank(notOwner);

vm.expectRevert();

token.mint(1000);

vm.prank(owner);

token.mint(1000); // Should succeed

}

Best Practices Checklist

  • [ ] 100% coverage: Every line tested
  • [ ] Test reverts: Use vm.expectRevert for error cases
  • [ ] Fuzz critical functions: Especially math and token transfers
  • [ ] Test events: Verify events are emitted correctly
  • [ ] Test access control: Ensure only authorized users can call functions
  • [ ] Test edge cases: Zero values, max values, empty arrays
  • [ ] Fork test integrations: Test against live protocols
  • [ ] Invariant testing: For complex state machines
  • [ ] Gas benchmarks: Track gas usage with snapshots
  • [ ] Readable test names: Describe what's being tested

Common Mistakes

Not testing reverts

function testWithdraw() public {

contract.withdraw(1000); // What if it should revert?

}

Test both success and failure

function testWithdrawSuccess() public {

_setupBalance(1000);

contract.withdraw(500);

assertEq(balance(), 500);

}

function testWithdrawInsufficientBalance() public {

vm.expectRevert("Insufficient balance");

contract.withdraw(1000);

}

Forgetting to bound fuzz inputs

function testFuzzTransfer(uint256 amount) public {

token.transfer(user, amount); // Will fail with huge amounts

}

Always bound or assume

function testFuzzTransfer(uint256 amount) public {

amount = bound(amount, 0, token.balanceOf(address(this)));

token.transfer(user, amount);

}

Conclusion

Foundry testing transforms smart contract development:

  • Fast: Tests run in milliseconds
  • 🔒 Secure: Fuzz and invariant testing catch edge cases
  • 📊 Insightful: Gas reports and coverage guide optimization
  • 🚀 Powerful: Fork testing against live protocols

Security tip: No amount of testing guarantees safety, but comprehensive tests + audits + gradual rollout = best chance of success.

Start testing every function, every edge case, every invariant. Your future self (and your users' funds) will thank you.

Next: Learn smart contract auditing to take your security skills to the next level!

Prêt à mettre en pratique ?

Applique ces concepts avec des exercices interactifs sur Solingo.

Commencer gratuitement