Foundry Testing & Script Skill
Rules and patterns for Foundry tests. Find examples in the actual codebase.
Bundled References
Reference Content When to Read
./references/test-infrastructure.md
Constants, defaults, mocks When setting up tests
./references/cheat-codes.md
Common cheatcode patterns When using vm cheatcodes
./references/invariant-patterns.md
Handlers, stores, invariants When writing invariant tests
./references/formal-verification.md
Halmos, Certora, symbolic exec When proving correctness
./references/deployment-scripts.md
Script patterns, verification When writing deploy scripts
./references/deployment-checklist.md
Pre-mainnet deployment steps Before deploying to production
./references/gas-benchmarking.md
Snapshot, profiling, CI When measuring gas performance
./references/sablier-conventions.md
Sablier-specific patterns When working in Sablier repos
Test Types
Type Directory Naming Purpose
Integration tests/integration/concrete/
*.t.sol
BTT-based concrete tests
Fuzz tests/integration/fuzz/
*.t.sol
Property-based testing
Fork tests/fork/
*.t.sol
Mainnet state testing
Invariant tests/invariant/
Invariant*.t.sol
Stateful protocol properties
Scripts scripts/solidity/
*.s.sol
Deployment/initialization
- Integration Tests (Concrete)
Naming Convention
Pattern Usage
test_RevertWhen_{Condition}
Revert on input
test_RevertGiven_{State}
Revert on state
test_When_{Condition}
Success path
Rules
-
Stack modifiers to document BTT path (modifiers are often empty - just document the path)
-
Expect events BEFORE action - vm.expectEmit() then call function
-
Assert state AFTER action - Check state changes after function executes
-
Use revert helpers for common patterns (expectRevert_DelegateCall , expectRevert_Null )
-
Named parameters in assertions - assertEq(actual, expected, "description")
Mock Rules
-
Place all mocks in tests/mocks/
-
One mock per scenario (not one mega-mock)
-
Naming: *Good , *Reverting , *InvalidSelector , *Reentrant
- Fuzz Tests
Naming Convention
testFuzz_{FunctionName}_{Scenario}
Rules
-
Bound before assume - _bound() is more efficient than vm.assume()
-
Bound in dependency order - Independent params first, then dependent
-
Never hardcode params with validation constraints
-
Document fuzzed scenarios in NatSpec
Bounding Pattern
// 1. Bound independent params first cliffDuration = boundUint40(cliffDuration, 0, MAX - 1);
// 2. Bound dependent params based on constraints totalDuration = boundUint40(totalDuration, cliffDuration + 1, MAX);
- Fork Tests
Rules
-
Create fork with vm.createSelectFork("ethereum")
-
Use deal() to give tokens to test users
-
Use assumeNoBlacklisted() for USDC/USDT
-
Use forceApprove() for non-standard tokens (USDT)
Token Quirks
Token Issue Solution
USDC/USDT Blacklist assumeNoBlacklisted()
USDT Non-standard forceApprove()
Fee-on-transfer Balance diff Check actual received amount
- Invariant Tests
Architecture
tests/invariant/ ├── handlers/ # State manipulation (call functions with bounded params) ├── stores/ # State tracking (record totals, IDs) └── Invariant.t.sol
Rules
-
Target handlers only - targetContract(address(handler))
-
Exclude protocol contracts - excludeSender(address(vault))
-
Use stores to track totals for invariant assertions
-
Early return in handlers if preconditions not met
- Solidity Scripts
Rules
-
Inherit from BaseScript with broadcast modifier
-
Use env vars: ETH_FROM , MNEMONIC
-
Simulation first, then broadcast
Commands
Simulation
forge script scripts/Deploy.s.sol --sig "run(...)" ARGS --rpc-url $RPC
Broadcast
forge script scripts/Deploy.s.sol --sig "run(...)" ARGS --rpc-url $RPC --broadcast --verify
Running Tests
By type
forge test --match-path "tests/integration/concrete/" forge test --match-path "tests/fork/" forge test --match-contract Invariant_Test
Specific test
forge test --match-test test_WhenCallerRecipient -vvvv
Fuzz with more runs
forge test --match-test testFuzz_ --fuzz-runs 1000
Coverage
forge coverage --report lcov
Debugging
Verbosity Levels
Flag Shows
-v
Logs for failing tests
-vv
Logs for all tests
-vvv
Stack traces for failures
-vvvv
Stack traces + setup traces
-vvvvv
Full execution traces
Console Logging
import { console2 } from "forge-std/console2.sol";
console2.log("value:", someValue); console2.log("address:", someAddress); console2.logBytes32(someBytes32);
Debugging Commands
Trace specific failing test
forge test --match-test test_MyTest -vvvv
Gas report for a test
forge test --match-test test_MyTest --gas-report
Debug in interactive debugger
forge debug --debug tests/MyTest.t.sol --sig "test_MyTest()"
Inspect storage layout
forge inspect MyContract storage-layout
Debugging Tips
-
Label addresses - vm.label(addr, "Recipient") for readable traces
-
Check state with logs - Add console2.log before reverts
-
Isolate failures - Run single test with --match-test
-
Compare gas - Use --gas-report to spot unexpected costs
-
Snapshot comparisons - Use vm.snapshot() / vm.revertTo() to isolate state changes
Best Practices Summary
-
Use constants from Defaults /Constants
-
never hardcode
-
Specialized mocks - one per scenario, all in tests/mocks/
-
Modifiers in Modifiers.sol
-
centralize BTT path modifiers
-
Label addresses with vm.label() for traces
-
Events before actions - vm.expectEmit() then call
-
Bound before assume - more efficient
External References
- Foundry Book
Example Invocations
Test this skill with these prompts:
-
Integration test: "Write a concrete test for withdraw that expects Errors.Flow_Overdraw when amount exceeds balance"
-
Fuzz test: "Create a fuzz test for deposit that bounds amount between 1 and type(uint128).max"
-
Fork test: "Write a fork test for USDC deposits on mainnet with blacklist handling"
-
Invariant test: "Create an invariant handler for the deposit and withdraw functions"
-
Deploy script: "Write a deployment script for SablierFlow with verification"