blockchainsv
Developer Tools & Infrastructure·July 08, 2026·13 min read

Foundry Solidity testing: how to write your first test

The feedback loop is where smart contract engineering lives or dies, and for years that loop was painfully slow.

Foundry Solidity testing: how to write your first test

Foundry Solidity Testing: a Guide to Faster EVM Workflows

Writing tests in the same language as the contracts under test is not a stylistic preference — it eliminates an entire class of impedance mismatches between production code and test code.

This is a guide for engineers who already write Solidity, who have felt the friction of JavaScript-mediated test runners, and who want a concrete, opinionated path into Foundry. We will bootstrap a workspace, write a real test suite against a real contract, manipulate EVM state with cheatcodes, lean on the built-in fuzzer, and finish with the CLI mechanics that turn a green test run into a debuggable artifact. The goal is not to declare Foundry the universal winner — that kind of tribalism does nobody a favor — but to give you the working vocabulary and trade-off matrix to make an informed adoption decision for your own stack.

Bootstrapping the workspace with forge init

Every Foundry project starts the same way: forge init. The command scaffolds a directory tree that maps cleanly onto the three responsibilities a smart contract project actually has — writing contracts, writing tests, and writing deployment scripts. You get src/ for production code, test/ for the Solidity test files, script/ for forge-driven deployment and invariant work, and a foundry.toml at the root for build configuration. The configuration file is where most of the runtime tuning lives: Solidity version pinning, optimizer settings, remappings for libraries, and the all-important [rpc_endpoints] block that defines which networks can be forked and at which block.

There is a subtle architectural decision baked into this layout. By giving tests and scripts their own top-level directories rather than burying them inside a contracts tree, the toolchain signals that test code is first-class production code, not an afterthought. Conversely, the lib/ directory and remappings.txt file keep third-party dependencies (OpenZeppelin, Solmate, forge-std) addressable with short, stable imports that survive version bumps. Engineers coming from Truffle will recognize the conceptual separation but will immediately notice that everything is declarative and text-based rather than hidden inside a truffle-config.js closure factory — a deliberate trade-off that favors reproducibility over ergonomics.

A reasonable workflow is to fork a live network early in the project lifecycle, even before you need it, so that the foundry.toml carries a known-good RPC endpoint and block height from day one. The mechanics are simple: add [rpc_endpoints] with a key like mainnet = "https://...", then pass --fork-url mainnet to any test invocation. This pre-commitments the project to integration discipline, and in practice, the engineers who do this from the start ship fewer mainnet-only bugs.

Composing your first test suite in Solidity

Forge discovers tests through naming convention, not registration. Any public or external function in a file under test/ whose name starts with test is executed when you run forge test; there is no manifest, no describe block, no test runner configuration to maintain. The standard convention is test_<Behavior>_<Condition>_<ExpectedResult>, which reads almost like a specification line: test_transfer_revertsWhenInsufficientBalance, test_staking_creditsPendingRewardsOnDeposit. The convention is not enforced, but adopting it consistently turns a failing test name into a self-documenting bug report.

The test file itself imports forge-std/Test.sol, which provides the base contract Test and a library of assertion helpers. You write your tests as methods on a contract that inherits from Test. The setUp() function is the per-test fixture — it runs before each test case, which is the correct default for stateless unit testing but is worth understanding because some teams want shared state across tests and need to be explicit about that. For shared state, you put it in storage on the test contract itself; for isolated state, you deploy fresh contracts inside setUp(). In practice, the latter is almost always what you want, because test isolation is the single most valuable property of a fast-feedback loop.

Assertions come from forge-std as well. assertEq(actual, expected) for value comparisons, assertGt(a, b) for strict inequality, assertTrue(condition) for boolean predicates, and a long tail of specialized variants. What matters more than the exact API surface is the discipline of one assertion per logical concept: a single test that asserts five things tells you almost nothing when it fails, because the failure point is buried in the stack trace and you end up bisecting manually. Conversely, a test that asserts one thing and reads like a sentence tells you, on red, exactly which invariant broke.

A pragmatic pattern that scales well is the behavior-driven folder structure: test/unit/ for pure logic tests against libraries and stateless contracts, test/integration/ for tests that exercise contract-to-contract interaction, and test/fork/ for tests that run against forked mainnet state. Forge supports all three under the same forge test command, and you can target a single directory with forge test --match-path test/integration/* when iteration speed demands it. This kind of test pyramid is not novel, but having it natively supported by a toolchain rather than bolted on through runner scripts is one of the quieter wins of the Foundry workflow.

A test name is a specification line. If it cannot be read aloud and understood, the test is not finished.

Manipulating EVM state with cheatcodes

The cheatcode system is where Foundry stops being a faster test runner and starts being a different kind of testing tool. Cheatcodes are prefixed methods on a vm reference that the Forge EVM backend intercepts at the bytecode level — they are not Solidity functions that get compiled into your contract, they are privileged operations recognized by the test harness. They give you surgical control over the EVM environment without writing fragile fixtures or deploying mock infrastructure.

The most-used cheatcode is vm.prank(address), which sets msg.sender for the next external call. It is the answer to nearly every "how do I test onlyOwner behavior" question. The pattern is: prank as the privileged address, call the function, then assert on the state change. vm.startPrank and vm.stopPrank exist for sequences of calls that should all originate from the spoofed address. The discipline to internalize is to prank in the smallest possible scope — a single call is almost always preferable to a multi-call session, because the latter obscures which line of test code actually required the impersonation.

For time-dependent logic, vm.warp(uint256) moves the block timestamp forward or backward to any value you choose. For block-number-dependent logic, vm.roll(uint256) does the same for block.number. Together, these two cheatcodes collapse what would otherwise be a tangle of await advanceTimeAndBlock workarounds into a single declarative line. Time-locked vesting, auction end conditions, epoch transitions, staking reward calculations — anything gated by block.timestamp or block.number — becomes directly testable without flakiness.

Two other cheatcodes deserve early attention. vm.deal(address, uint256) sets an account's ETH balance, which is the only sane way to test contracts that branch on address(this).balance or that pull value from the caller. vm.expectRevert() and its variants pre-declare that the next call should revert, with optional substring matching on the revert reason, which turns error-path testing from a defensive afterthought into a first-class concern. In production engineering, the error paths are where the most consequential bugs live, and cheatcodes make them as easy to test as the happy path.

The architectural trade-off here is worth naming explicitly. Cheatcodes make tests powerful but couple them to the Forge runtime — you cannot run a vm.prank-using test under Hardhat without translation. For most teams this is fine, because the upside in test expressiveness outweighs the portability cost. For a minority of teams maintaining cross-runtime test parity (typically protocol foundations hedging against tooling obsolescence), the cheatcode system is a real consideration. Conversely, teams that have already standardized on Hardhat and are considering Foundry should plan for a real migration cost on tests that lean heavily on Hardhat-specific fixtures.

Automating edge case discovery with fuzz testing

Fuzz testing in Foundry is not a separate tool you bolt on; it is a property of how you write a test. Any test function that takes arguments can become a fuzz test by adding parameter types, and Forge will automatically generate random inputs, run the test hundreds of thousands of times, and report any input that violates an invariant expressed through an assert* call. There is no separate runner, no configuration block to enable, no JSON output to parse — the fuzzer is just the default execution model for any test that declares inputs.

The value of this is hard to overstate for production engineering. Smart contracts are adversarial code; they run in an environment where any input can be adversarially constructed. A test suite that only exercises hand-picked values is testing what the author thought to test, not what the code actually does. Fuzzing flips that — it tests what the code does across a much wider input space than any human would enumerate, and it does so with essentially zero authoring overhead.

The way to write a useful fuzz test is to constrain the inputs. vm.assume(condition) at the top of a test tells the fuzzer to discard inputs that violate the condition and try another. This is critical because unconstrained fuzzing will waste enormous compute on trivially invalid inputs (negative token amounts, zero-addresses, timestamps in pre-history). A useful rule of thumb is that every fuzz test should have at least one vm.assume constraining the input domain to "physically meaningful" values, and ideally assertions at the boundaries of that domain.

A more advanced pattern is stateful fuzzing, where multiple test functions run sequentially against a shared state contract that the fuzzer calls in random order. This is how invariant testing works in Foundry: you write an invariant that must hold across all sequences of operations, and the fuzzer tries to find a sequence that breaks it. For DeFi protocols — AMMs, lending markets, vaults — invariant fuzzing has uncovered entire classes of bugs that no amount of hand-written unit testing would have surfaced. The line between "thorough unit testing" and "semi-formal verification" gets genuinely blurry here, in the best possible way.

Fuzzing is not extra work — it is the default execution model. The test you already wrote becomes a property test the moment it takes parameters.

The honest trade-off: fuzzing finds bugs you did not anticipate, but it is slower than unit testing on a per-run basis. The practical pattern is to keep unit tests fast for the inner loop and to run fuzz tests with a higher invocation count (FOUNDRY_FUZZ_RUNS=10000) as part of CI, where the additional seconds are amortized across the whole pipeline.

Executing and debugging with the Forge CLI

The final piece of the workflow is the CLI itself, and this is where the Foundry ergonomics compound on top of the speed gains. forge test is the entry point, but its flags are where daily productivity lives. --match-test <regex> runs only the tests whose name matches the pattern. -vvv increases verbosity, with -v showing successful traces, -vv adding console logs, and -vvv dumping the full EVM stack for every call. --gas-report produces a per-function gas profile that drives most optimization decisions on hot paths. --coverage instruments the contracts and reports line and branch coverage in a format that integrates with the standard coverage tooling.

For debugging specifically, the combination of -vvvv and a failing assertion is usually sufficient. The output reads like an EVM-level stack trace: every internal call, every storage read, every return value, with the failing line clearly marked. In practice, this collapses the median debug session from "add console.log, recompile, rerun, repeat" to "rerun with verbose output and read." The forge test --debug <test> flag goes further, dropping you into an interactive debugger where you can step through opcodes — a feature that sounds extravagant until you need it once and then never want to go back.

For tests that fork mainnet, --fork-url combined with a --fork-block-number lets you pin execution to a specific historical state, which is invaluable when you are reproducing a bug from a specific transaction and need deterministic replay. Combined with --match-path, this gives you surgical reproduction: the exact test, the exact block, the exact state, every time.

A reasonable engineer workflow that captures most of the value looks like this: write the test in Solidity, run forge test --match-test <pattern> -vv during inner-loop development, run forge test --gas-report before opening a pull request to catch gas regressions early, and run forge coverage in CI alongside the standard test command. Once a quarter, run the full suite with FOUNDRY_FUZZ_RUNS=50000 and FOUNDRY_INVARIANT_RUNS=500 to give the fuzzer enough headroom to find the classes of bugs that only emerge at scale. This is a small, stable set of habits that compound across years.

The trade-off matrix in closing

Foundry is not a universal answer, and pretending otherwise would be a disservice to anyone making a tooling decision with real consequences. The architectural trade-off is sharp: you gain a faster, more expressive test environment in exchange for coupling your test suite to a specific EVM backend. Teams that prioritize cross-runtime portability, that have deep investment in Hardhat plugins, or that maintain complex JavaScript orchestration around their tests will find the migration cost real and the benefit incremental. Teams that ship Solidity-heavy protocols, that want their test code to read like their production code, and that value iteration speed above tooling agnosticism will find the migration pays for itself within a sprint.

The deeper observation, the one that matters beyond the immediate tooling choice, is that the impedance mismatch between Solidity contracts and JavaScript tests was always a tax, not a feature. Removing it changes how engineers write code — more tests, smaller boundaries, more aggressive refactoring, faster recovery from broken assumptions. That is the real reason to learn Foundry, and the rest of the benefits, however dramatic the benchmark numbers get, follow from that one architectural simplification.

FAQ

How do I run a specific test in Foundry?
You can use the --match-test flag followed by a regex pattern to execute only the tests that match that name.
What is the purpose of the setUp() function in a Foundry test?
The setUp() function acts as a per-test fixture that runs before each test case to initialize the environment or deploy fresh contracts.
How can I test functions that are restricted to the contract owner?
You can use the vm.prank(address) cheatcode to set the msg.sender for the next external call to the privileged address.
How does Foundry handle time-dependent logic in tests?
You can use the vm.warp(uint256) cheatcode to move the block timestamp forward or backward to any desired value.
Can I run tests against a live network state?
Yes, by defining an RPC endpoint in your foundry.toml file and passing the --fork-url flag to your test command.
How do I prevent the fuzzer from using invalid input values?
You can use vm.assume(condition) at the beginning of your test to instruct the fuzzer to discard inputs that violate your specified constraints.

By Lucas Meade