Lesson 2 — Fuzz testing — Solidity's proptest!
Question
Foundry fuzz tests (testFuzz_* functions, run by forge test — there is no forge fuzz subcommand) are proptest! with a different syntax. Same theorem-first mindset — assertions must hold over the entire valid input space, not over hand-picked examples. Three disciplines port directly: vm.assume, failure persistence, and profile-per-environment iteration counts. Shrinking does not: Foundry 1.7.1 reports the raw failing input, so you keep counterexamples readable yourself (bound, regression unit tests). Shrinking exists only for invariant tests (Lesson 3).
Principle (minimum model)
vm.assume(cond)is the Solidityprop_assume!. Filters out inputs that violate preconditions before the assertion runs. The test only exercises the well-defined regime of the property.- Default 256 iterations is a floor, not a goal.
foundry.toml'sfuzz.runs = 256is the OOB default. Production CIs run 10 K – 100 K. Nightly fuzzers push to 1 M.[profile.ci.fuzz]enables profile-per-environment. - No shrinking for fuzz tests. Foundry 1.7.1 reports the first failing input as-is — often a 30-plus-digit
uint256. (Itsshrinklogic lives only in the invariant executor,shrink_run_limit; Lesson 3.) Keep counterexamples readable withbound(x, lo, hi)and pin interesting ones as unit tests. - Failure persistence. A failing fuzz test writes its counterexample to
cache/fuzz/failures/<Contract>/<test>(failure_persist_dir = "cache/fuzz"); the next run replays it first. Closest to Rust'sproptest-regressions/, but it holds one entry per test and is overwritten by the next failure. - The
vm.assumetrap. A filter that rejects almost everything exhaustsmax_test_rejects(65,536) →`vm.assume` rejected too many inputs (65536 allowed). A pinpoint filter likevm.assume(x == 42)is worse: the fuzzer harvests the literal42from your bytecode into its dictionary, so the test reports[PASS] (runs: 1000)while testing one value.vm.assumeis only for excluding boundary conditions (< 1 %), not for selecting specific values — that's a unit test. vm.expectRevertandvm.assumeare opposite roles.vm.expectRevert= negative-path test where the revert IS the success criterion.vm.assume= positive-path guard that excludes revert-causing inputs so the assertion runs on a well-defined domain. Same physical event (the contract reverts on this input); opposite test discipline.- Per-iteration isolation.
setUp()runs once per test; every fuzz run starts from that same post-setUpstate, and a run's state changes are never committed → one run cannot poison the next. Reproducibility depends on this. - Gas stats (μ / ~) come from passing iterations. Failing iterations don't contribute. So fuzz gas is typical-case, not worst-case. For worst-case, write a specific unit test.
Worked example + steps
Lesson 2 — Fuzz testing — Solidity's proptest!
Goal
Concepts you'll grasp in this lesson:
- Foundry fuzz testing is
proptest!with different syntax. (There is noforge fuzzcommand: fuzz tests are ordinarytestFuzz_*functions with parameters, andforge testruns them.) Same theorem-first mindset: write an assertion that should hold for all valid inputs (not just hand-picked examples), and let the runner search the input space for a counterexample. Similar failure persistence: the last counterexample is replayed first on the next run. One big difference: no shrinker — Foundry reports the raw failing input, not a minimal one. If you wroteproptest! { #[test] fn balance_never_negative(...) { ... } }in the liquidation course'swithdraw_shortfalllesson, you already know the shape of atestFuzz_*function — Solidity just wraps it in contract syntax. vm.assume(condition)is the Solidity equivalent ofprop_assume!. Both filter out inputs that violate preconditions before the assertion runs, so the test only exercises the regime where the property is well-defined. The pattern matches the levered-regime guardprop_assume!(i128::from(entry) * i128::from(size) > i128::from(collateral))from the liquidation course'saccount_equity+margin_ratiolesson (the same proptest also filters withprop_assume!(mark_a < mark_b)): when an input would push the test out of its meaningful domain, discard it. The fuzzer just generates another input and tries again.- Default 256 iterations is a minimum, not a goal.
foundry.toml'sfuzz.runs = 256is the out-of-the-box default — enough to catch the obvious bugs in seconds, not enough to prove a property. Production codebases bump it to 10_000 or 100_000 for CI and reserve the higher counts for nightly runs. Same trade-off Rust'sproptest!makes with itsCASES = 256default. - Without shrinking, a readable counterexample is your job. When a fuzz test fails, Foundry 1.7.1 stops at the first failing input and prints it as-is (say,
x = 4928673976188424316541936830759). Rust'sproptestwould shrink that toward the smallest failing value; Foundry's fuzz runner doesn't. You get readable failures by constraining inputs withbound, reproducing with--fuzz-seed, and pinning the counterexample as a unit test.
Verification:
forge test
…passes 4 tests (3 from Lesson 1 + 1 new fuzz test added in this lesson). All four green at the default 256 iterations; you'll also see what happens at 100_000.
Specific changes:
foundry.toml— adds a[fuzz]profile section withruns = 1000for the default and a profile alias[profile.ci]withruns = 100000for the heavy run. Demonstrates how to tune iteration counts without hard-coding them in each test.test/Counter.t.sol— appends one new fuzz test:testFuzz_IncrementPreservesPlusOne(uint256 x). Usesvm.assume(x < type(uint256).max)to filter out the overflow case before asserting the property holds.
Total: ~15 lines of new code. Lesson 2 is about what fuzzing is and what a failure report gives you, not about clever fuzz coverage.
Recap
After Lesson 1:
forge testruns 3 tests cleanly (the 2forge initdefaults + your newvm.expectReverttest).- You've internalized the project shape, the
setUpper-test isolation pattern, and the-vthrough-vvvvvverbosity ladder. - You've seen the
testFuzz_SetNumber(uint256 x)test pass with 256 runs — but it was unexplained. Lesson 2 explains what it was doing.
Lesson 2 turns that mysterious 256-run line into the central tool of property-based testing.
Plan
Three edits:
- Open
foundry.tomland add a[fuzz]section to tune the default iteration count. Add a[profile.ci]profile with a higher count for heavy runs. (No new contract code yet — just configuration.) - Read
testFuzz_SetNumber(uint256 x)from Lesson 1'sCounter.t.sol. Understand why Foundry treats it as a fuzz test, what the runner does each iteration, and how the result line(runs: 256, μ: 26801, ~: 29289)is generated (μ = mean gas, ~ = median; they vary slightly from run to run). - Append one new fuzz test:
testFuzz_IncrementPreservesPlusOne(uint256 x). Setcountertox, callcounter.increment(), assertcounter.number() == x + 1. Usevm.assume(x < type(uint256).max)to filter out the overflow case. Run withforge test -vvv.
(Answer: Most production CI runs at 10_000 or 100_000; nightly fuzzers push to 1_000_000. The trade-off: each iteration runs the full test (setUp → call → assertion → state cleanup). Cost scales roughly linearly with the count and with how expensive one run is: this lesson's trivial Counter tests finish 100_000 runs each in under a second on a 12-core laptop (Foundry spreads runs over worker threads), while tests that deploy contracts or make dozens of calls per run take far longer. Past 100_000 the diminishing returns kick in unless your test is exercising a vast input space — most uint256 fuzz tests have de facto small interesting regions, and 100_000 hits them already. Run high counts on dedicated nightly CI, default counts on PR CI, low counts during local development.)
What fuzz testing actually does
flowchart TD
S[setUp runs once<br/>snapshot: Counter, number = 0] --> A
A[1. Generate uint256<br/>random / edge / dictionary] --> B[2. Start from the post-setUp snapshot]
B --> C[3. Call testFuzz_* x = generated]
C --> D{4. vm.assume cond?}
D -->|false: discard iteration| A
D -->|true| E[5. Run assertion<br/>assertEq / assertTrue]
E -->|PASS: discard state, next iteration| A
E -->|FAIL: stop| F[report raw counterexample<br/>no shrinking<br/>persist to cache/fuzz/failures]
A -.->|max_test_rejects exceeded| H[vm.assume rejected too many inputs]
A -.->|after fuzz.runs successes| G[report gas stats μ ~]
Three things to notice about the loop:
setUp()runs once; every iteration starts from its snapshot. Each run's calls execute without committing state, so the next run sees the post-setUpstate again (asetUpthat appends to a file writes one line for a 1000-run test). This is per-iteration state isolation — same discipline as per-test isolation in Lesson 1, at a finer grain. Per-iteration isolation is what makes fuzz failures reproducible.vm.assume(cond)inside a fuzz test silently discards the iteration if the condition is false. It doesn't fail the test, doesn't count as a pass — it just generates a new input. This is the input-filtering mechanism. Usevm.assumefor preconditions; usevm.expectRevertfor negative-path tests. They sound similar; they do opposite things.- Gas statistics (μ and ~) come from the iterations that passed. Failing iterations don't contribute. So a fuzz test that mostly passes but occasionally hits an expensive edge case still reports a low μ because the cheap iterations dominate. Don't read fuzz gas numbers as worst-case; they're typical-case. For worst-case gas, use unit tests on the specific high-gas inputs.
Why there is no shrinker step (and where there is one)
Rust's proptest shrinks: after a failure it simplifies the input and re-runs until it finds a small failing case. Foundry's fuzzer is built on the proptest crate, but the stateless fuzz runner in 1.7.1 (crates/evm/evm/src/executors/fuzz/mod.rs) never calls the shrinking step. You can check it in ten seconds:
function testFuzz_ShrinkProbe(uint256 x) public pure {
assertLt(x, 1000);
}
A shrinker would report x = 1000. Foundry 1.7.1 reported args=[115792089237316195423570985008687907853269984665640564039457584007913129639933] — the raw input (here type(uint256).max - 2, from the edge-value generator).
flowchart TD
A[Fuzz test fails at input x] --> B[Report x as-is<br/>counterexample args=x]
B --> C[Persist x to cache/fuzz/failures]
D[Invariant test fails<br/>Lesson 3] --> E[Shrink the call sequence<br/>up to shrink_run_limit = 5000]
E --> F[Report a shorter failing sequence]
Two things to notice:
- Shrinking in Foundry means invariant shrinking. When an invariant test (Lesson 3) fails, Foundry shortens the failing call sequence (
[invariant] shrink_run_limit = 5000inforge config). That is the real Foundry counterpart ofproptest's shrinking. Single-call fuzz tests get nothing comparable. - So you shape the input space yourself.
x = bound(x, 0, 1000)keeps every input (and therefore every counterexample's effect) in a readable range;--fuzz-seedwith-j1replays a failure deterministically; and copying the counterexample into atest_*unit test turns it into a permanent regression. Step 4 shows all three.
Walk-through
Step 1: Tune foundry.toml for fuzz iteration counts
Open foundry.toml. It should look like this after forge init:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
Append a [fuzz] section to the default profile and a heavier [profile.ci]:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[fuzz]
runs = 1000
max_test_rejects = 65536
[profile.ci.fuzz]
runs = 100000
Three things to notice:
runs = 1000is the new default — 4× the out-of-the-box 256. Tight enough to keep local development feedback under a second; loose enough to catch obvious bugs the default would miss. Bump from 256 to 1000 as soon as you write your second fuzz test; the cost is sub-second.max_test_rejects = 65536— the maximum number ofvm.assumerejections before the test gives up and reports a failure. The default is 65536; you'll usually never hit it. If you do, yourvm.assumepredicate is too restrictive — the fuzzer can't find inputs that satisfy it. Amax_test_rejectsfailure is a signal that your precondition is wrong, not that the fuzzer is broken.[profile.ci.fuzz] runs = 100000— when CI runsFOUNDRY_PROFILE=ci forge test, this 100K-iteration value overrides the default. This profile-per-environment pattern is common in larger Foundry codebases.forge configshows the merged result (FOUNDRY_PROFILE=ci forge configprintsruns = 100000under[fuzz]). Profiles let you tune iteration counts per environment without hard-coding.
Run forge test to confirm the config didn't break anything:
forge test
Expected output now shows (runs: 1000, ...) for the existing fuzz test (gas figures vary slightly between runs):
[PASS] testFuzz_SetNumber(uint256) (runs: 1000, μ: 27080, ~: 29289)
Step 2: Read testFuzz_SetNumber from Lesson 1
The test from forge init (which you already have):
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
Four things to notice:
- Function name starts with
testFuzz_. Foundry recognizes any function whose name starts withtestAND takes parameters as a fuzz test. ThetestFuzz_prefix is convention (not strict syntax); the parameter is what triggers fuzzing. Convention + parameter signature = fuzz test. uint256 xis the fuzz input. Foundry generates a randomuint256for each iteration. Multi-parameter signatures (e.g.,function testFuzz_Op(uint256 a, address b)) get independently-fuzzed values for each. Each fuzz parameter is independently sampled.- The assertion
assertEq(counter.number(), x)is the property. Read it as: "for all uint256 valuesx, aftersetNumber(x), the counter holdsx." That's a statement of program correctness, not a single example. A fuzz assertion is a universally-quantified property; a unit-test assertion is one example. - There's no
vm.assumebecause there's no precondition. Everyuint256value is valid input tosetNumber. When every input is valid, you don't need to filter — just let the fuzzer iterate.vm.assumeis for restricting the regime; omit it when the property holds universally.
This particular test is trivially true — setNumber just stores the value. The property is "the storage write actually stored what we passed in." It's a property worth proving (a future refactor that masked some bits in the setter would fail this fuzz test), but it's not an interesting demonstration of fuzzing's power. Our new test in Step 3 is.
Step 3: Add testFuzz_IncrementPreservesPlusOne
Append to test/Counter.t.sol:
function testFuzz_IncrementPreservesPlusOne(uint256 x) public {
// Precondition: x must not be at the type ceiling, otherwise
// increment() would overflow and Solidity 0.8 would revert,
// taking the assertion with it. vm.assume filters these inputs
// before the assertion runs — same role as the liquidation
// course's levered-regime guard, prop_assume!(
// i128::from(entry) * i128::from(size) > i128::from(collateral)).
vm.assume(x < type(uint256).max);
counter.setNumber(x);
counter.increment();
assertEq(counter.number(), x + 1);
}
Six things to notice:
vm.assume(x < type(uint256).max)filters the one input the property doesn't hold for — the maximum value, wherex + 1would overflow. Without this filter, the test would correctly fail on that single input. With the filter, the test proves the property for the meaningful input range.vm.assumedefines the regime where the property is asserted. This is the opposite role from Lesson 1'svm.expectRevert.vm.expectRevertis a negative-path test that expects the revert to happen and treats it as success;vm.assumeis a positive-path test that excludes inputs that would revert, so the property assertion can run on the well-defined domain. Same physical phenomenon (the contract would revert at this input) — opposite test-discipline intent.- The comment cross-references the liquidation course's
prop_assume!— same role, same pattern, different syntax. Readers who came through that course recognize the discipline. Cross-language pattern recognition is the load-bearing pedagogical move of this whole course. - The property
counter.number() == x + 1is the conservation law. Before increment:x. After increment:x + 1. The difference is exactly 1 — and it holds for all validx. Same shape as thewithdraw_amount_plus_unfilled_equals_shortfallproptest from the liquidation course'swithdraw_shortfalllesson. Fuzz tests express conservation laws; unit tests express specific cases. x + 1happens inside the assertion, aftervm.assumerejectedtype(uint256).max. So the+1arithmetic is always safe — never overflows. Thevm.assumeis what protects this assertion from misfire. Preconditions guard arithmetic; preconditions are part of the property.counter.setNumber(x)mutates state before the assertion. Each fuzz iteration starts from the post-setUpsnapshot (see the diagram above), so the mutation only affects this iteration's contract instance. State setup + property assertion = one iteration; isolation prevents leak.- No
expectRevert. This is a positive-path fuzz test — we're not testing the overflow case (that was Lesson 1's job). We're testing that when overflow doesn't happen, the conservation law holds. One test per property; one property per test.
Run:
forge test -vvv
Expected output:
[PASS] testFuzz_IncrementPreservesPlusOne(uint256) (runs: 1000, μ: 33499, ~: 33499)
[PASS] testFuzz_SetNumber(uint256) (runs: 1000, μ: 27080, ~: 29289)
[PASS] test_Increment() (gas: 28783)
[PASS] test_RevertWhen_DecrementBelowZero() (gas: 151890)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 8.28ms (15.90ms CPU time)
Four tests, all green at 1000 iterations. The whole suite finishes in milliseconds because each iteration is cheap.
[!WARNING] The "Pinpoint Filtering Trap" and
max_test_rejectsFailures
vm.assumeshould be used exclusively to filter out a tiny fraction of the input space (typically less than 1%), such as boundary values (like overflow limits or zero).If you write a filter that almost nothing passes, the fuzzer burns through the rejection budget
max_test_rejects(default 65,536) and the test fails with:[FAIL: `vm.assume` rejected too many inputs (65536 allowed)] testFuzz_TooStrict(uint256) (runs: 0, μ: 0, ~: 0)(That run used
vm.assume(uint256(keccak256(abi.encode(x))) % 1_000_000 == 0);.)A pinpoint filter on a literal is sneakier:
vm.assume(x == 42); // ✗ Anti-patternRandom sampling from $2^{256}$ would never hit
42, but Foundry seeds its input dictionary with constants found in your bytecode, so in 1.7.1 this test reports[PASS] testFuzz_Pinpoint(uint256) (runs: 1000, ...). Green — while every accepted run tested the same single value.
- The Core Issue: Writing
vm.assume(x == target)degrades a powerful fuzz test into a highly inefficient unit test.- Remedy and Best Practice:
- If you need to verify code behavior for specific pinpoint values (e.g.,
42or0xdead...), do not use fuzzing. Write a standard unit test (test_...) instead.- Fuzz testing is designed to verify invariants across broad ranges, not to serve as a substitute for explicit scenario tests. Use each tool for its intended purpose.
Step 4: Break the test and read the failure report
Deliberately break the property. Change the assertion to:
assertEq(counter.number(), x + 2); // Wrong: should be x + 1
Run forge test --match-test IncrementPreserves -vvv (real Foundry 1.7.1 output, trace trimmed):
[FAIL: assertion failed: 4928673976188424316541936830760 != 4928673976188424316541936830761; counterexample: calldata=0x8a133210000000000000000000000000000000000000003e35678f06d01d791b82e78927 args=[4928673976188424316541936830759 [4.928e30]]] testFuzz_IncrementPreservesPlusOne(uint256) (runs: 0, μ: 0, ~: 0)
Traces:
[34084] CounterTest::testFuzz_IncrementPreservesPlusOne(4928673976188424316541936830759 [4.928e30])
├─ [0] VM::assume(true) [staticcall]
...
Fuzz seed: 0x7dce3c1be6cf656b1781fc22100fe2c5d33329ae398f0b517f62236ec67940f8 (use `--fuzz-seed` to reproduce)
How to read it:
args=[4928673976188424316541936830759 ...]is the raw input that failed — not a minimised one. Every input fails this broken property (even0), but Foundry does not shrink, so you see whatever value hit first. Your run will show a different number.runs: 0counts successful runs before the failure. Here the very first input failed. A bug that only some inputs trigger shows a higher count.calldata=is the exact call, and theFuzz seed:line is the RNG seed for this run.
Three ways to get a readable, reproducible counterexample:
- Reproduce with the seed, single-threaded.
forge test --match-test IncrementPreserves --fuzz-seed 1 -j1printed the same counterexample on three runs in a row. Without-j1, Foundry fuzzes on several worker threads, and when many inputs fail, whichever worker fails first wins — the same seed gave different counterexamples across runs. - Bound the input. Replacing
vm.assumewithx = bound(x, 0, 1000);keeps every input small. Theargs=field still shows the raw fuzzer value, but the assertion message shows the bounded one (assertion failed: 367 != 368). - Pin it as a unit test. Once you have a counterexample, keep it forever:
// Regression test pinned from a fuzz counterexample.
function test_IncrementPreservesPlusOne_Regression() public {
uint256 x = 4928673976188424316541936830759;
counter.setNumber(x);
counter.increment();
assertEq(counter.number(), x + 1);
}
(Optional; the test counts below assume you didn't add it.)
Revert the assertion back to x + 1 before continuing.
assertEq(counter.number(), x + 1); // Restored
Re-run forge test. All four tests green again.
Step 5: Look at the persisted failure
Foundry writes a failing fuzz test's counterexample under failure_persist_dir (cache/fuzz by default — forge config shows it). After the break-and-revert above:
find cache/fuzz -type f
# cache/fuzz/failures/CounterTest/testFuzz_IncrementPreservesPlusOne
cat cache/fuzz/failures/CounterTest/testFuzz_IncrementPreservesPlusOne
{"warp":null,"roll":null,"sender":null,"addr":null,"calldata":"0x8a133210000000000000000000000000000000000000003e35678f06d01d791b82e78927","contract_name":null,"func_name":null,"signature":null,"args":"4928673976188424316541936830759 [4.928e30]","raw_args":"4928673976188424316541936830759"}
One file per test, holding one counterexample. On the next forge test, the first worker replays that calldata before generating new inputs (the replay does not count toward runs). What that buys you:
- A rare failure comes back immediately. With a property that fails for 1 in 1000 inputs, four consecutive runs reported the same
args=[7]. - When almost every input fails, the report can change. Other worker threads fuzz in parallel, and whichever failure is claimed first is reported — and overwrites the file. With the broken
x + 2property, three reruns reportedargs=[1],args=[1904]and another value. With-j1the replayed value came back every time. - Fixing the bug doesn't delete the file. The replayed input simply passes, and the file stays until another failure overwrites it.
This is the role proptest-regressions/ files play in Rust, but thinner: Rust's file accumulates seeds you commit; Foundry keeps only the latest failure per test, under cache/, which forge init's .gitignore ignores. To keep a counterexample, turn it into a unit test (Step 4), not a cache file.
Step 6: Run with the CI profile
FOUNDRY_PROFILE=ci forge test
This runs with fuzz.runs = 100000 (the profile we added in Step 1). The output:
[PASS] testFuzz_IncrementPreservesPlusOne(uint256) (runs: 100000, μ: 33499, ~: 33499)
[PASS] testFuzz_SetNumber(uint256) (runs: 100000, μ: 27052, ~: 29289)
[PASS] test_Increment() (gas: 28783)
[PASS] test_RevertWhen_DecrementBelowZero() (gas: 151890)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 714.62ms (1.41s CPU time)
100× more iterations, still under a second for these two trivial fuzz tests on a 12-core laptop. Real suites with deployments and multi-call setups cost far more per run, which is why large counts usually run nightly, not on every PR. Use profiles to gate iteration counts to environment.
Common errors
[FAIL: `vm.assume` rejected too many inputs (65536 allowed)]—vm.assumerejected more thanmax_test_rejectsinputs. Your predicate is too restrictive. Loosen it, or useboundto map inputs into the valid range instead of rejecting them.counterexample: ... args=[<huge number>]— expected: Foundry does not shrink fuzz counterexamples. If you want small values,boundthe input; to investigate, rerun with--fuzz-seed <seed> -j1and-vvvvfor the full trace.(runs: 0, μ: 0, ~: 0)on a[FAIL]line — the first input already failed;runscounts successful runs before the failure.- A fuzz test you fixed still "replays" an old input — that's the persisted failure in
cache/fuzz/failures/. It passes once the bug is fixed; delete the file (orcache/fuzz/) if you want a clean start.
Design reflection
Three load-bearing decisions in fuzz testing's design:
-
Parameter signature is the fuzz signal, not a
@fuzzannotation. Same convention-over-attribute discipline asforge testitself. Foundry's testing surface scales by naming + parameters, not by markup. Tooling doesn't need a syntax tree to discover tests. -
vm.assumefilters rather than fails. The alternative would bevm.requirePrecondition(cond)that fails the iteration if false. Foundry chose the filter semantics because: (a) most precondition violations are inputs you genuinely don't want to test, not bugs; (b) treating them as test failures would flood your CI with noise; (c)max_test_rejectsalready catches the case where your precondition is too restrictive to ever find valid inputs.vm.assumesays "this input isn't interesting"; failures say "this property is broken." -
Fuzz tests report, invariant tests shrink. In 1.7.1 the stateless fuzz runner stops at the first failing input and reports it raw; shrinking is implemented only for invariant call sequences (
shrink_run_limit). For a single call, the input is the whole reproduction, andboundplus a pinned unit test gets you a readable case; for a 500-call sequence, shrinking is what makes the failure debuggable at all. Know which tool shrinks before you rely on a "minimal" counterexample.
Answer key
After Lesson 2:
my-foundry-lab/
├── foundry.toml (+ [fuzz] runs = 1000, + [profile.ci.fuzz] runs = 100000)
├── src/Counter.sol (unchanged from Lesson 1)
├── test/Counter.t.sol (+ testFuzz_IncrementPreservesPlusOne)
└── lib/forge-std/ (unchanged)
After Lesson 2:
forge testpasses 4 tests at 1000 iterationsFOUNDRY_PROFILE=ci forge testpasses 4 tests at 100,000 iterations- You've read a raw (unshrunk) counterexample and reproduced it with
--fuzz-seed/-j1 - You've seen
cache/fuzz/failures/persist the last failure per test for replay
Common questions
Q1: Why isn't the default fuzz.runs higher than 256? Wouldn't more iterations be strictly better?
Tradeoff: 256 is the speed-vs-coverage sweet spot for local development (sub-second feedback per test). Production codebases bump it for CI because they have time budget for it; local development needs to stay tight. 256 is for the inner loop; 10_000–100_000 is for the outer loop.
Q2: Why does fuzz testing use random input generation instead of exhaustive search?
Because uint256's input space is $2^{256} \approx 10^{77}$ values — exhaustive search is impossible. Foundry's uint generator (crates/evm/fuzz/src/strategies/uint.rs) mixes three sources: random values whose bit-length is picked uniformly (so small numbers are common), edge values within 3 of $0$ or type(uint256).max, and fixtures you declare. On top of that, dictionary_weight = 40 (see forge config) draws inputs from a dictionary of constants and storage values harvested from your contracts. Pure-random over $2^{256}$ would miss every edge case; biased generation plus the dictionary hits them.
Q3: Should every state-changing function have a corresponding fuzz test?
Ideally yes — every external function that mutates state should have at least one fuzz test proving the relevant invariant. In practice, prioritize: arithmetic (overflow boundaries), access control (caller checks), and any function that has a conservation law (deposit/withdraw, mint/burn). Aim for fuzz coverage of properties, not lines.
Q4: How is fuzz testing different from invariant testing (Lesson 3)?
(Both are course labels; forge test runs both kinds.) A fuzz test is single-call: each iteration calls one function with random parameters and checks an assertion. An invariant test (Lesson 3) is multi-call: each iteration calls many functions in random sequence and checks an invariant after each call (check_interval = 1 by default). Fuzz tests one function in isolation; invariant tests sequences of function calls. Both are property tests; the granularity differs.
Q5: What happens if my fuzz test calls a function that internally calls vm.assume?
vm.assume works from an internal helper and from an external call into another contract alike: in 1.7.1, a test that calls h.check(x) on a helper contract doing vm.assume(x % 2 == 0) passed 1000 runs with only even x reaching the assertion. The first vm.assume(false) discards the iteration. Composability is built into the cheatcode model.
Q6: Does shrinking work with bytes and string parameters?
No — fuzz-test counterexamples aren't shrunk for any parameter type in Foundry 1.7.1, so a failing bytes input is reported at whatever length it was generated: assertLt(data.length, 3) failed in 1.7.1 with a 31-byte counterexample, not a 3-byte one. Keep them readable the same way as integers: cap the length up front (vm.assume(data.length <= 64) passed 1000 runs without hitting the rejection limit), and pin a failing value as a unit test. Shrinking in Foundry is for invariant call sequences (Lesson 3), not single-call inputs.
Next lesson (Lesson 3) — Invariant testing — multi-call tests
Lesson 3 graduates from single-call fuzz testing to multi-call invariant testing — the closest Solidity primitive to per-scan conservation laws from the liquidation course's scanner-capstone lesson.
The key concept: define a Handler contract whose functions are the "things the system can do" (deposit, withdraw, increment, etc.). Tell Foundry "treat this Handler as the surface area to fuzz." Foundry then generates random sequences of method calls — deposit(100), withdraw(50), increment(), withdraw(75) — and checks an invariant_* function after each step.
This is what catches multi-call bugs that single-call fuzzing never sees: token-balance reentrancy, ordering-dependent state corruption, accounting drift that only appears after many operations. Lesson 3 is where forge becomes a real adversary, not just a parameter generator.
Expert continuation
Single-call fuzzing is the Solidity-side primitive. The expert course takes the same property-testing idea down to the consensus layer — see its differential-fuzzing lesson for cross-client diff testing of EVM implementations against the canonical spec.
Summary (3 lines)
- Fuzz tests (run by
forge test) = Solidity-sideproptest!.vm.assume=prop_assume!; failure persistence and profile-per-environment carry over; shrinking doesn't (raw counterexample — usebound,--fuzz-seed+-j1, pinned unit tests; invariant tests do shrink). Function-name + parameter signature = fuzz signal. vm.assumeis for excluding boundary conditions (< 1 %), never for pinning specific values (→`vm.assume` rejected too many inputs, or a misleading green run on a dictionary literal).vm.expectRevertandvm.assumeinvert each other — negative-path success vs positive-path guard.- Default 256 is the local-dev sweet spot (sub-second). Production CI 10 K – 100 K; nightly 1 M. Every run starts from the post-
setUpsnapshot, which makes failures reproducible; fuzz gas is typical-case. Next lesson: invariant testing for multi-call conservation laws.