# 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.expectRevertfor 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!