Loading...
Loading...
Smart contract testing with Foundry — unit tests, fuzz testing, fork testing, invariant testing. What to test, what not to test, and what LLMs get wrong.
npx skill4agent add austintgriffith/ethskills testingname()forge testdeposit(100)balance[user] = 100// test/MyContract.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
token = new MyToken("Test", "TST", 1_000_000e18);
// Give alice some tokens for testing
token.transfer(alice, 10_000e18);
}
function test_TransferUpdatesBalances() public {
vm.prank(alice);
token.transfer(bob, 1_000e18);
assertEq(token.balanceOf(alice), 9_000e18);
assertEq(token.balanceOf(bob), 1_000e18);
}
function test_TransferEmitsEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 500e18);
vm.prank(alice);
token.transfer(bob, 500e18);
}
function test_RevertWhen_TransferExceedsBalance() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(bob, 999_999e18); // More than alice has
}
function test_RevertWhen_TransferToZeroAddress() public {
vm.prank(alice);
vm.expectRevert();
token.transfer(address(0), 100e18);
}
}// Equality
assertEq(actual, expected);
assertEq(actual, expected, "descriptive error message");
// Comparisons
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
// Approximate equality (for math with rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%)
// Revert expectations
vm.expectRevert(); // Any revert
vm.expectRevert("Insufficient balance"); // Specific message
vm.expectRevert(MyContract.CustomError.selector); // Custom error
// Event expectations
vm.expectEmit(true, true, false, true); // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);// ✅ TEST: Edge cases that lose money
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }
// ✅ TEST: Access control
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }
// ✅ TEST: Failure modes
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }
// ❌ DON'T TEST: OpenZeppelin internals
// function test_NameReturnsName() — they already tested this
// function test_SymbolReturnsSymbol() — waste of time
// function test_DecimalsReturns18() — it does, trust it// Foundry calls this with random amounts
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
// Bound input to valid range
amount = bound(amount, 1, token.balanceOf(alice));
uint256 balanceBefore = token.balanceOf(alice);
vm.startPrank(alice);
token.approve(address(vault), amount);
vault.deposit(amount, alice);
vault.withdraw(vault.balanceOf(alice), alice, alice);
vm.stopPrank();
// Property: user gets back what they deposited (minus any fees)
assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding
}// bound() is preferred over vm.assume() — bound reshapes, assume discards
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
amount = bound(amount, 1e6, 1e30); // Reasonable token amounts
feeBps = bound(feeBps, 1, 10_000); // 0.01% to 100%
uint256 fee = (amount * feeBps) / 10_000;
uint256 afterFee = amount - fee;
// Property: fee + remainder always equals original
assertEq(fee + afterFee, amount);
}
// vm.assume() discards inputs — use sparingly
function testFuzz_Division(uint256 a, uint256 b) public {
vm.assume(b > 0); // Skip zero (would revert)
// ...
}# Default: 256 runs
forge test
# More thorough: 10,000 runs
forge test --fuzz-runs 10000
# Set in foundry.toml for CI
# [fuzz]
# runs = 1000contract SwapTest is Test {
// Real mainnet addresses
address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
address constant WETH = 0xC02aaA39b223FE8D0A0e5d4F533d69895b411153;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
function setUp() public {
// Fork mainnet at a specific block for reproducibility
vm.createSelectFork("mainnet", 19_000_000);
}
function test_SwapETHForUSDC() public {
address user = makeAddr("user");
vm.deal(user, 1 ether);
vm.startPrank(user);
// Build swap path
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: user,
amountIn: 0.1 ether,
amountOutMinimum: 0, // In production, NEVER set to 0
sqrtPriceLimitX96: 0
});
// Execute swap
uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);
vm.stopPrank();
// Verify we got USDC back
assertGt(amountOut, 0, "Should receive USDC");
assertGt(IERC20(USDC).balanceOf(user), 0);
}
}# Fork from RPC URL
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# Fork at specific block (reproducible)
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
# Set in foundry.toml to avoid CLI flags
# [rpc_endpoints]
# mainnet = "${MAINNET_RPC_URL}"contract VaultInvariantTest is Test {
MyVault public vault;
IERC20 public token;
VaultHandler public handler;
function setUp() public {
token = new MockERC20("Test", "TST", 18);
vault = new MyVault(token);
handler = new VaultHandler(vault, token);
// Tell Foundry which contract to call randomly
targetContract(address(handler));
}
// This runs after every random sequence
function invariant_TotalAssetsMatchesBalance() public view {
assertEq(
vault.totalAssets(),
token.balanceOf(address(vault)),
"Total assets must equal actual balance"
);
}
function invariant_SharePriceNeverZero() public view {
if (vault.totalSupply() > 0) {
assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero");
}
}
}
// Handler: guided random actions
contract VaultHandler is Test {
MyVault public vault;
IERC20 public token;
constructor(MyVault _vault, IERC20 _token) {
vault = _vault;
token = _token;
}
function deposit(uint256 amount) public {
amount = bound(amount, 1, 1e24);
deal(address(token), msg.sender, amount);
vm.startPrank(msg.sender);
token.approve(address(vault), amount);
vault.deposit(amount, msg.sender);
vm.stopPrank();
}
function withdraw(uint256 shares) public {
uint256 maxShares = vault.balanceOf(msg.sender);
if (maxShares == 0) return;
shares = bound(shares, 1, maxShares);
vm.prank(msg.sender);
vault.redeem(shares, msg.sender, msg.sender);
}
}# Default depth (15 calls per sequence, 256 sequences)
forge test
# Deeper exploration
forge test --fuzz-runs 1000
# Configure in foundry.toml
# [invariant]
# runs = 512
# depth = 50ERC20.transferrequiremappingname()expectEmitforge snapshotslither .forge test -vvv