Foundry Forge: Modern Solidity Testing in Pure Solidity

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
foundryup

This 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-project

The project structure:

my-project/
├── src/           # Contracts
├── test/          # Tests (.t.sol files)
├── script/        # Deployment scripts
├── lib/           # Dependencies (git submodules)
└── foundry.toml   # Configuration

Install OpenZeppelin:

forge install OpenZeppelin/openzeppelin-contracts

Add 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 = 50

Writing 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 tests
  • testFuzz_* — fuzz tests (Foundry auto-detects parameters)
  • testFail_* — expect a revert (legacy, prefer vm.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 <= b

For 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 = 65536

Invariant 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 snapshot

The .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 table

CI 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 summary

The 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 traces

These 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.

Read more

Start now free