Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Foundry for beginners - compiling, tests, fuzzing and a local network

A practical introduction to Foundry: installation, project structure, unit tests, vm cheatcodes, fuzzing and invariants, forge, cast and anvil, with an example.

6 min readUpdated:
In this article

Foundry is a toolkit for developing smart contracts in which tests are written in Solidity itself. It is fast and convenient, so it has become the standard in many teams. This article is a practical introduction: from installation to a fuzz test. The theory of testing is in the article testing smart contracts, the language in the Solidity tour.

What Foundry includes

ToolPurpose
forgebuild, tests, deployment, gas reports
castcommands for talking to a network from the terminal
anvila local blockchain for development
chiselan interactive Solidity console

Installation and a project

bash
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init escrow-demo
cd escrow-demo

The project structure:

text
src/        contract source code
test/       tests (files *.t.sol)
script/     deployment scripts
lib/        dependencies (forge-std and others)
foundry.toml  settings: compiler version, optimizer

An example of settings close to what our contract uses:

toml
[profile.default]
solc = "0.8.24"
evm_version = "paris"
optimizer = true
optimizer_runs = 200
bytecode_hash = "none"

These parameters make the build reproducible - important for verifying a contract.

A contract for the example

Let us take a simplified version of the escrow's share calculation (the math explained):

solidity
// src/Splitter.sol
library Splitter {
    uint256 internal constant BPS = 10_000;
    uint256 internal constant PLATFORM_FEE_BPS = 100; // 1%

    function split(uint256 amount, uint256 arbiterFeeBps, uint256 buyerShareBps)
        internal
        pure
        returns (uint256 buyer, uint256 seller, uint256 arbiter, uint256 platform)
    {
        platform = (amount * PLATFORM_FEE_BPS) / BPS;
        uint256 net = amount - platform;
        arbiter = (net * arbiterFeeBps) / BPS;
        uint256 pot = net - arbiter;
        buyer = (pot * buyerShareBps) / BPS;
        seller = pot - buyer;
    }
}

The first test

solidity
// test/Splitter.t.sol
import {Test} from "forge-std/Test.sol";
import {Splitter} from "../src/Splitter.sol";

contract SplitterTest is Test {
    function test_completedDeal() public {
        // 1000 USDC (6 decimals), guarantor 2%, everything to the seller
        (uint256 b, uint256 s, uint256 a, uint256 p) = Splitter.split(1_000_000_000, 200, 0);
        assertEq(p, 10_000_000);     // 10 USDC to the platform
        assertEq(a, 19_800_000);     // 19.80 to the guarantor
        assertEq(b, 0);
        assertEq(s, 970_200_000);    // 970.20 to the seller
    }
}

Running:

bash
forge test -vvv

The -v flags show details: the more v, the more detail (logs, traces).

Fuzzing: thousands of random inputs

Instead of checking a few numbers, let us state a property: the sum of the parts always equals the whole.

solidity
function testFuzz_partsEqualWhole(uint96 amount, uint16 arbBps, uint16 buyerBps) public {
    arbBps = uint16(bound(arbBps, 0, 3000));       // limit to sensible values
    buyerBps = uint16(bound(buyerBps, 0, 10_000));
    (uint256 b, uint256 s, uint256 a, uint256 p) = Splitter.split(amount, arbBps, buyerBps);
    assertEq(b + s + a + p, amount);
}

Foundry will substitute hundreds of random values (256 by default) and on failure show a minimal counterexample. bound neatly brings a value into the needed range, and vm.assume(cond) discards unsuitable ones.

Such tests catch what you did not think of: an amount of 0, the uint96 maximum, boundary shares.

The vm cheatcodes

A test contract inherits Test and so gets a vm object with "cheats" that control the environment.

CheatWhat it does
vm.prank(addr)the next call runs as addr
vm.startPrank(addr) / vm.stopPrank()the same for a series of calls
vm.expectRevert(selector)the next call must revert with this error
vm.warp(time)sets block.timestamp
vm.roll(block)sets the block number
deal(addr, amount)gives ETH to an address (a token variant exists)
vm.expectEmit(...)checks an event
makeAddr("alice")creates a test address

Example checks for the escrow

solidity
function test_strangerCannotConfirm() public {
    uint256 id = _createActiveDeal();
    vm.prank(makeAddr("stranger"));
    vm.expectRevert(Escrow.NotAuthorized.selector);
    escrow.confirmDelivery(id);
}

function test_arbiterTimeoutSplitsEvenly() public {
    uint256 id = _createDisputedDeal();
    vm.warp(block.timestamp + 30 days + 1);
    vm.prank(buyer);
    escrow.claimArbiterTimeout(id);
    // check: guarantor 0, buyer and seller equal
}

This is how the rules from access control and the deal lifecycle are verified: who may act and when, and what happens on timeout.

Invariants

An invariant test checks a property that must hold after any sequence of calls. Foundry randomly calls the contract's functions and after every step checks the condition:

solidity
function invariant_solvency() public view {
    assertGe(token.balanceOf(address(escrow)), escrow.reserved(address(token)));
}

This is exactly the solvency invariant: the contract's balance is no less than its obligations (explanation).

Gas and coverage

bash
forge test --gas-report     # a table of gas use per function
forge snapshot              # saves reference gas values
forge coverage              # which lines are covered by tests

The results help to optimise gas and to see untested branches of code.

A local network and cast

bash
anvil                         # a blockchain on localhost:8545, chain id 31337
cast sig "transfer(address,uint256)"   # 0xa9059cbb - the selector
cast keccak "hello"                    # the keccak256 hash
cast call <address> "balanceOf(address)(uint256)" <wallet> --rpc-url http://127.0.0.1:8545

anvil creates test accounts with ETH, on which it is convenient to rehearse the whole deal flow locally without spending real money. Selectors and hashes are the subject of the articles on ABI and hash functions.

Forking a real network

Tests can run on a copy of a real network (state is only read, nothing is sent):

bash
forge test --fork-url $RPC_URL

This is how interaction with real tokens like USDC is checked - for example how they behave on address blocking.

Tips

  1. Test not only the "happy path" but also failures and boundary values.
  2. Write a fuzz test for every arithmetic function.
  3. One test, one idea, clear names: test_..._reverts_when_....
  4. Run tests in CI on every change.
  5. Tests do not replace an audit but complement it.

Learn how independent experts examine contracts: audits and bug bounties. Or how to cut gas use: gas optimization.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.