14 KiB
14 KiB
Foundry Testing Guide
Complete guide to testing smart contracts with Forge.
Test Structure
File Conventions
- Test files:
ContractName.t.sol - Script files:
ScriptName.s.sol - Test contracts inherit from
Test
Function Prefixes
| Prefix | Purpose |
|---|---|
test_ |
Unit test |
testFuzz_ |
Fuzz test |
testFork_ |
Fork test |
invariant_ |
Invariant test |
test_RevertIf_ |
Expected revert |
test_RevertWhen_ |
Expected revert |
Basic Test Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {Test, console} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract VaultTest is Test {
Vault public vault;
address public alice;
address public bob;
function setUp() public {
vault = new Vault();
alice = makeAddr("alice");
bob = makeAddr("bob");
deal(alice, 100 ether);
deal(bob, 100 ether);
}
function test_Deposit() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
assertEq(vault.balanceOf(alice), 1 ether);
}
}
Unit Testing
Assertions
// Equality
assertEq(actual, expected);
assertEq(actual, expected, "custom message");
assertNotEq(actual, expected);
// Comparisons
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
// Boolean
assertTrue(condition);
assertFalse(condition);
// Approximation (for rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // 1e18 = 100%
// Arrays
assertEq(arr1, arr2);
Testing Reverts
// Expect any revert
vm.expectRevert();
vault.withdraw(1000 ether);
// Expect specific message
vm.expectRevert("Insufficient balance");
vault.withdraw(1000 ether);
// Expect custom error (selector only)
vm.expectRevert(InsufficientBalance.selector);
vault.withdraw(1000 ether);
// Expect custom error with parameters
vm.expectRevert(
abi.encodeWithSelector(
InsufficientBalance.selector,
0, // available
1000 // required
)
);
vault.withdraw(1000 ether);
// Partial revert (match selector only)
vm.expectPartialRevert(InsufficientBalance.selector);
vault.withdraw(1000 ether);
Testing Events
// Expect event emission
vm.expectEmit(true, true, false, true);
// topic1, topic2, topic3, data
emit Transfer(alice, bob, 100);
// Call that should emit the event
token.transfer(bob, 100);
// Record and inspect logs
vm.recordLogs();
token.transfer(bob, 100);
Vm.Log[] memory logs = vm.getRecordedLogs();
assertEq(logs[0].topics[0], keccak256("Transfer(address,address,uint256)"));
Testing Access Control
function test_OnlyOwner() public {
// Should succeed as owner
vault.adminFunction();
// Should fail as non-owner
vm.expectRevert("Ownable: caller is not the owner");
vm.prank(alice);
vault.adminFunction();
}
Fuzz Testing
Forge automatically generates random inputs for test parameters.
Basic Fuzz Test
function testFuzz_Deposit(uint256 amount) public {
// Bound input to valid range
amount = bound(amount, 0.01 ether, 100 ether);
deal(alice, amount);
vm.prank(alice);
vault.deposit{value: amount}();
assertEq(vault.balanceOf(alice), amount);
}
Fuzz Configuration
# foundry.toml
[fuzz]
runs = 256 # Number of fuzz runs
seed = 42 # Deterministic seed (optional)
max_test_rejects = 65536
Fixtures (Pre-defined Values)
// Array fixture (must match parameter name)
uint256[] public fixtureAmount = [0, 1, 100, type(uint256).max];
// Function fixture
function fixtureUser() public returns (address[] memory) {
address[] memory users = new address[](3);
users[0] = makeAddr("alice");
users[1] = makeAddr("bob");
users[2] = address(0);
return users;
}
function testFuzz_Transfer(uint256 amount, address user) public {
// amount will be one of: 0, 1, 100, max
// user will be one of: alice, bob, address(0)
}
Using vm.assume()
function testFuzz_Transfer(address recipient) public {
// Skip invalid inputs
vm.assume(recipient != address(0));
vm.assume(recipient != address(token));
// Or use helper
assumeNotZeroAddress(recipient);
assumeNotPrecompile(recipient);
}
Invariant Testing
Test that properties hold across random function call sequences.
Basic Invariant Test
contract VaultInvariantTest is Test {
Vault public vault;
VaultHandler public handler;
function setUp() public {
vault = new Vault();
handler = new VaultHandler(vault);
// Only fuzz the handler
targetContract(address(handler));
}
function invariant_SolvencyCheck() public view {
assertGe(
address(vault).balance,
vault.totalDeposits(),
"Vault is insolvent"
);
}
function invariant_TotalSupplyConsistent() public view {
assertEq(
vault.totalSupply(),
sumAllBalances(),
"Supply mismatch"
);
}
}
Handler Pattern
Wrap target contract to bound inputs and track state:
contract VaultHandler is Test {
Vault public vault;
uint256 public ghost_totalDeposited;
address[] public actors;
constructor(Vault _vault) {
vault = _vault;
actors.push(makeAddr("alice"));
actors.push(makeAddr("bob"));
actors.push(makeAddr("charlie"));
}
function deposit(uint256 actorIndex, uint256 amount) external {
// Bound inputs
actorIndex = bound(actorIndex, 0, actors.length - 1);
amount = bound(amount, 0.01 ether, 10 ether);
address actor = actors[actorIndex];
deal(actor, amount);
vm.prank(actor);
vault.deposit{value: amount}();
// Track ghost variable
ghost_totalDeposited += amount;
}
function withdraw(uint256 actorIndex, uint256 amount) external {
actorIndex = bound(actorIndex, 0, actors.length - 1);
address actor = actors[actorIndex];
uint256 balance = vault.balanceOf(actor);
amount = bound(amount, 0, balance);
vm.prank(actor);
vault.withdraw(amount);
ghost_totalDeposited -= amount;
}
}
Target Configuration
function setUp() public {
// Only fuzz specific contracts
targetContract(address(handler));
// Exclude contracts from fuzzing
excludeContract(address(vault));
excludeContract(address(token));
// Only use specific senders
targetSender(alice);
targetSender(bob);
// Exclude specific senders
excludeSender(address(0));
// Target specific functions
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = handler.deposit.selector;
selectors[1] = handler.withdraw.selector;
targetSelector(FuzzSelector({
addr: address(handler),
selectors: selectors
}));
}
Invariant Configuration
# foundry.toml
[invariant]
runs = 256 # Number of sequences
depth = 50 # Calls per sequence
fail_on_revert = false # Continue on reverts
shrink_run_limit = 5000 # Shrinking iterations
Fork Testing
Test against live blockchain state.
Basic Fork Test
contract ForkTest is Test {
uint256 mainnetFork;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WHALE = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
function setUp() public {
mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL"));
vm.selectFork(mainnetFork);
}
function testFork_USDCTransfer() public {
vm.prank(WHALE);
IERC20(USDC).transfer(address(this), 1000e6);
assertEq(IERC20(USDC).balanceOf(address(this)), 1000e6);
}
}
Fork Cheatcodes
// Create fork
uint256 forkId = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/...");
uint256 forkId = vm.createFork("mainnet"); // Uses foundry.toml rpc_endpoints
uint256 forkId = vm.createFork("mainnet", 18000000); // Pin to block
// Create and select in one call
vm.createSelectFork("mainnet");
// Switch between forks
vm.selectFork(mainnetFork);
vm.selectFork(arbitrumFork);
// Get active fork
uint256 active = vm.activeFork();
// Roll fork to different block
vm.rollFork(18500000);
vm.rollFork(arbitrumFork, 150000000);
// Make account persist across forks
vm.makePersistent(address(myContract));
vm.makePersistent(alice, bob, charlie);
// Check persistence
bool isPersistent = vm.isPersistent(address(myContract));
// Revoke persistence
vm.revokePersistent(address(myContract));
Multi-Fork Testing
function testFork_CrossChain() public {
// Deploy on mainnet
vm.selectFork(mainnetFork);
address mainnetToken = address(new Token());
// Deploy on Arbitrum
vm.selectFork(arbitrumFork);
address arbitrumToken = address(new Token());
// Verify different deployments
vm.selectFork(mainnetFork);
assertEq(Token(mainnetToken).name(), "Token");
vm.selectFork(arbitrumFork);
assertEq(Token(arbitrumToken).name(), "Token");
}
Fork Configuration
# foundry.toml
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
arbitrum = "${ARBITRUM_RPC_URL}"
optimism = "${OPTIMISM_RPC_URL}"
Differential Testing
Compare implementations against reference.
function testDifferential_MerkleRoot(bytes32[] memory leaves) public {
vm.assume(leaves.length > 0 && leaves.length < 100);
// Solidity implementation
bytes32 solidityRoot = merkle.getRoot(leaves);
// Reference implementation (via FFI)
string[] memory cmd = new string[](3);
cmd[0] = "node";
cmd[1] = "scripts/merkle.js";
cmd[2] = vm.toString(abi.encode(leaves));
bytes memory result = vm.ffi(cmd);
bytes32 jsRoot = abi.decode(result, (bytes32));
assertEq(solidityRoot, jsRoot, "Merkle root mismatch");
}
Complete Cheatcode Reference
State Manipulation
// ETH balance
vm.deal(address, uint256);
// Code at address
vm.etch(address, bytes memory code);
// Storage
vm.store(address, bytes32 slot, bytes32 value);
bytes32 value = vm.load(address, bytes32 slot);
// Nonce
vm.setNonce(address, uint64 nonce);
vm.resetNonce(address);
uint64 nonce = vm.getNonce(address);
// Copy storage
vm.copyStorage(address from, address to);
Caller Context
// Single call
vm.prank(address sender);
vm.prank(address sender, address origin);
// Multiple calls
vm.startPrank(address sender);
vm.startPrank(address sender, address origin);
vm.stopPrank();
Time & Block
vm.warp(uint256 timestamp); // block.timestamp
vm.roll(uint256 blockNumber); // block.number
vm.fee(uint256 gasPrice); // tx.gasprice
vm.difficulty(uint256 difficulty); // block.difficulty
vm.coinbase(address miner); // block.coinbase
vm.chainId(uint256 chainId); // block.chainid
vm.prevrandao(bytes32 prevrandao); // block.prevrandao
Expectations
// Reverts
vm.expectRevert();
vm.expectRevert(bytes memory message);
vm.expectRevert(bytes4 selector);
vm.expectPartialRevert(bytes4 selector);
// Events
vm.expectEmit(bool topic1, bool topic2, bool topic3, bool data);
vm.expectEmit(bool topic1, bool topic2, bool topic3, bool data, address emitter);
// Calls
vm.expectCall(address target, bytes memory data);
vm.expectCall(address target, uint256 value, bytes memory data);
vm.expectCall(address target, bytes memory data, uint64 count);
Snapshots
uint256 snapshot = vm.snapshot();
vm.revertTo(snapshot);
vm.revertToAndDelete(snapshot);
Environment
// Read .env
string memory value = vm.envString("KEY");
uint256 value = vm.envUint("KEY");
address value = vm.envAddress("KEY");
bool value = vm.envBool("KEY");
bytes32 value = vm.envBytes32("KEY");
// With default
uint256 value = vm.envOr("KEY", uint256(100));
// Check existence
bool exists = vm.envExists("KEY");
Cryptography
// Sign message
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
(bytes32 r, bytes32 vs) = vm.signCompact(privateKey, digest);
// Get address from private key
address addr = vm.addr(privateKey);
// Derive key from mnemonic
uint256 key = vm.deriveKey(mnemonic, index);
uint256 key = vm.deriveKey(mnemonic, path, index);
// Create wallet
Vm.Wallet memory wallet = vm.createWallet("label");
Vm.Wallet memory wallet = vm.createWallet(privateKey);
Labels & Debugging
vm.label(address, "name");
string memory name = vm.getLabel(address);
vm.breakpoint(); // Debugger breakpoint
vm.breakpoint("name"); // Named breakpoint
Gas Metering
vm.pauseGasMetering();
// ... expensive operations ...
vm.resumeGasMetering();
Vm.Gas memory gas = vm.lastCallGas();
File I/O
string memory content = vm.readFile("path/to/file");
vm.writeFile("path/to/file", "content");
bool exists = vm.exists("path/to/file");
vm.removeFile("path/to/file");
Test Verbosity Levels
forge test # Summary only
forge test -v # + Logs
forge test -vv # + Assertion errors
forge test -vvv # + Stack traces (failures)
forge test -vvvv # + Stack traces (all) + setup
forge test -vvvvv # + All traces always
Best Practices
Test Organization
- One test file per contract
- Group related tests in same contract
- Use descriptive function names
- Include failure reason in test name
Fuzz Testing
- Always
bound()numeric inputs - Use
vm.assume()sparingly (reduces coverage) - Use fixtures for edge cases
- Set deterministic seed in CI
Invariant Testing
- Use handler pattern for complex protocols
- Track ghost variables for assertions
- Start with simple invariants
- Increase depth/runs gradually
Fork Testing
- Pin to specific block for reproducibility
- Configure RPC endpoints in foundry.toml
- Use
vm.makePersistent()for deployed contracts - Cache responses with deterministic seed
General
- Test public interface, not internal state
- Use
makeAddr()for labeled addresses - Use
deal()instead of minting - Label important addresses for traces
- Test both success and failure paths