Solidity Unit Testing with Foundry Forge: A Complete Guide

Solidity Unit Testing with Foundry Forge: A Complete Guide

Foundry Forge lets you write Solidity tests in Solidity itself, eliminating the JavaScript layer that Hardhat requires. Combined with cheatcodes for precise EVM state control, built-in fuzzing, invariant testing, and gas snapshots, Foundry is the fastest and most capable testing framework available for Solidity developers today.

Key Takeaways

Forge tests are Solidity contracts. Every test file is a Solidity contract that inherits from Test. Functions prefixed with test are run automatically; setUp runs before each test.

Cheatcodes give you EVM superpowers. The vm object exposes vm.prank, vm.deal, vm.warp, vm.expectRevert, and dozens of other primitives that let you control any aspect of EVM state from within a test.

Fuzz testing requires only adding a parameter. Any test function that accepts parameters becomes a fuzz test — Forge generates thousands of random inputs automatically and reports the exact failing case.

Invariant testing operates at the sequence level. Forge repeatedly calls random functions with random inputs and checks that user-defined invariants hold after every call — this catches bugs that require specific sequences of actions.

Gas snapshots catch regressions early. forge snapshot records gas costs per test. A changed snapshot alerts you when a change unexpectedly increased gas consumption.

Installing Foundry

Foundry is distributed as a single installer that manages the toolchain:

curl -L https://foundry.paradigm.xyz | bash
foundryup

This installs four binaries: forge (compiler and test runner), cast (CLI for interacting with contracts), anvil (local Ethereum node), and chisel (Solidity REPL).

Verify the installation:

forge --version
# forge 0.2.0 (a123456 2024-01-15T00:00:00.000000000Z)

Creating a New Project

forge init my-project
cd my-project

The default project structure:

my-project/
├── foundry.toml          # project configuration
├── lib/
│   └── forge-std/        # standard test library (git submodule)
├── src/
│   └── Counter.sol       # example contract
├── test/
│   └── Counter.t.sol     # example test
└── script/
    └── Counter.s.sol     # deployment script

The .t.sol suffix is convention for test files. Foundry discovers and runs all contracts that inherit from Test.

foundry.toml Configuration

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
fuzz = { runs = 1000 }       # fuzz test iterations
invariant = { runs = 256, depth = 15 }  # invariant test config

[profile.ci]
fuzz = { runs = 10000 }      # more runs in CI

The forge test Command

forge test                        <span class="hljs-comment"># run all tests
forge <span class="hljs-built_in">test -v                     <span class="hljs-comment"># verbose: show test names
forge <span class="hljs-built_in">test -vv                    <span class="hljs-comment"># show logs (console.log output)
forge <span class="hljs-built_in">test -vvv                   <span class="hljs-comment"># show execution traces on failure
forge <span class="hljs-built_in">test -vvvv                  <span class="hljs-comment"># show full traces for all tests

forge <span class="hljs-built_in">test --match-test testFuzz  <span class="hljs-comment"># run tests matching a pattern
forge <span class="hljs-built_in">test --match-contract Token <span class="hljs-comment"># run tests in contracts matching a pattern
forge <span class="hljs-built_in">test --match-path <span class="hljs-built_in">test/ERC20.t.sol  <span class="hljs-comment"># run specific test file
forge <span class="hljs-built_in">test --fork-url <span class="hljs-variable">$RPC_URL    <span class="hljs-comment"># fork mainnet state for tests
forge <span class="hljs-built_in">test --fork-block-number 19000000  <span class="hljs-comment"># fork at specific block

Anatomy of a Forge Test

// test/Token.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/Token.sol";

contract TokenTest is Test {
    Token public token;
    address public owner;
    address public alice;
    address public bob;

    // setUp runs before EVERY test function
    function setUp() public {
        owner = address(this);
        alice = makeAddr("alice");   // deterministic address from label
        bob   = makeAddr("bob");

        token = new Token("MyToken", "MTK", 18);
        token.mint(alice, 1000e18);
    }

    // test_ prefix: regular test
    function test_AliceStartsWithBalance() public view {
        assertEq(token.balanceOf(alice), 1000e18);
    }

    // testFail_ prefix: test passes when function REVERTS
    function testFail_TransferMoreThanBalance() public {
        vm.prank(alice);
        token.transfer(bob, 2000e18);  // should revert
    }
}

Assertions

Foundry provides a comprehensive set of assertions from forge-std/Test.sol:

assertEq(a, b);                   // a == b
assertEq(a, b, "custom message"); // with message
assertNotEq(a, b);                // a != b
assertGt(a, b);                   // a > b
assertGe(a, b);                   // a >= b
assertLt(a, b);                   // a < b
assertLe(a, b);                   // a <= b
assertTrue(condition);             // condition is true
assertFalse(condition);            // condition is false

// For approximate equality (useful for floating point-like math)
assertApproxEqAbs(a, b, delta);   // |a - b| <= delta
assertApproxEqRel(a, b, 1e16);   // within 1% (1e16 = 1% in wad)

Cheatcodes

Cheatcodes are accessed through the vm object (an instance of Vm from forge-std). They are injected by the Forge EVM and do not exist in production deployments.

Identity and Caller Control

// Make the next call come from alice
vm.prank(alice);
token.transfer(bob, 100e18);

// Make ALL subsequent calls come from alice until stopPrank
vm.startPrank(alice);
token.approve(address(router), type(uint256).max);
router.swapExactTokensForETH(100e18, 0, path, alice, block.timestamp);
vm.stopPrank();

// Change msg.sender AND tx.origin
vm.prank(alice, alice);

Balance Manipulation

// Give alice 10 ETH
vm.deal(alice, 10 ether);

// Give alice 1000 USDC (works with any token — sets storage directly)
deal(address(usdc), alice, 1000e6);

// The standalone deal() also works with tokens that have complex minting
deal(address(weth), alice, 10e18, true);  // true = adjust totalSupply

Time and Block Manipulation

// Jump forward in time (useful for vesting, locks, cooldowns)
vm.warp(block.timestamp + 30 days);

// Set specific timestamp
vm.warp(1_700_000_000);

// Mine blocks
vm.roll(block.number + 100);

// Manipulate block base fee
vm.fee(10 gwei);

// Manipulate chain ID
vm.chainId(1);  // pretend we're on mainnet

Expecting Reverts and Events

// Expect a revert with a custom error
vm.expectRevert(MyContract.InsufficientBalance.selector);
myContract.withdraw(amount);

// Expect a revert with error parameters
vm.expectRevert(
    abi.encodeWithSelector(
        MyContract.InsufficientBalance.selector,
        requested,
        available
    )
);
myContract.withdraw(requested);

// Expect a string revert (old-style require)
vm.expectRevert("Ownable: caller is not the owner");
myContract.adminFunction();

// Expect an event
// vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData)
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 100e18);  // emit the expected event
token.transferFrom(alice, bob, 100e18);  // then call the function

Storage Manipulation

// Read storage slot directly
bytes32 value = vm.load(address(myContract), bytes32(uint256(0)));

// Write to storage slot directly (bypass all access control)
vm.store(address(myContract), bytes32(uint256(0)), bytes32(uint256(1)));

// Get a contract's bytecode (for mocking)
bytes memory code = address(myContract).code;

Labels and Formatting

// Label addresses for readable traces
vm.label(alice, "Alice");
vm.label(address(token), "USDC");

// Create a named address deterministically
address alice = makeAddr("alice");
// alice == 0x328809Bc894f92807417D2dAD6b7C998c1aFdac (always the same)

Fuzz Testing with Foundry

Adding parameters to a test function makes it a fuzz test. Foundry generates pseudorandom inputs and reports the exact failing case if it finds one.

// This runs 1000 times with random 'amount' values
function testFuzz_Transfer(uint256 amount) public {
    // Bound the input to a valid range
    amount = bound(amount, 1, token.balanceOf(alice));

    uint256 aliceBefore = token.balanceOf(alice);
    uint256 bobBefore   = token.balanceOf(bob);

    vm.prank(alice);
    token.transfer(bob, amount);

    assertEq(token.balanceOf(alice), aliceBefore - amount);
    assertEq(token.balanceOf(bob),   bobBefore   + amount);
}

The bound(value, min, max) function maps any uint256 to the [min, max] range without rejection sampling — it is far more efficient than vm.assume.

// bound examples
amount = bound(amount, 0, 1e18);          // [0, 1 ether]
fee    = bound(fee, 1, 10000);            // [1, 10000] (basis points)
blocks = bound(blocks, 1, 1000);         // [1, 1000]

Use vm.assume only for boolean conditions that cannot be expressed with bound:

function testFuzz_TransferToSelf(address recipient, uint256 amount) public {
    vm.assume(recipient != address(0));       // exclude zero address
    vm.assume(recipient != address(token));   // exclude token contract itself
    amount = bound(amount, 1, 1000e18);
    // ...
}

When a fuzz test fails, Foundry reports the counterexample:

[FAIL. Reason: assertion failed]
  [Counterexample: calldata=..., args=[1000000000000000001]]

Foundry automatically saves counterexamples to ~/.foundry/cache/fuzz/ for regression testing.

Invariant Testing

Invariant testing (stateful fuzzing) calls random functions in random sequences and checks that defined properties hold after every call.

// test/Token.invariant.t.sol
contract TokenInvariantTest is Test {
    Token  public token;
    Handler public handler;

    function setUp() public {
        token   = new Token("MyToken", "MTK", 18);
        handler = new Handler(token);

        // Tell Foundry to call handler functions (not token directly)
        targetContract(address(handler));
    }

    // Invariant: total supply equals the sum of all balances
    function invariant_totalSupplyMatchesBalances() public view {
        assertEq(
            token.totalSupply(),
            token.balanceOf(handler.USER_A()) +
            token.balanceOf(handler.USER_B()) +
            token.balanceOf(address(handler))
        );
    }

    // Invariant: total supply never exceeds max cap
    function invariant_totalSupplyBelowCap() public view {
        assertLe(token.totalSupply(), token.MAX_SUPPLY());
    }
}

// Handler contract controls which actions are taken and tracks actors
contract Handler is Test {
    Token public token;
    address public constant USER_A = address(0xA);
    address public constant USER_B = address(0xB);

    constructor(Token _token) {
        token = _token;
        deal(address(token), USER_A, 1000e18, true);
    }

    function transfer(uint256 amount) external {
        amount = bound(amount, 0, token.balanceOf(USER_A));
        vm.prank(USER_A);
        token.transfer(USER_B, amount);
    }

    function transferBack(uint256 amount) external {
        amount = bound(amount, 0, token.balanceOf(USER_B));
        vm.prank(USER_B);
        token.transfer(USER_A, amount);
    }
}

Run invariant tests:

forge test --match-test invariant -vvv

Gas Snapshots

Track gas consumption over time to catch regressions:

forge snapshot              # create/update .gas-snapshot file
forge snapshot --check      <span class="hljs-comment"># compare current gas to snapshot, fail if different
forge snapshot --diff       <span class="hljs-comment"># show differences from last snapshot

The .gas-snapshot file should be committed to source control:

TokenTest:test_Transfer() (gas: 34521)
TokenTest:testFuzz_Transfer(uint256) (gas: 38142)
TokenTest:test_Approve() (gas: 29341)

When a change increases gas usage, forge snapshot --check fails and you can investigate deliberately.

Testing with Console Output

Use forge-std/console2.sol for debug output during test development:

import "forge-std/console2.sol";

function test_DebugTransfer() public {
    console2.log("alice balance before:", token.balanceOf(alice));
    vm.prank(alice);
    token.transfer(bob, 100e18);
    console2.log("alice balance after:", token.balanceOf(alice));
    console2.log("bob balance after:", token.balanceOf(bob));
}

Output appears with forge test -vv or higher verbosity. Remove console imports before production deployment — they add gas overhead.

CI Integration

# .github/workflows/forge.yml
name: Forge Tests
on: [push, pull_request]

jobs:
  forge:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - uses: foundry-rs/foundry-toolchain@v1
        with:
          version: nightly

      - name: Run tests
        run: forge test --fork-url ${{ secrets.MAINNET_RPC }} -vvv

      - name: Check gas snapshot
        run: forge snapshot --check

      - name: Coverage report
        run: forge coverage --report lcov

Summary

Foundry Forge is the most powerful Solidity testing framework available. Writing tests in Solidity itself eliminates context-switching and gives you direct access to cheatcodes that no JavaScript-based framework can match. The built-in fuzzer and invariant tester find bugs that manual test cases miss. Gas snapshots prevent silent regressions.

The investment in learning Foundry's cheatcode API pays off quickly — you can simulate complex scenarios (flash loans, governance attacks, time-locked operations) in a few lines of Solidity that would require pages of JavaScript and mocking infrastructure in Hardhat.

Read more