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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- foundry-testing Installs to ~/.agents/skills/foundry-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install foundry-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project foundry-testing Installs to .agents/skills/foundry-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/foundry-testing.zipunzip -o foundry-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) foundry-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The project uses Hardhat with TypeScript tests: use hardhat-projects
- You want a security review rather than test authoring: use smart-contract-audit
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.
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
- Read
AGENTS.md,foundry.toml,remappings.txtand an existing test for naming and base-contract conventions (many repos have aBaseTestwith shared setup). forge --version. Foundry 1.x removedtestFail*tests and makesvm.expectRevertignore reverts in internal calls by default; old patterns may need updating.forge buildthenforge testto know the starting state before adding anything.lib/forge-stdmust exist;forge install foundry-rs/forge-stdneeds 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:
| Need | Use |
|---|---|
| Call as someone | vm.prank(a) (next call), vm.startPrank(a) / vm.stopPrank() |
| Give ETH | vm.deal(a, 1 ether); hoax(a, 1 ether) = deal + prank |
| Give ERC-20 | deal(address(token), a, amt) (StdCheats; writes the balance slot) |
| Expect custom error | vm.expectRevert(Err.selector) or abi.encodeWithSelector(Err.selector, args) |
| Expect event | vm.expectEmit(address(emitter)); emit Ev(...); target.call(); |
| Time and blocks | vm.warp(ts), skip(1 days), vm.roll(n) |
| Mock a dependency | vm.mockCall(addr, abi.encodeWithSelector(sel), abi.encode(ret)) |
| Assert a call happens | vm.expectCall(addr, data) |
| Readable traces | vm.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()overvm.assume(). Heavyassumefilters cause “too many rejects” failures and waste runs. - Fuzz parameters of type
addresscan be precompiles, the test contract or zero. Exclude withvm.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-seedor 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; usetargetSelectorto pick specific functions andexcludeSenderfor 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 = trueunder[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 = truelets tests run shell commands; leave it off unless differential testing needs it, and never enable it for untrusted test code.dealon tokens with unusual storage (rebasing, proxies with packed slots) may fail; fall back to transferring from a known holder withvm.prank(whale)on a fork.setUpfailures report as a single failing test namedsetUp(); 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 testandFOUNDRY_PROFILE=ci forge testboth pass; coverage reported
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
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.