Foundry Forge: Modern Solidity Testing in Pure Solidity
For years, JavaScript was the only option for Solidity testing. Hardhat and Truffle gave you a familiar environment, but context-switching between Solidity contracts and JavaScript test files created friction. You had to constantly think about type conversions, BigNumber handling, and the impedance mismatch between the two languages.
Foundry changes this. Tests are written in Solidity. You deploy contracts in Solidity. You make assertions in Solidity. The mental model stays consistent, and as a bonus, Foundry is dramatically faster than Hardhat for large test suites.
This guide covers Foundry from installation through advanced features: fuzz testing, invariant testing, gas snapshots, and CI integration.
Foundry vs. Hardhat: When to Use Which
Before diving in, the honest comparison:
Foundry wins on:
- Test execution speed (10-100x faster for large suites)
- Writing tests in Solidity (no context switching)
- Built-in fuzz testing and invariant testing
- Built-in gas snapshots
- Simpler dependency management (uses git submodules)
Hardhat wins on:
- JavaScript/TypeScript ecosystem integration
- Plugin ecosystem (Tenderly, Etherscan verification)
- Familiarity for web3.js/ethers.js users
- Scripting complex deployment flows in JavaScript
Many serious teams use both: Foundry for unit and fuzz testing, Hardhat for deployment scripts and integration tests that need JavaScript tooling.
Installation and Setup
Foundry installs via foundryup:
curl -L https://foundry.paradigm.xyz | bash
foundryupThis installs four tools: forge (testing/building), cast (CLI interactions), anvil (local node), and chisel (Solidity REPL).
Create a new project:
forge init my-project
cd my-projectThe project structure:
my-project/
├── src/ # Contracts
├── test/ # Tests (.t.sol files)
├── script/ # Deployment scripts
├── lib/ # Dependencies (git submodules)
└── foundry.toml # ConfigurationInstall OpenZeppelin:
forge install OpenZeppelin/openzeppelin-contractsAdd the remapping to foundry.toml:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]
[profile.default.fuzz]
runs = 1000
[profile.default.invariant]
runs = 500
depth = 50Writing Tests with forge-std
Every test file imports from forge-std, which provides the Test base contract:
// test/Counter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
address public owner = makeAddr("owner");
address public user = makeAddr("user");
function setUp() public {
vm.prank(owner);
counter = new Counter();
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function test_SetNumber() public {
counter.setNumber(42);
assertEq(counter.number(), 42);
}
function test_RevertWhen_CallerIsNotOwner() public {
vm.prank(user);
vm.expectRevert();
counter.adminReset();
}
}Test function naming conventions:
test_*— standard teststestFuzz_*— fuzz tests (Foundry auto-detects parameters)testFail_*— expect a revert (legacy, prefervm.expectRevert)invariant_*— invariant tests
Core Assertions: assertEq, expectRevert, expectEmit
The forge-std Test contract gives you typed assertions:
// Equality
assertEq(a, b);
assertEq(a, b, "custom error message");
// Approximate equality (useful for floating point-like values)
assertApproxEqAbs(a, b, delta); // |a - b| <= delta
assertApproxEqRel(a, b, percentDelta); // relative difference
// Boolean
assertTrue(condition);
assertFalse(condition);
// Greater/less than
assertGt(a, b); // a > b
assertLt(a, b); // a < b
assertGe(a, b); // a >= b
assertLe(a, b); // a <= bFor reverts, vm.expectRevert is placed immediately before the call that should revert:
function test_RevertWhen_InsufficientBalance() public {
vm.expectRevert(
abi.encodeWithSelector(ERC20.ERC20InsufficientBalance.selector, user, 0, 100)
);
token.transfer(user, 100);
}
function test_RevertWhen_StringError() public {
vm.expectRevert("Exceeds max supply");
token.mint(user, type(uint256).max);
}
function test_RevertWhen_AnyError() public {
vm.expectRevert();
token.connect(user).adminFunction();
}For events, vm.expectEmit declares which fields to check before the emitting call:
function test_EmitsTransferEvent() public {
// Parameters: checkTopic1, checkTopic2, checkTopic3, checkData
vm.expectEmit(true, true, false, true);
emit Transfer(address(this), user, 1000);
token.transfer(user, 1000);
}The four booleans control which parts of the event are checked: the three indexed topics and the non-indexed data.
Fuzz Testing with Foundry
This is where Foundry genuinely shines. Add parameters to a test function and Foundry automatically generates random inputs:
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract MyTokenFuzzTest is Test {
MyToken token;
address owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
token = new MyToken("Test", "TST", 1_000_000e18, owner);
vm.stopPrank();
}
// Foundry generates random values for `amount` across 1000 runs
function testFuzz_Transfer(uint256 amount) public {
amount = bound(amount, 1, 1_000_000e18);
address recipient = makeAddr("recipient");
vm.prank(owner);
token.transfer(recipient, amount);
assertEq(token.balanceOf(recipient), amount);
assertEq(token.balanceOf(owner), 1_000_000e18 - amount);
}
function testFuzz_MintNeverExceedsMaxSupply(uint256 mintAmount) public {
uint256 currentSupply = token.totalSupply();
uint256 maxSupply = token.MAX_SUPPLY();
uint256 remaining = maxSupply - currentSupply;
mintAmount = bound(mintAmount, 0, remaining);
vm.prank(owner);
token.mint(owner, mintAmount);
assertLe(token.totalSupply(), maxSupply);
}
function testFuzz_BurnReducesSupply(uint256 burnAmount) public {
burnAmount = bound(burnAmount, 1, 1_000_000e18);
uint256 supplyBefore = token.totalSupply();
vm.prank(owner);
token.burn(burnAmount);
assertEq(token.totalSupply(), supplyBefore - burnAmount);
}
}The bound(value, min, max) function is essential — it constrains random inputs to valid ranges without rejecting runs (which would waste the fuzz budget).
Configure fuzz runs in foundry.toml:
[profile.default.fuzz]
runs = 10000
seed = "0xdeadbeef"
max_test_rejects = 65536Invariant Testing
Invariant tests check properties that must hold across any sequence of contract interactions, not just individual function calls.
// test/MyToken.invariants.t.sol
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract Handler is Test {
MyToken public token;
address public owner;
address[] public actors;
constructor(MyToken _token, address _owner) {
token = _token;
owner = _owner;
for (uint i = 0; i < 5; i++) {
actors.push(makeAddr(string(abi.encodePacked("actor", i))));
}
}
function mint(uint256 actorSeed, uint256 amount) public {
address actor = actors[actorSeed % actors.length];
uint256 remaining = token.MAX_SUPPLY() - token.totalSupply();
amount = bound(amount, 0, remaining);
vm.prank(owner);
token.mint(actor, amount);
}
function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) public {
address from = actors[fromSeed % actors.length];
address to = actors[toSeed % actors.length];
amount = bound(amount, 0, token.balanceOf(from));
vm.prank(from);
token.transfer(to, amount);
}
function burn(uint256 actorSeed, uint256 amount) public {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 0, token.balanceOf(actor));
if (amount == 0) return;
vm.prank(actor);
token.burn(amount);
}
}
contract MyTokenInvariantTest is Test {
MyToken token;
Handler handler;
address owner = makeAddr("owner");
function setUp() public {
vm.prank(owner);
token = new MyToken("Test", "TST", 0, owner);
handler = new Handler(token, owner);
targetContract(address(handler));
}
function invariant_TotalSupplyNeverExceedsMax() public view {
assertLe(token.totalSupply(), token.MAX_SUPPLY());
}
function invariant_SumOfBalancesEqualsTotalSupply() public view {
uint256 sum = 0;
address[] memory actors = handler.actors();
for (uint i = 0; i < actors.length; i++) {
sum += token.balanceOf(actors[i]);
}
sum += token.balanceOf(owner);
assertEq(sum, token.totalSupply());
}
}The Handler pattern is the key — it acts as a puppet that Foundry's fuzzer calls with random arguments. This lets you control preconditions while still exercising all code paths.
Gas Snapshot Testing
Foundry can take gas snapshots and fail tests if gas usage increases:
forge snapshot # Creates .gas-snapshot file
forge snapshot --check # Fails if gas usage changed
forge snapshot --diff # Shows diff from last snapshotThe .gas-snapshot file looks like:
CounterTest:test_Increment() (gas: 28383)
CounterTest:test_SetNumber() (gas: 26447)
MyTokenTest:test_Transfer() (gas: 54821)Commit .gas-snapshot to version control. CI runs forge snapshot --check and fails if any function's gas usage increases.
Running Tests
forge test # Run all tests
forge test -v # Verbose: show function names
forge test -vv # Very verbose: show logs
forge test -vvvv # Maximum: full traces on failures
forge test --match-test "Transfer" # Filter by test name
forge test --match-contract "Token" # Filter by contract name
forge test --gas-report # Show gas tableCI Integration with GitHub Actions
name: 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
with:
version: nightly
- name: Run Forge build
run: forge build --sizes
- name: Run Forge tests
run: forge test -vvv
- name: Check gas snapshots
run: forge snapshot --check
- name: Run coverage
run: forge coverage --report summaryThe submodules: recursive flag is critical — Foundry dependencies are git submodules and checkout won't include them without it.
Cheatcodes: The vm Object
Foundry's vm cheatcodes let you manipulate EVM state in tests:
vm.prank(address) // Next call comes from address
vm.startPrank(address) // All subsequent calls from address
vm.stopPrank() // Stop pranking
vm.deal(address, amount) // Set ETH balance
vm.warp(timestamp) // Set block.timestamp
vm.roll(blockNumber) // Set block.number
vm.store(contract, slot, value) // Write to storage slot
vm.load(contract, slot) // Read storage slot
vm.expectRevert(...) // Expect next call to revert
vm.expectEmit(...) // Expect next call to emit event
vm.label(address, "name") // Label address in tracesThese are far more ergonomic than Hardhat's equivalent helpers and they work inline in Solidity without await/async overhead.
Foundry is now the foundation of serious smart contract development. Its speed, Solidity-native testing, and built-in security tooling make it the right default for any new project. Start with unit tests, add fuzz tests for core invariants, and let the fuzzer find the cases you didn't think of.