Skip to content
← Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
Test discipline
Lesson 3 of 7·CONTENT35 min70 XP
Course
Mastering Foundry — Solidity testing discipline for engineers who already think in Rust
Lesson role
CONTENT
Sequence
3 / 7

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 Solidity prop_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's fuzz.runs = 256 is 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. (Its shrink logic lives only in the invariant executor, shrink_run_limit; Lesson 3.) Keep counterexamples readable with bound(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's proptest-regressions/, but it holds one entry per test and is overwritten by the next failure.
  • The vm.assume trap. A filter that rejects almost everything exhausts max_test_rejects (65,536) → `vm.assume` rejected too many inputs (65536 allowed). A pinpoint filter like vm.assume(x == 42) is worse: the fuzzer harvests the literal 42 from your bytecode into its dictionary, so the test reports [PASS] (runs: 1000) while testing one value. vm.assume is only for excluding boundary conditions (< 1 %), not for selecting specific values — that's a unit test.
  • vm.expectRevert and vm.assume are 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-setUp state, 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 no forge fuzz command: fuzz tests are ordinary testFuzz_* functions with parameters, and forge test runs 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 wrote proptest! { #[test] fn balance_never_negative(...) { ... } } in the liquidation course's withdraw_shortfall lesson, you already know the shape of a testFuzz_* function — Solidity just wraps it in contract syntax.
  • vm.assume(condition) is the Solidity equivalent of prop_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 guard prop_assume!(i128::from(entry) * i128::from(size) > i128::from(collateral)) from the liquidation course's account_equity + margin_ratio lesson (the same proptest also filters with prop_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's fuzz.runs = 256 is 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's proptest! makes with its CASES = 256 default.
  • 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's proptest would shrink that toward the smallest failing value; Foundry's fuzz runner doesn't. You get readable failures by constraining inputs with bound, 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 with runs = 1000 for the default and a profile alias [profile.ci] with runs = 100000 for 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). Uses vm.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 test runs 3 tests cleanly (the 2 forge init defaults + your new vm.expectRevert test).
  • You've internalized the project shape, the setUp per-test isolation pattern, and the -v through -vvvvv verbosity 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:

  1. Open foundry.toml and 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.)
  2. Read testFuzz_SetNumber(uint256 x) from Lesson 1's Counter.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).
  3. Append one new fuzz test: testFuzz_IncrementPreservesPlusOne(uint256 x). Set counter to x, call counter.increment(), assert counter.number() == x + 1. Use vm.assume(x < type(uint256).max) to filter out the overflow case. Run with forge 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:

  1. setUp() runs once; every iteration starts from its snapshot. Each run's calls execute without committing state, so the next run sees the post-setUp state again (a setUp that 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.
  2. 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. Use vm.assume for preconditions; use vm.expectRevert for negative-path tests. They sound similar; they do opposite things.
  3. 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:

  1. Shrinking in Foundry means invariant shrinking. When an invariant test (Lesson 3) fails, Foundry shortens the failing call sequence ([invariant] shrink_run_limit = 5000 in forge config). That is the real Foundry counterpart of proptest's shrinking. Single-call fuzz tests get nothing comparable.
  2. 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-seed with -j1 replays a failure deterministically; and copying the counterexample into a test_* 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:

  1. runs = 1000 is 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.
  2. max_test_rejects = 65536 — the maximum number of vm.assume rejections before the test gives up and reports a failure. The default is 65536; you'll usually never hit it. If you do, your vm.assume predicate is too restrictive — the fuzzer can't find inputs that satisfy it. A max_test_rejects failure is a signal that your precondition is wrong, not that the fuzzer is broken.
  3. [profile.ci.fuzz] runs = 100000 — when CI runs FOUNDRY_PROFILE=ci forge test, this 100K-iteration value overrides the default. This profile-per-environment pattern is common in larger Foundry codebases. forge config shows the merged result (FOUNDRY_PROFILE=ci forge config prints runs = 100000 under [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:

  1. Function name starts with testFuzz_. Foundry recognizes any function whose name starts with test AND takes parameters as a fuzz test. The testFuzz_ prefix is convention (not strict syntax); the parameter is what triggers fuzzing. Convention + parameter signature = fuzz test.
  2. uint256 x is the fuzz input. Foundry generates a random uint256 for 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.
  3. The assertion assertEq(counter.number(), x) is the property. Read it as: "for all uint256 values x, after setNumber(x), the counter holds x." 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.
  4. There's no vm.assume because there's no precondition. Every uint256 value is valid input to setNumber. When every input is valid, you don't need to filter — just let the fuzzer iterate. vm.assume is 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:

  1. vm.assume(x < type(uint256).max) filters the one input the property doesn't hold for — the maximum value, where x + 1 would 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.assume defines the regime where the property is asserted. This is the opposite role from Lesson 1's vm.expectRevert. vm.expectRevert is a negative-path test that expects the revert to happen and treats it as success; vm.assume is 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.
  2. 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.
  3. The property counter.number() == x + 1 is the conservation law. Before increment: x. After increment: x + 1. The difference is exactly 1 — and it holds for all valid x. Same shape as the withdraw_amount_plus_unfilled_equals_shortfall proptest from the liquidation course's withdraw_shortfall lesson. Fuzz tests express conservation laws; unit tests express specific cases.
  4. x + 1 happens inside the assertion, after vm.assume rejected type(uint256).max. So the +1 arithmetic is always safe — never overflows. The vm.assume is what protects this assertion from misfire. Preconditions guard arithmetic; preconditions are part of the property.
  5. counter.setNumber(x) mutates state before the assertion. Each fuzz iteration starts from the post-setUp snapshot (see the diagram above), so the mutation only affects this iteration's contract instance. State setup + property assertion = one iteration; isolation prevents leak.
  6. 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_rejects Failures

vm.assume should 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-pattern

Random 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., 42 or 0xdead...), 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:

  1. args=[4928673976188424316541936830759 ...] is the raw input that failed — not a minimised one. Every input fails this broken property (even 0), but Foundry does not shrink, so you see whatever value hit first. Your run will show a different number.
  2. runs: 0 counts successful runs before the failure. Here the very first input failed. A bug that only some inputs trigger shows a higher count.
  3. calldata= is the exact call, and the Fuzz 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 -j1 printed 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.assume with x = bound(x, 0, 1000); keeps every input small. The args= 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 + 2 property, three reruns reported args=[1], args=[1904] and another value. With -j1 the 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.assume rejected more than max_test_rejects inputs. Your predicate is too restrictive. Loosen it, or use bound to 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, bound the input; to investigate, rerun with --fuzz-seed <seed> -j1 and -vvvv for the full trace.
  • (runs: 0, μ: 0, ~: 0) on a [FAIL] line — the first input already failed; runs counts 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 (or cache/fuzz/) if you want a clean start.

Design reflection

Three load-bearing decisions in fuzz testing's design:

  1. Parameter signature is the fuzz signal, not a @fuzz annotation. Same convention-over-attribute discipline as forge test itself. Foundry's testing surface scales by naming + parameters, not by markup. Tooling doesn't need a syntax tree to discover tests.

  2. vm.assume filters rather than fails. The alternative would be vm.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_rejects already catches the case where your precondition is too restrictive to ever find valid inputs. vm.assume says "this input isn't interesting"; failures say "this property is broken."

  3. 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, and bound plus 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 test passes 4 tests at 1000 iterations
  • FOUNDRY_PROFILE=ci forge test passes 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-side proptest!. vm.assume = prop_assume!; failure persistence and profile-per-environment carry over; shrinking doesn't (raw counterexample — use bound, --fuzz-seed + -j1, pinned unit tests; invariant tests do shrink). Function-name + parameter signature = fuzz signal.
  • vm.assume is 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.expectRevert and vm.assume invert 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-setUp snapshot, which makes failures reproducible; fuzz gas is typical-case. Next lesson: invariant testing for multi-call conservation laws.