Test smart contracts
you can actually trust.
In this guide you’ll learn Foundry testing step-by-step — from unit tests to fuzzing and invariants. You’ll build a real tested contract and walk away with a workflow used in production teams and audits.
function test_DepositIncreasesBalance() public {
vault.deposit{value: 1 ether}();
assertEq(vault.balanceOf(address(this)), 1 ether);
}
What you’ll learn
This is a practical crash course. No random theory dumps — everything is anchored to real code you’ll run.
- Setting up a clean Foundry project
- Writing unit tests with forge-std/Test.sol
- Using cheatcodes: prank, deal, warp, expectRevert
- Fuzz testing with randomized inputs
- Invariant testing to catch “impossible states”
- Debugging failing tests like an adult
If you already shipped at least one Solidity project and want faster & more powerful tests than Hardhat gives — Foundry is your next step.
- ✓ Writing basic Solidity contracts
- ✓ Using CLI tools / terminal
- ✓ Understanding msg.sender / msg.value / reverts
- • Some unit testing experience (any framework)
Install Foundry and initialize a project
1. Install Foundry (macOS / Linux / WSL):
curl -L https://foundry.paradigm.xyz | bash
foundryup
2. Create a new project:
mkdir foundry-testing-crash-course
cd foundry-testing-crash-course
forge init
You now have a skeleton project with: src/, test/, and lib/.
Foundry evolves fast. If something feels broken, run: foundryup.
- ✓ forge — compile & test
- ✓ cast — interact with chains
- ✓ anvil — local node
- ✓ chisel — Solidity REPL
In this guide we’ll mainly use forge test.
Build a tiny contract worth testing
Delete the sample contract in src/Counter.sol (if any), and create src/MiniVault.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice A minimal ETH vault with deposits & withdrawals.
contract MiniVault {
mapping(address => uint256) public balanceOf;
error InsufficientBalance();
event Deposit(address indexed user, uint256 amount);
event Withdraw(address indexed user, uint256 amount);
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
if (balanceOf[msg.sender] < amount) revert InsufficientBalance();
balanceOf[msg.sender] -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "TRANSFER_FAILED");
emit Withdraw(msg.sender, amount);
}
}
This contract is simple, but it already has meaningful invariant rules:
- User balances must never go negative
- Vault shouldn’t lose ETH unless someone withdraws
- Reverts should trigger correctly on bad withdrawals
Compile once:
forge build
Write unit tests
Foundry tests are Solidity contracts stored in test/. They inherit from forge-std/Test.sol.
Create test/MiniVault.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MiniVault.sol";
contract MiniVaultTest is Test {
MiniVault vault;
address alice = address(0xA11CE);
address bob = address(0xB0B);
function setUp() public {
vault = new MiniVault();
// Give accounts ETH for testing
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function test_DepositIncreasesBalance() public {
vm.prank(alice);
vault.deposit{value: 2 ether}();
assertEq(vault.balanceOf(alice), 2 ether);
}
function test_WithdrawDecreasesBalance() public {
vm.startPrank(alice);
vault.deposit{value: 3 ether}();
vault.withdraw(1 ether);
vm.stopPrank();
assertEq(vault.balanceOf(alice), 2 ether);
}
function test_RevertOnTooLargeWithdraw() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
vm.prank(alice);
vm.expectRevert(MiniVault.InsufficientBalance.selector);
vault.withdraw(2 ether);
}
}
Run tests:
forge test -vv
-v = verbose, -vv = logs + traces, -vvv = deepest traces.
How do cheatcodes work?
Cheatcodes are special “testing syscalls” exposed through the vm object. They only exist in tests, never on-chain. That’s why Foundry tests are so powerful.
What is prank / startPrank?
vm.prank(x) makes the next call execute as x. vm.startPrank(x) keeps msg.sender = x until stopPrank().
Fuzz testing: find edge cases automatically
Fuzzing means Foundry generates random inputs for your test function. If any input breaks the test, Foundry prints the minimal failing case.
Add this to MiniVaultTest:
function testFuzz_DepositAndWithdraw(uint96 amount) public {
// bound converts random amount into safe range
uint256 a = bound(uint256(amount), 1 wei, 5 ether);
vm.prank(alice);
vault.deposit{value: a}();
vm.prank(alice);
vault.withdraw(a);
assertEq(vault.balanceOf(alice), 0);
}
Run fuzz tests:
forge test --match-test testFuzz -vv
Smaller types fuzz faster and avoid weird overflow paths. For ETH-based fuzzing, uint96 is a sweet spot.
What does bound() do?
Foundry gives you a fully random value. bound(x, min, max) clamps it into a range so the fuzzing stays meaningful.
Invariants: test the rules, not a single path
Invariant tests repeatedly call random actions on a contract and verify some property always holds.
Create a handler contract in test/handlers/MiniVaultHandler.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../../src/MiniVault.sol";
contract MiniVaultHandler is Test {
MiniVault public vault;
address[] public users;
constructor(MiniVault _vault) {
vault = _vault;
users.push(address(0xA11CE));
users.push(address(0xB0B));
vm.deal(users[0], 100 ether);
vm.deal(users[1], 100 ether);
}
function deposit(uint256 amount, uint256 userIndex) public {
address user = users[userIndex % users.length];
uint256 a = bound(amount, 1 wei, 5 ether);
vm.prank(user);
vault.deposit{value: a}();
}
function withdraw(uint256 amount, uint256 userIndex) public {
address user = users[userIndex % users.length];
uint256 bal = vault.balanceOf(user);
uint256 a = bound(amount, 0, bal);
vm.prank(user);
vault.withdraw(a);
}
}
Now create an invariant test file test/MiniVault.invariant.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MiniVault.sol";
import "./handlers/MiniVaultHandler.sol";
contract MiniVaultInvariantTest is Test {
MiniVault vault;
MiniVaultHandler handler;
function setUp() public {
vault = new MiniVault();
handler = new MiniVaultHandler(vault);
targetContract(address(handler));
}
/// Invariant: vault ETH == sum of user balances tracked in contract
function invariant_VaultSolvency() public {
uint256 sum;
sum += vault.balanceOf(address(0xA11CE));
sum += vault.balanceOf(address(0xB0B));
assertEq(address(vault).balance, sum);
}
}
Run invariants:
forge test --match-test invariant -vv
If your state accounting has even a tiny bug, invariants usually catch it. That’s why auditors love them.
Debugging failing tests fast
Foundry gives you great tools to understand why something failed. Here are the important ones:
- forge test -vvv — full call traces
- console2.log() — logs from Solidity tests
- --match-test — run only specific tests
- --gas-report — see gas costs per function
Try adding a log:
import "forge-std/console2.sol";
console2.log("alice balance", vault.balanceOf(alice));
Foundry tests don’t see my new file
Run forge clean then forge test. If it’s still weird, check import paths and file naming (.t.sol).
Where to go from here
Nice! You’ve just:
- Installed Foundry and initialized a project
- Wrote unit tests with cheatcodes
- Added fuzz tests to auto-discover edge cases
- Built invariant tests for solvency rules
- Learned how to debug failures quickly
This is already enough to test most real-world contracts. Next, level up by testing DeFi patterns and security properties.
- Test ERC-20/721 integrations
- Fuzz + invariants for AMMs / vaults / staking
- Property-based security testing
- → Foundry deployment · scripts, broadcasting, verify
- → Security invariants · reentrancy, access control, solvency
- → Hardhat vs Foundry · when to use which stack
Link these to your docs like foundry.html, security.html, or a comparison page.