2026-03-28·11 min read

Foundry fuzz testing: from basic fuzzing to invariant tests

Foundry fuzz testing: from basic fuzzing to invariant tests

Foundry is the development framework most Solidity teams use. Its built-in fuzzer runs inside forge test, which means you can start fuzz testing with zero additional setup. But Foundry's fuzzing capabilities go well beyond what most developers use — from basic input fuzzing to stateful invariant tests to a bridge into dedicated fuzzing tools.

This guide covers the full spectrum. We'll start with the basics, build up to invariant tests, and show you where Foundry's built-in fuzzing ends and tools like Echidna and Medusa begin.

<a href="/request-audit" class="cta-button">Get expert fuzzing for your protocol</a>

Foundry fuzz basics

A Foundry fuzz test is any test function that takes parameters. Instead of calling the function with a single hardcoded value, the fuzzer generates hundreds or thousands of random inputs and runs the test with each one.

// Standard unit test: one input
function test_sqrt() public {
    assertEq(Math.sqrt(4), 2);
    assertEq(Math.sqrt(9), 3);
    assertEq(Math.sqrt(0), 0);
}

// Fuzz test: thousands of inputs
function testFuzz_sqrt(uint256 x) public {
    uint256 result = Math.sqrt(x);
    // Property: result^2 <= x < (result+1)^2
    assert(result * result <= x);
    if (result < type(uint128).max) {
        assert((result + 1) * (result + 1) > x);
    }
}

The first test checks three cases you thought of. The second checks every case the fuzzer generates. If there's any input where sqrt violates the property, the fuzzer will find it and give you the exact value that failed.

Running fuzz tests

# Run all tests including fuzz tests
forge test

# Control the number of fuzz runs
forge test --fuzz-runs 10000

# Run a specific fuzz test
forge test --match-test testFuzz_sqrt --fuzz-runs 50000

The default run count is 256. For development iteration, that's fine. For pre-merge checks, bump it to 10,000+. For serious security testing, you'll want 100,000 or more, or a different tool entirely (more on that below).

Bounding inputs

Raw random uint256 values often hit reverts before reaching interesting code paths. Foundry provides bound() to constrain inputs to a useful range without excluding edge cases.

function testFuzz_deposit(uint256 amount) public {
    // Bad: most random uint256 values will exceed balance and revert
    // vault.deposit(amount);

    // Good: constrain to a valid range
    amount = bound(amount, 1, token.balanceOf(address(this)));
    vault.deposit(amount);
    assertGt(vault.balanceOf(address(this)), 0);
}

bound() maps the random value into your range using modular arithmetic, but the fuzzer's coverage guidance still works because different random inputs still map to different bounded values.

Assumptions vs bounds

You might see vm.assume() used to filter inputs:

function testFuzz_divide(uint256 a, uint256 b) public {
    vm.assume(b != 0);  // Skip runs where b == 0
    uint256 result = a / b;
    assert(result <= a);
}

vm.assume() rejects inputs that don't match the condition. This wastes fuzzer runs. Every rejected input is a run that didn't test anything. Prefer bound() when you can:

function testFuzz_divide(uint256 a, uint256 b) public {
    b = bound(b, 1, type(uint256).max);  // No wasted runs
    uint256 result = a / b;
    assert(result <= a);
}

Use vm.assume() only when the constraint is hard to express as a bound, like requiring that two addresses be different.

Writing fuzz tests that find real bugs

The power of a fuzz test is entirely in the property you check. Weak properties find nothing. Strong properties find bugs that manual testing misses.

Input/output properties

The simplest pattern: for any valid input, the output satisfies some condition.

function testFuzz_wrapUnwrap(uint256 amount) public {
    amount = bound(amount, 1, type(uint128).max);

    uint256 wrapped = wrapper.wrap(amount);
    uint256 unwrapped = wrapper.unwrap(wrapped);

    // Round-trip should not increase value
    assertLe(unwrapped, amount);
    // Round-trip loss should be bounded
    assertGe(unwrapped, amount - 1); // At most 1 wei rounding
}

Before/after properties

Check that a state transition preserves some relationship.

function testFuzz_transferPreservesTotal(
    address to,
    uint256 amount
) public {
    vm.assume(to != address(0) && to != address(this));
    amount = bound(amount, 0, token.balanceOf(address(this)));

    uint256 totalBefore = token.balanceOf(address(this))
                        + token.balanceOf(to);

    token.transfer(to, amount);

    uint256 totalAfter = token.balanceOf(address(this))
                       + token.balanceOf(to);

    assertEq(totalAfter, totalBefore);
}

Comparative properties

Compare two implementations or two code paths that should produce the same result.

function testFuzz_previewMatchesActual(uint256 assets) public {
    assets = bound(assets, 1, vault.maxDeposit(address(this)));
    if (assets == 0) return;

    uint256 preview = vault.previewDeposit(assets);
    uint256 actual = vault.deposit(assets, address(this));

    // Preview should be accurate (or conservative)
    assertLe(actual, preview);
}

These comparative properties are particularly good for ERC-4626 vaults, where the standard requires preview functions to match actual behavior.

Invariant test mode

This is where Foundry fuzzing gets serious. Invariant tests go beyond single-function testing. Foundry generates random sequences of function calls against your contracts and checks properties after each call.

// Invariant test contract
contract VaultInvariantTest is Test {
    MockERC20 token;
    Vault vault;

    function setUp() public {
        token = new MockERC20("Test", "T", 18);
        vault = new Vault(address(token));
        token.mint(address(this), 1_000_000e18);
        token.approve(address(vault), type(uint256).max);

        // Tell Foundry which contract to call
        targetContract(address(vault));
    }

    // Checked after every random call sequence
    function invariant_solvency() public view {
        assertGe(
            token.balanceOf(address(vault)),
            vault.totalAssets()
        );
    }

    function invariant_sharePriceNonZero() public view {
        if (vault.totalSupply() > 0) {
            assertGt(vault.convertToAssets(1e18), 0);
        }
    }
}

Configuring invariant tests

# foundry.toml
[invariant]
runs = 256          # Number of sequences to generate
depth = 50          # Calls per sequence
fail-on-revert = false  # Don't fail on reverts (expected in fuzzing)
forge test --match-contract VaultInvariantTest

Handler contracts

By default, Foundry calls random functions with random inputs on your target contract. Most of these calls revert because the inputs are nonsensical. Handler contracts solve this by wrapping target functions with bounded inputs.

contract VaultHandler is Test {
    Vault vault;
    MockERC20 token;

    constructor(Vault _vault, MockERC20 _token) {
        vault = _vault;
        token = _token;
    }

    function deposit(uint256 amount) public {
        amount = bound(amount, 1, token.balanceOf(address(this)));
        if (amount == 0) return;
        vault.deposit(amount, address(this));
    }

    function withdraw(uint256 amount) public {
        uint256 maxWith = vault.maxWithdraw(address(this));
        if (maxWith == 0) return;
        amount = bound(amount, 1, maxWith);
        vault.withdraw(amount, address(this), address(this));
    }
}
// In your test's setUp:
handler = new VaultHandler(vault, token);
token.mint(address(handler), 1_000_000e18);
vm.prank(address(handler));
token.approve(address(vault), type(uint256).max);

targetContract(address(handler));

Now the fuzzer calls handler.deposit() and handler.withdraw() with random arguments, and the handler ensures the arguments are valid. The revert rate drops from 90%+ to near zero, which means almost every fuzzer run tests something meaningful.

Limitations of Foundry-only fuzzing

Foundry's fuzzer is great for development-speed property checks. But it has real limitations for serious security testing.

No corpus persistence

Every forge test run starts from scratch. The fuzzer doesn't save interesting inputs between runs. If you run a 10,000-iteration campaign today and another tomorrow, the second run doesn't benefit from the first.

Echidna and Medusa both persist their corpus to disk. Over time, the corpus grows and the fuzzer starts each run already knowing how to reach deep states. This is the single biggest advantage of dedicated fuzzers.

Limited sequence quality

Foundry's invariant test mode generates random call sequences, but it doesn't do the kind of directed exploration that Echidna does. Echidna builds sequences incrementally: if it finds that deposit → borrow reaches new code, it tries deposit → borrow → X for many values of X. Foundry just generates random sequences of the configured depth.

For shallow bugs, this doesn't matter. For bugs that require a specific 6-8 call sequence with specific parameters, Echidna's directed approach is significantly more effective.

Single-threaded

Foundry's fuzzer runs on a single thread. Medusa can run 8+ parallel workers sharing coverage data. For the same wall-clock time, Medusa explores roughly 3-4x more of the state space.

No optimization testing

Medusa supports optimization mode. Instead of just checking if a property is true or false, it can minimize or maximize a numeric value. This is useful for finding worst-case scenarios: "what's the maximum amount an attacker can extract in a single transaction?"

Sequence shrinking

When Foundry finds a failing invariant, it reports the raw call sequence. Echidna aggressively shrinks failing sequences to the minimal reproducer — often reducing a 50-call sequence to 3-4 calls. This makes debugging dramatically easier.

When to graduate to Echidna or Medusa

Here's our rule of thumb at Recon:

Stay with Foundry when:

  • You're writing fuzz tests during development for fast iteration
  • You're testing pure functions or simple state transitions
  • You need sub-minute feedback in CI
  • Your properties are stateless (single function, random inputs)

Add Echidna or Medusa when:

  • Your protocol has multi-step flows (deposit → borrow → liquidate)
  • You need to find bugs that require specific transaction sequences
  • You want corpus persistence across runs
  • You need deeper exploration (500K+ tests)
  • You're preparing for an audit or deployment

For a detailed comparison of these tools, see our fuzzing tools comparison and our Echidna vs Medusa head-to-head.

The Chimera bridge

The Chimera framework bridges Foundry and dedicated fuzzers. Write your properties once with Chimera's interface, and they'll run on Foundry (forge test), Echidna, and Medusa without modification.

// Works with Foundry, Echidna, AND Medusa
contract CryticTester is TargetFunctions, CryticAsserts {
    constructor() {
        setup();
    }
}

This is how we structure every engagement at Recon. Properties start in Foundry for fast iteration, then the same properties run on Echidna and Medusa for deep exploration. No rewriting, no maintaining two test suites.

The workflow:

  1. Write properties and handlers using Chimera's base contracts
  2. Run with forge test during development for instant feedback
  3. Run with Medusa for quick parallel coverage, 500K tests in minutes
  4. Run with Echidna for deep directed exploration, multi-hour campaigns
  5. Run everything in Recon Pro for cloud-scale campaigns

Each step builds on the one before. Your time investment in writing good properties pays off across every tool.

Getting started today

If you're already using Foundry, you're 10 minutes away from your first fuzz test:

  1. Pick a function with a clear property (round-trip, conservation, bounds)
  2. Add a parameter to your test function
  3. Use bound() to constrain it
  4. Replace assertEq with the property that should hold for all inputs
  5. Run forge test --fuzz-runs 10000

When you're ready for stateful testing, add invariant tests with handler contracts. When you outgrow Foundry's fuzzer, install Chimera and your existing properties will run on Echidna and Medusa without changes.

If you want a team that's built invariant test suites for dozens of protocols to set up your fuzzing infrastructure, request an audit with Recon. We'll build the properties, run the campaigns, and leave you with a test suite that keeps working long after the engagement ends.

Related Posts

Related Glossary Terms

Go beyond Foundry — run multi-tool campaigns