Hardhat unit tests check what you think will go wrong. Foundry fuzz testing finds the edge cases you didn't think of. You’re writing tests for a function that should only accept values between 1 and 100. Your unit test dutifully checks 1, 50, and 100. Meanwhile, Foundry’s fuzzer is already screaming because you forgot about type(uint256).max, zero, and the fact that your logic breaks when block.timestamp is an odd number. Your assumptions are the bug. Foundry is here to break them.
With over 50 million smart contracts deployed and vulnerabilities causing $2.8B in losses in 2025, mostly from reentrancy and access control flaws, hoping your manual tests are enough is professional malpractice. The data is clear: Foundry fuzz testing catches 3x more vulnerabilities than manual unit testing on average. This isn't about preference; it's about survival. Let's move beyond assert(true) and start writing tests that fight back.
Your First Foundry Test: More Than Just assertEq
Foundry tests live alongside your contracts in files named ContractName.t.sol. This isn't just convention; it keeps your tests physically and conceptually close to the code they're trying to break. Open your integrated terminal (Ctrl+\`` in VS Code) and run forge initif you haven't. You'll get atest/` directory, but the real magic is in the structure.
The setUp() function is your test universe's Big Bang. Every single test function runs after setUp(), giving you a clean, predictable state. This is where you deploy your contracts, mint tokens, and assign roles. Think of it as the mandatory pre-game lobby for every test.
// test/MyToken.t.sol
pragma solidity ^0.8.23;
import {Test, stdError} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public owner = makeAddr("owner");
address public user = makeAddr("user");
// This runs before every test.
function setUp() public {
vm.startPrank(owner); // Cheatcode: set msg.sender
token = new MyToken();
vm.stopPrank();
// Fund the user for gas, because tests run with 0 balance.
vm.deal(user, 100 ether);
}
function test_InitialBalance() public {
assertEq(token.balanceOf(owner), token.totalSupply());
}
}
The assertion library is your toolbox. assertEq is your hammer, but don't forget the rest:
assertEq(a, b): Equality for values, arrays, strings.assertEqDecimal(a, b, decimals): For comparing fixed-point numbers (e.g., USDC with 6 decimals).assertTrue/assertFalse: State checks.assertGt,assertGe,assertLt,assertLe: Greater-than, etc.assertEq(keccak256(abi.encode(a)), keccak256(abi.encode(b))): For comparing complex structs.
Run it with forge test --match-test test_InitialBalance -vv. The -vv flag shows logs. You'll see why Foundry is the speed demon: 2.1s vs 8.4s for 100 tests — Foundry is 4x faster than Hardhat. That difference is between checking your email between test runs and staying in the flow.
Unit Testing: Expecting Things to Go Right (and Wrong)
Unit tests verify the "happy path" and the expected failure modes. The key is being explicit about what should fail and why. Foundry's vm.expectRevert() is your sentinel.
Let's test a simple vault with a withdrawal function. The classic, catastrophic error is the reentrancy vulnerability.
Real Error & Fix:
Vulnerability:
withdraw()calls external contract (user) before updating internal state. Fix: Use the checks-effects-interactions pattern. Always update state before making external calls.
// Incorrect, vulnerable pattern (DO NOT USE):
function withdraw(uint256 amount) public {
require(balance[msg.sender] >= amount, "insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
balance[msg.sender] -= amount; // STATE UPDATE TOO LATE
}
// Correct, safe pattern:
function withdraw(uint256 amount) public {
require(balance[msg.sender] >= amount, "insufficient balance");
balance[msg.sender] -= amount; // STATE UPDATE FIRST
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
Your unit tests must enforce this.
function test_WithdrawRevertsOnInsufficientBalance() public {
vm.startPrank(user);
uint256 excessiveAmount = token.balanceOf(owner) + 1;
// Expect a revert with the exact error message
vm.expectRevert("insufficient balance");
token.transfer(user, excessiveAmount);
vm.stopPrank();
}
function test_WithdrawHappyPath() public {
uint256 transferAmount = 100;
vm.prank(owner);
token.transfer(user, transferAmount);
assertEq(token.balanceOf(user), transferAmount);
assertEq(token.balanceOf(owner), token.totalSupply() - transferAmount);
}
Use vm.expectRevert(bytes4(keccak256("CustomError()"))) for custom errors. This precision ensures your contract reverts for the right reason, not just any reason.
Fuzz Testing: Let the Machine Do the Breaking
This is where Foundry separates the hobbyists from the professionals. You write a test invariant or property, and Foundry runs it with hundreds of random inputs, trying to falsify it.
A fuzz test is just a unit test with parameters. Foundry automatically runs it with many random values.
// This test will run ~256 times with random `amount` values.
function testFuzz_TransferDoesNotExceedBalance(address sender, address recipient, uint256 amount) public {
// Assumptions: constrain the fuzzer to valid starting states.
vm.assume(sender != address(0) && recipient != address(0));
vm.assume(sender != recipient);
// Give the sender some balance to work with. We'll cheat and mint to them.
vm.prank(owner);
token.transfer(sender, amount); // This assumes amount isn't crazy large.
// Now, test the property: transferring your entire balance should work.
vm.prank(sender);
token.transfer(recipient, amount);
assertEq(token.balanceOf(recipient), amount);
}
But what about overflow? If you're on Solidity <0.8.0, this was a major risk.
Real Error & Fix:
Vulnerability: Integer overflow in
balanceOf[sender] -= amount;in Solidity <0.8.0. Fix: 1) Upgrade to Solidity 0.8.x (overflow protection is built-in). 2) If stuck on older version, use OpenZeppelin's SafeMath library for all arithmetic.
Foundry's fuzzer will find overflow edges in older code. The key is writing properties that should always hold, like "the sum of all balances equals totalSupply" or "a transfer never creates tokens."
Fork Testing: Your Mainnet Sandbox
You're integrating with Curve, using Uniswap V3 as an oracle, or testing a fork of Compound. You can't replicate this complex, live state in a local test. Enter vm.createFork().
Fork testing lets you run tests against a snapshot of a real network. It's like having a mainnet sandbox where you can manipulate anything as the test runner.
function test_InteractionWithLiveProtocol() public {
// Create a fork of Ethereum mainnet at a specific block.
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 forkId = vm.createFork(MAINNET_RPC_URL, 19_000_000);
// Select the fork for this test.
vm.selectFork(forkId);
// Now you can interact with *real, deployed contracts*.
address liveUniswapV3Pool = 0x...;
IUniswapV3Pool pool = IUniswapV3Pool(liveUniswapV3Pool);
// You are the god of this fork. Need 10,000 ETH to test something?
vm.deal(address(this), 10_000 ether);
// Test your contract's interaction with the live pool.
(uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
// Your logic here...
// All changes are isolated to this fork. You're not affecting real mainnet.
}
This is invaluable for testing integrations, simulating governance proposals, or debugging a transaction that happened on-chain. Store your RPC URL in a .env file and load it with vm.envString. Never hardcode secrets.
The vm Cheatcode: Your Time Machine and God Mode
The vm cheatcode is Foundry's Swiss Army knife. It manipulates the EVM environment. Here are the essentials:
- vm.warp(uint256): Set
block.timestamp. Test time-locks, vesting, staking periods. - vm.roll(uint256): Set
block.number. Test functions that depend on block numbers. - vm.deal(address, uint256): Give an address ETH. No more begging from a faucet.
- vm.prank(address): Set
msg.senderfor the next call.vm.startPrankfor multiple calls. - vm.mockCall(address, bytes memory, bytes memory): Mock the return value of any external call. Perfect for testing Chainlink oracle interactions without deploying mocks.
- vm.expectEmit(true, true, true, true): Check that an event was emitted with the correct data.
function test_StakingTimeLock() public {
vm.startPrank(user);
stakingContract.stake{value: 1 ether}(1 ether);
// Warp 6 days into the future. Lock period is 7 days.
vm.warp(block.timestamp + 6 days);
// Should revert, lock not expired.
vm.expectRevert("Locked");
stakingContract.withdraw();
// Warp one more day.
vm.warp(block.timestamp + 1 days);
// Now it should succeed.
stakingContract.withdraw();
vm.stopPrank();
}
Measuring Your Blind Spots with forge coverage
You've written tests. Are they any good? forge coverage tells you. It shows which lines of your Solidity code are executed during your test run.
Run forge coverage --report lcov and generate a detailed HTML report. The goal isn't 100%—some code, like extreme failure cases, may be untestable. But hitting 80-90% significantly de-risks your code before an audit. It visually highlights the unchecked if statement or the obscure require that could be a backdoor.
Shipping It: CI with GitHub Actions
Tests that only run on your machine are useless. Automate. Here's a minimal GitHub Actions workflow to run Foundry tests on every push.
name: Foundry Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run Tests
run: forge test --gas-report -vvv
- name: Run Coverage
run: forge coverage --report lcov
# Optional: Cache forge build for faster runs
- name: Cache Foundry
uses: actions/cache@v3
with:
path: |
~/.foundry/cache
~/.svm
out/
key: ${{ runner.os }}-foundry-${{ hashFiles('foundry.toml') }}
Add a foundry.toml to configure your project. Critical setting: ffi = true if you need to call external scripts (e.g., for fork tests using an RPC URL from env).
Tool Comparison: Why Foundry Wins the Benchmarks
Let's look at the numbers. This isn't about tribal warfare; it's about efficiency and security.
| Tool / Task | Foundry (forge test) | Hardhat (npx hardhat test) | Notes |
|---|---|---|---|
| Speed (100 tests) | 2.1 seconds | 8.4 seconds | Foundry is written in Rust, runs in-process. Hardhat runs tests in a separate node process. |
| Fuzz Testing | Native, 1st-class | Requires 3rd-party plugin (e.g., hardhat-foundry) | Foundry's fuzzer is integrated and optimized. |
| Gas Reports | --gas-report flag | Available via plugin | Foundry's is built-in and detailed per function call. |
| Fork Testing | vm.createFork() | hardhat_reset to fork | Both are capable, but Foundry's is more granular per-test. |
| Static Analysis | Not its focus | Not its focus | Use Slither (45s for 200 contracts, catches 75% of vuln classes) for this. |
The speed difference alone is a game-changer for TDD. Waiting 8 seconds for feedback breaks concentration. Two seconds means you stay in the zone.
Next Steps: From Foundry to Formal Verification
You've mastered Foundry's core. Your tests are fast, your fuzzer is brutal, and your fork tests mirror mainnet. What's next?
- Integrate Static Analysis: Run Slither (
slither .) in your CI pipeline. It catches different bug classes—like incorrect ERC20 signatures or flawed proxy storage layouts—that dynamic testing might miss. - Up the Fuzzing Game: Look at Echidna for stateful property fuzzing. While Foundry excels at function-level fuzzing, Echidna can test invariants across sequences of transactions (e.g., "liquidity can never be removed from this pool without a fee").
- Consider Formal Verification: For life-critical DeFi protocols (bridges, stablecoins), tools like Certora use formal methods to prove that certain properties hold under all conditions, not just the billions of cases a fuzzer can run. This is the next frontier.
- Optimize Relentlessly: Remember, using
uint128vsuint256in packed structs can save 15–20% gas. Foundry's gas report will show you where to focus. Compare your custom ERC20 to OpenZeppelin's (~21,000 gas vs ~29,000 gas) and ask why.
Your testing suite is now a vulnerability-hunting machine. You're not just checking boxes; you're systematically breaking your own code before an attacker can. In a world where a single unchecked edge case can mean eight figures in losses, that's not just best practice. It's the only responsible way to build.