Foundry Testing skill for Codex

Fuzz the math, run the invariants, pin the fork. Codex writes forge suites that assert every revert and event, fuzzes arithmetic with bound() instead of assume(), and drives invariant tests through handlers with ghost variables so call sequences are meaningful. Fork tests are pinned to a block, CI gets its own foundry.toml profile with more runs, and Foundry 1.x behaviour changes around expectRevert are handled.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- foundry-testing

Installs to ~/.agents/skills/foundry-testing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $foundry-testing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes Foundry (forge) test suites for Solidity - unit tests with cheatcodes like vm.prank, vm.expectRevert and vm.expectEmit, bounded fuzz tests, handler-based invariant tests with ghost variables, mainnet fork tests pinned to a block, coverage and gas snapshots - and wires them into CI. Use when asked to test a contract, add fuzzing or invariants, reproduce a bug on a fork, raise coverage, debug a failing forge test, or set up foundry.toml profiles.

Use it when

  • A Solidity contract needs unit, fuzz or invariant tests in Foundry
  • A forge test fails and you need to read traces and fix the cause
  • Coverage or gas regressions need to gate CI
  • A bug must be reproduced against live protocol state on a fork

Use something else when

How to check the result

forge test passes under both the default and CI profiles, invariant runs show most handler calls succeeding, and forge coverage reports every branch of value-moving functions.

The mistake it prevents

Invariant tests that target the contract directly instead of a handler, so nearly every call reverts and the invariant passes without testing anything.

~/.agents/skills/foundry-testing/SKILL.md

--- name: foundry-testing description: Writes Foundry (forge) test suites for Solidity - unit tests with cheatcodes like vm.prank, vm.expectRevert and vm.expectEmit, bounded fuzz tests,… license: MIT ---

A Foundry suite is good when each revert and event of every external function is asserted, arithmetic is fuzzed with bounded inputs, the protocol’s accounting rules run as invariants against a handler, fork tests are pinned to a block, and the suite runs the same way locally and in CI.

Inspect first

  1. Read AGENTS.md, foundry.toml, remappings.txt and an existing test for naming and base-contract conventions (many repos have a BaseTest with shared setup).
  2. forge --version. Foundry 1.x removed testFail* tests and makes vm.expectRevert ignore reverts in internal calls by default; old patterns may need updating.
  3. forge build then forge test to know the starting state before adding anything.
  4. lib/forge-std must exist; forge install foundry-rs/forge-std needs network.

Layout and naming

test/
├── unit/Vault.t.sol            # one contract per file under test
├── fuzz/VaultFuzz.t.sol
├── invariant/VaultInvariant.t.sol
├── invariant/handlers/VaultHandler.sol
└── fork/VaultFork.t.sol
  • test_Deposit_UpdatesBalance, test_RevertWhen_AmountIsZero, testFuzz_DepositWithdraw, invariant_TotalEqualsSum. The prefix is what forge uses to classify tests.
  • One behaviour per test. Name says the condition and the expected outcome.

Unit tests

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Test} from "forge-std/Test.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import {Vault} from "src/Vault.sol";

contract VaultTest is Test {
    Vault vault;
    ERC20Mock token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new ERC20Mock();
        vault = new Vault(token, owner);
        token.mint(alice, 1_000e18);
        vm.prank(alice);
        token.approve(address(vault), type(uint256).max);
    }

    function test_Deposit_UpdatesBalanceAndEmits() public {
        vm.expectEmit(address(vault));
        emit Vault.Deposited(alice, 100e18);
        vm.prank(alice);
        vault.deposit(100e18);
        assertEq(vault.balanceOf(alice), 100e18);
        assertEq(token.balanceOf(address(vault)), 100e18);
    }

    function test_RevertWhen_WithdrawExceedsBalance() public {
        vm.prank(alice);
        vm.expectRevert(abi.encodeWithSelector(Vault.InsufficientBalance.selector, 1, 0));
        vault.withdraw(1);
    }

    function test_RevertWhen_NonOwnerPauses() public {
        vm.prank(alice);
        vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", alice));
        vault.setPaused(true);
    }
}

Cheatcode reference for the common cases:

NeedUse
Call as someonevm.prank(a) (next call), vm.startPrank(a) / vm.stopPrank()
Give ETHvm.deal(a, 1 ether); hoax(a, 1 ether) = deal + prank
Give ERC-20deal(address(token), a, amt) (StdCheats; writes the balance slot)
Expect custom errorvm.expectRevert(Err.selector) or abi.encodeWithSelector(Err.selector, args)
Expect eventvm.expectEmit(address(emitter)); emit Ev(...); target.call();
Time and blocksvm.warp(ts), skip(1 days), vm.roll(n)
Mock a dependencyvm.mockCall(addr, abi.encodeWithSelector(sel), abi.encode(ret))
Assert a call happensvm.expectCall(addr, data)
Readable tracesvm.label(addr, "Oracle"), run with -vvvv

vm.expectRevert applies to the very next external call. Put vm.prank before it or after it, but never let another call (such as a getter used as an argument) sit between expectRevert and the target call: argument expressions are evaluated first and a getter on an external contract consumes the expectation.

Fuzz tests

function testFuzz_DepositThenWithdrawReturnsSame(uint256 amount) public {
    amount = bound(amount, 1, token.balanceOf(alice));
    vm.startPrank(alice);
    vault.deposit(amount);
    vault.withdraw(amount);
    vm.stopPrank();
    assertEq(token.balanceOf(alice), 1_000e18);
}
  • Prefer bound() over vm.assume(). Heavy assume filters cause “too many rejects” failures and waste runs.
  • Fuzz parameters of type address can be precompiles, the test contract or zero. Exclude with vm.assume(a != address(0) && a.code.length == 0) or use a fixed actor set.
  • When a fuzz test fails, forge prints the counterexample and stores it under cache/fuzz; reproduce with --fuzz-seed or turn it into a named regression unit test.

Invariant tests

Invariants assert properties that must hold after any sequence of calls. Always route calls through a handler so the fuzzer produces meaningful sequences instead of reverting.

contract VaultHandler is Test {
    Vault vault; ERC20Mock token;
    address[] public actors;
    uint256 public ghostDeposited;
    uint256 public ghostWithdrawn;

    constructor(Vault v, ERC20Mock t) {
        vault = v; token = t;
        for (uint256 i; i < 3; i++) actors.push(makeAddr(string(abi.encode(i))));
    }

    function deposit(uint256 actorSeed, uint256 amount) external {
        address a = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 1, 1e30);
        token.mint(a, amount);
        vm.startPrank(a);
        token.approve(address(vault), amount);
        vault.deposit(amount);
        vm.stopPrank();
        ghostDeposited += amount;
    }

    function withdraw(uint256 actorSeed, uint256 amount) external {
        address a = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 0, vault.balanceOf(a));
        if (amount == 0) return;
        vm.prank(a);
        vault.withdraw(amount);
        ghostWithdrawn += amount;
    }
}

contract VaultInvariantTest is Test {
    Vault vault; ERC20Mock token; VaultHandler handler;

    function setUp() public {
        token = new ERC20Mock();
        vault = new Vault(token, address(this));
        handler = new VaultHandler(vault, token);
        targetContract(address(handler));
    }

    function invariant_SolvencyHolds() public view {
        assertEq(token.balanceOf(address(vault)), vault.totalDeposits());
        assertEq(vault.totalDeposits(), handler.ghostDeposited() - handler.ghostWithdrawn());
    }
}
  • targetContract(handler) restricts the fuzzer to the handler; use targetSelector to pick specific functions and excludeSender for addresses that must not call.
  • With fail_on_revert = false (default), reverting calls are discarded silently. Turn it on temporarily to find handler bugs that make most calls revert; a handler where most calls revert proves nothing.
  • Check how many calls actually succeeded: set show_metrics = true under [invariant] to print per-selector call and revert counts, or count successes in a ghost variable.

foundry.toml profiles

[profile.default]
src = "src"
test = "test"
libs = ["lib"]
solc_version = "0.8.28"
optimizer = true
optimizer_runs = 200

[profile.default.fuzz]
runs = 256

[profile.default.invariant]
runs = 64
depth = 64
fail_on_revert = false

[profile.ci.fuzz]
runs = 10000

[profile.ci.invariant]
runs = 256
depth = 128

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"

Run the CI profile with FOUNDRY_PROFILE=ci forge test. Override per test with an inline comment: /// forge-config: default.fuzz.runs = 2000.

Fork tests

function setUp() public {
    vm.createSelectFork(vm.rpcUrl("mainnet"), 21_500_000); // pinned for reproducibility
}
  • Pin the block. An unpinned fork makes results change daily and hammers the RPC.
  • Fork tests need network and an RPC URL; in a Codex sandbox without network, run them only after approval or skip with --no-match-path "test/fork/*".
  • Forge caches fork state under ~/.foundry/cache/rpc; a pinned block reruns quickly.

Commands

forge test                                   # all tests
forge test --mt test_RevertWhen -vvv         # match test name
forge test --mc VaultInvariantTest           # match contract
forge test --mp "test/fork/*" -vvvv          # match path, full traces
forge coverage --report summary
forge coverage --report lcov                 # for CI upload
forge snapshot && forge snapshot --check     # gas regression gate
forge test --gas-report

Gotchas

  • ffi = true lets tests run shell commands; leave it off unless differential testing needs it, and never enable it for untrusted test code.
  • deal on tokens with unusual storage (rebasing, proxies with packed slots) may fail; fall back to transferring from a known holder with vm.prank(whale) on a fork.
  • setUp failures report as a single failing test named setUp(); read that trace first.
  • Coverage instruments without the optimizer; “stack too deep” there needs --ir-minimum.
  • Do not assert on exact gas in unit tests; use snapshots with a tolerance instead.

Done when

  • Every external function has success, revert and event tests
  • Arithmetic paths have bounded fuzz tests
  • Accounting invariants run through a handler with a low revert rate
  • Fork tests are pinned and isolated by path
  • forge test and FOUNDRY_PROFILE=ci forge test both pass; coverage reported

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Foundry Testing"
  short_description: "Fuzz the math, run the invariants, pin the fork"
  default_prompt: "$foundry-testing Write a Foundry test suite for src/Vault.sol covering every revert"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem