Write Foundry tests and scripts that pass, pin down the specified behavior, and follow the project's conventions.
Ground Rules
- Read
foundry.toml(testandscriptdirectories, fuzz runs, profiles,block_timestamp),remappings.txt, the nearestAGENTS.md, and one or two neighboring tests before writing. Existing layout, base contracts, and helpers win over the defaults below. - Import Forge Std through the project's remapping; in this template that is
forge-std/src/Test.sol. - Keep tests deterministic: derive times from
block.timestamp, which this template pins infoundry.toml, and pin fork block numbers.
References
- references/btt.md: read before writing or changing a
.treespec or a BTT-generated test. - references/cheat-codes.md: read when using
vmcheatcodes or Forge Std helpers beyondprank,warp, andexpectRevert. - references/test-infrastructure.md: read when a suite outgrows one file and needs a shared base test, users, constants, defaults, modifiers, mocks, or revert helpers.
- references/invariant-patterns.md: read when writing invariant tests, handlers, or stores.
- references/gas-benchmarking.md: read when measuring, snapshotting, or comparing gas.
Test Types
| Type | Function prefix | Location in larger suites |
|---|---|---|
| Concrete (unit) | test_ |
tests/integration/concrete/ |
| Fuzz | testFuzz_ |
tests/integration/fuzz/ |
| Fork | testFork_ (or testForkFuzz_ if fuzzed) |
tests/fork/ |
| Invariant | invariant_ |
tests/invariant/ |
| Deployment scripts | run |
script/*.s.sol |
Small projects keep one flat file per contract, such as tests/Foo.t.sol. Add the directory split when the suite grows.
Writing Tests
- Test one behavior per function. Name reverts
test_RevertWhen_{Condition}(input) ortest_RevertGiven_{State}(state), and successestest_When{Condition}ortest_Given{State}. - Arrange shared state in
setUpor modifiers; never rely on test execution order. - Set expectations before the call:
vm.expectRevert,vm.expectEmit, andvm.expectCallprecede the action. - Expect every event, with all parameters, in at least one test.
- Expect custom errors with their arguments:
vm.expectRevert(abi.encodeWithSelector(Errors.TokenVault_Overdraw.selector, id, amount, available)). - Assert state after the action, and give every assertion a description:
assertEq(actual, expected, "balance"). - Create users with
makeAddr("alice")and label deployed contracts withvm.labelfor readable traces. - Use named constants instead of hardcoded values, especially for parameters with validation constraints.
BTT Specs
Specify concrete tests as bulloak .tree files when the project uses the
Branching Tree Technique or the user asks for it. Install bulloak with cargo install bulloak if it is missing.
- Write
{functionName}.treein a kebab-case directory, e.g.tests/integration/concrete/create-position/. - Scaffold a new test:
bulloak scaffold -w -F -s '<pragma>' <file.tree>. Add-m(--skip-modifiers) when modifiers live in a shared contract.-wnever overwrites an existing.t.sol; add-fonly to deliberately replace one, which discards implemented bodies. - Add imports, inheritance, and setup, then implement each test body.
- Verify alignment with
bulloak check <file.tree>, passing the same modifier and description flags used to scaffold:-mand--format-descriptions(checkhas no-Fshort form).bulloak check --fixwith those flags inserts missing tests; runforge fmton the file and review the diff.
Tree syntax, naming rules, and examples are in references/btt.md.
Fuzz Tests
- Constrain inputs with
bound(x, min, max)rather thanvm.assume; each rejection wastes a run, and too many fail the test. - Bound independent parameters first, then dependent ones from the already-bound values.
- Never hardcode a parameter that has validation constraints; bound it into the valid range.
- Exclude special addresses with Forge Std helpers such as
assumeNotZeroAddress,assumeNotPrecompile, andassumeNotForgeAddress. - Document the fuzzed scenario in the test's NatSpec.
cliffDuration = bound(cliffDuration, 0, MAX_DURATION - 1);
totalDuration = bound(totalDuration, cliffDuration + 1, MAX_DURATION);
Fork Tests
- Fork through an RPC alias from
foundry.tomlat a pinned block:vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 21_000_000 }). Pinned blocks are also cached locally. - Follow the project's policy for missing RPC keys; this template's fork tests return early when
API_KEY_ALCHEMYis unset. - Fund users with
deal(address(token), user, amount), and passadjust = truewhen the test depends ontotalSupply. - Handle token quirks:
assumeNotBlacklisted(token, user)for USDC and USDT,forceApprovefor USDT, and balance deltas for fee-on-transfer tokens.
Invariant Tests
Target handler contracts only, bound every input inside handlers, track ghost state in stores, and exclude protocol contracts as senders. Read references/invariant-patterns.md before writing one.
Scripts
- Inherit the project's base script (this template's
BaseScriptinscript/Base.s.sol), apply itsbroadcastmodifier torun, and return the deployed contracts. - Take the broadcaster from
$ETH_FROMor$MNEMONIC; never hardcode or log private keys. - Deploy deterministically with
new Foo{ salt: SALT }(...)through Foundry's CREATE2 deployer. The address depends on the salt, the init code (including constructor arguments), and compiler settings such asbytecode_hash. - Read chain-dependent inputs from environment variables or per-chain config; never hardcode them.
- Simulate without
--broadcastfirst, then broadcast with--rpc-url <alias> --broadcast, adding--verifywhen explorer keys are configured. - Test a script by instantiating it in a test and asserting on the contracts
runreturns.
Running and Debugging
forge test --match-path 'tests/integration/concrete/**' # By path
forge test --match-contract Invariant_Test # By contract
forge test --match-test test_WhenCallerOwner -vvvv # One test with traces
forge test --rerun # Only the failures from the last run
forge test --match-test testFuzz_ --fuzz-runs 10000 # More fuzz runs
FOUNDRY_PROFILE=ci forge test # CI profile (this template: 10,000 fuzz runs)
forge test --debug --match-test test_Foo # Interactive debugger for one test
forge coverage --report lcov # Coverage; add --ir-minimum on Stack Too Deep
forge inspect <Contract> storageLayout # Storage layout
| Verbosity | Shows |
|---|---|
-vv |
Logs for all tests |
-vvv |
Traces for failing tests |
-vvvv |
Traces for all tests, setup traces for failures |
-vvvvv |
Traces and setup traces for all tests, with storage |
Debugging tips: log with console2 (forge-std/src/console2.sol), isolate with --match-test, compare state with
vm.snapshotState() and vm.revertToState(id), and check unexpected costs with --gas-report.
Completion
Finish only when:
- The new or changed tests pass with a narrow
forge test --match-path <path>or--match-test <name>run, repeated under the CI profile when fuzz or invariant tests changed. bulloak checkpasses for every changed.treefile.- The repository's lint gate passes for touched files; in this template that is
forge fmt --check <files>andbun solhint <files>.
Report the exact commands and results.