Intermediate • Solidity Foundry testing crash course

Test smart contracts
you can actually trust.

In this guide you’ll learn Foundry testing step-by-step — from unit tests to fuzzing and invariants. You’ll build a real tested contract and walk away with a workflow used in production teams and audits.

Level · Intermediate
Stack · Foundry, Solidity
Time · ~90 minutes
What you’ll build TESTED VAULT
Contract MiniVault.sol
Testing types Unit • Fuzz • Invariants
Tooling forge • cast • anvil
test/MiniVault.t.sol Foundry · forge-std
function test_DepositIncreasesBalance() public { vault.deposit{value: 1 ether}(); assertEq(vault.balanceOf(address(this)), 1 ether); }

What you’ll learn

This is a practical crash course. No random theory dumps — everything is anchored to real code you’ll run.

  • Setting up a clean Foundry project
  • Writing unit tests with forge-std/Test.sol
  • Using cheatcodes: prank, deal, warp, expectRevert
  • Fuzz testing with randomized inputs
  • Invariant testing to catch “impossible states”
  • Debugging failing tests like an adult
Who is this for?

If you already shipped at least one Solidity project and want faster & more powerful tests than Hardhat gives — Foundry is your next step.

Prerequisites
You should be comfortable with:
  • Writing basic Solidity contracts
  • Using CLI tools / terminal
  • Understanding msg.sender / msg.value / reverts
Nice-to-have:
  • Some unit testing experience (any framework)

Install Foundry and initialize a project

1. Install Foundry (macOS / Linux / WSL):

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

2. Create a new project:

mkdir foundry-testing-crash-course
cd foundry-testing-crash-course
forge init

You now have a skeleton project with: src/, test/, and lib/.

Update Foundry often

Foundry evolves fast. If something feels broken, run: foundryup.

Your tools
  • forge — compile & test
  • cast — interact with chains
  • anvil — local node
  • chisel — Solidity REPL

In this guide we’ll mainly use forge test.

Build a tiny contract worth testing

Delete the sample contract in src/Counter.sol (if any), and create src/MiniVault.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice A minimal ETH vault with deposits & withdrawals.
contract MiniVault {
    mapping(address => uint256) public balanceOf;

    error InsufficientBalance();

    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external {
        if (balanceOf[msg.sender] < amount) revert InsufficientBalance();
        balanceOf[msg.sender] -= amount;

        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "TRANSFER_FAILED");

        emit Withdraw(msg.sender, amount);
    }
}

This contract is simple, but it already has meaningful invariant rules:

  • User balances must never go negative
  • Vault shouldn’t lose ETH unless someone withdraws
  • Reverts should trigger correctly on bad withdrawals

Compile once:

forge build

Write unit tests

Foundry tests are Solidity contracts stored in test/. They inherit from forge-std/Test.sol.

Create test/MiniVault.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract MiniVaultTest is Test {
    MiniVault vault;
    address alice = address(0xA11CE);
    address bob   = address(0xB0B);

    function setUp() public {
        vault = new MiniVault();

        // Give accounts ETH for testing
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_DepositIncreasesBalance() public {
        vm.prank(alice);
        vault.deposit{value: 2 ether}();

        assertEq(vault.balanceOf(alice), 2 ether);
    }

    function test_WithdrawDecreasesBalance() public {
        vm.startPrank(alice);
        vault.deposit{value: 3 ether}();
        vault.withdraw(1 ether);
        vm.stopPrank();

        assertEq(vault.balanceOf(alice), 2 ether);
    }

    function test_RevertOnTooLargeWithdraw() public {
        vm.prank(alice);
        vault.deposit{value: 1 ether}();

        vm.prank(alice);
        vm.expectRevert(MiniVault.InsufficientBalance.selector);
        vault.withdraw(2 ether);
    }
}

Run tests:

forge test -vv
Flags you’ll use daily

-v = verbose, -vv = logs + traces, -vvv = deepest traces.

How do cheatcodes work?

Cheatcodes are special “testing syscalls” exposed through the vm object. They only exist in tests, never on-chain. That’s why Foundry tests are so powerful.

What is prank / startPrank?

vm.prank(x) makes the next call execute as x. vm.startPrank(x) keeps msg.sender = x until stopPrank().

Fuzz testing: find edge cases automatically

Fuzzing means Foundry generates random inputs for your test function. If any input breaks the test, Foundry prints the minimal failing case.

Add this to MiniVaultTest:

function testFuzz_DepositAndWithdraw(uint96 amount) public {
    // bound converts random amount into safe range
    uint256 a = bound(uint256(amount), 1 wei, 5 ether);

    vm.prank(alice);
    vault.deposit{value: a}();

    vm.prank(alice);
    vault.withdraw(a);

    assertEq(vault.balanceOf(alice), 0);
}

Run fuzz tests:

forge test --match-test testFuzz -vv
Why uint96?

Smaller types fuzz faster and avoid weird overflow paths. For ETH-based fuzzing, uint96 is a sweet spot.

What does bound() do?

Foundry gives you a fully random value. bound(x, min, max) clamps it into a range so the fuzzing stays meaningful.

Invariants: test the rules, not a single path

Invariant tests repeatedly call random actions on a contract and verify some property always holds.

Create a handler contract in test/handlers/MiniVaultHandler.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract MiniVaultHandler is Test {
    MiniVault public vault;

    address[] public users;

    constructor(MiniVault _vault) {
        vault = _vault;
        users.push(address(0xA11CE));
        users.push(address(0xB0B));

        vm.deal(users[0], 100 ether);
        vm.deal(users[1], 100 ether);
    }

    function deposit(uint256 amount, uint256 userIndex) public {
        address user = users[userIndex % users.length];
        uint256 a = bound(amount, 1 wei, 5 ether);

        vm.prank(user);
        vault.deposit{value: a}();
    }

    function withdraw(uint256 amount, uint256 userIndex) public {
        address user = users[userIndex % users.length];
        uint256 bal = vault.balanceOf(user);

        uint256 a = bound(amount, 0, bal);

        vm.prank(user);
        vault.withdraw(a);
    }
}

Now create an invariant test file test/MiniVault.invariant.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/MiniVault.sol";
import "./handlers/MiniVaultHandler.sol";

contract MiniVaultInvariantTest is Test {
    MiniVault vault;
    MiniVaultHandler handler;

    function setUp() public {
        vault = new MiniVault();
        handler = new MiniVaultHandler(vault);

        targetContract(address(handler));
    }

    /// Invariant: vault ETH == sum of user balances tracked in contract
    function invariant_VaultSolvency() public {
        uint256 sum;
        sum += vault.balanceOf(address(0xA11CE));
        sum += vault.balanceOf(address(0xB0B));

        assertEq(address(vault).balance, sum);
    }
}

Run invariants:

forge test --match-test invariant -vv
Invariants are brutal (in a good way)

If your state accounting has even a tiny bug, invariants usually catch it. That’s why auditors love them.

Debugging failing tests fast

Foundry gives you great tools to understand why something failed. Here are the important ones:

  • forge test -vvv — full call traces
  • console2.log() — logs from Solidity tests
  • --match-test — run only specific tests
  • --gas-report — see gas costs per function

Try adding a log:

import "forge-std/console2.sol";

console2.log("alice balance", vault.balanceOf(alice));
Foundry tests don’t see my new file

Run forge clean then forge test. If it’s still weird, check import paths and file naming (.t.sol).

Where to go from here

Nice! You’ve just:

  • Installed Foundry and initialized a project
  • Wrote unit tests with cheatcodes
  • Added fuzz tests to auto-discover edge cases
  • Built invariant tests for solvency rules
  • Learned how to debug failures quickly

This is already enough to test most real-world contracts. Next, level up by testing DeFi patterns and security properties.

  • Test ERC-20/721 integrations
  • Fuzz + invariants for AMMs / vaults / staking
  • Property-based security testing
Suggested next guides
  • Foundry deployment · scripts, broadcasting, verify
  • Security invariants · reentrancy, access control, solvency
  • Hardhat vs Foundry · when to use which stack

Link these to your docs like foundry.html, security.html, or a comparison page.

0% · guide progress