
Testing Solidity with Property-Based Fuzzing
What property-based fuzzing tests
In a conventional unit test, you specify exact inputs and expected outputs. In a fuzz test, you specify a property that should always hold, regardless of the input values the framework chooses.
Typical properties include:
- balances should never become negative
- total supply should equal the sum of all user balances
- unauthorized callers should never change privileged state
- a withdrawal should never exceed the available deposit
- a state transition should only happen from valid states
The key idea is that you test behavior, not just examples. This is especially important in Solidity because many bugs only appear under unusual combinations of values, call order, or boundary conditions.
Why fuzzing is valuable in Solidity
Solidity contracts often encode assumptions that are hard to validate with a few examples:
- integer boundaries around
uint256arithmetic - zero-value edge cases
- repeated calls with the same parameters
- unusual caller identities
- state changes across multiple transactions
- interactions between multiple public functions
Fuzzing helps because it explores a much larger input space than manual tests. Modern frameworks can also shrink failing inputs to a minimal counterexample, making debugging much easier.
For example, a token contract might appear correct when tested with a few transfers, but a fuzz test could reveal that a sequence of deposits and withdrawals breaks an invariant under a rare combination of values.
Choosing the right properties
A good fuzz test starts with a precise property. The property should be:
- observable: you can check it directly from contract state
- stable: it should hold for all valid inputs
- meaningful: a failure indicates a real bug, not an expected exception
- narrow enough: avoid testing too many unrelated behaviors in one property
A useful way to think about properties is to separate them into categories.
| Property type | Example | What it catches |
|---|---|---|
| Safety | balance <= limit | Overflows, unauthorized changes |
| Conservation | sum(balances) == totalSupply | Accounting mismatches |
| Access control | only owner can pause | Privilege escalation |
| State machine | cannot withdraw before deposit | Invalid transitions |
| Idempotence | calling sync() twice has no effect | Duplicate side effects |
The best fuzz tests are usually small and focused. If a property fails, you want to know exactly which assumption was violated.
Example: fuzzing a simple vault
Consider a minimal vault that accepts deposits and withdrawals. The important invariant is that the contract’s recorded balance for a user should never exceed the amount they have deposited minus what they have withdrawn.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public deposits;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
deposits[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(amount > 0, "zero amount");
require(deposits[msg.sender] >= amount, "insufficient balance");
deposits[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function balanceOf(address user) external view returns (uint256) {
return deposits[user];
}
}A fuzz test should not only check that valid withdrawals succeed. It should also verify that the contract never allows a user to withdraw more than their deposit.
Foundry-style fuzz test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultFuzzTest is Test {
Vault vault;
function setUp() external {
vault = new Vault();
}
function testFuzz_DepositWithdrawConservation(uint96 depositAmount, uint96 withdrawAmount) external {
vm.assume(depositAmount > 0);
vm.deal(address(this), depositAmount);
vault.deposit{value: depositAmount}();
uint256 allowed = depositAmount;
uint256 requested = withdrawAmount;
if (requested > allowed) {
vm.expectRevert("insufficient balance");
vault.withdraw(requested);
return;
}
vault.withdraw(requested);
assertEq(vault.balanceOf(address(this)), depositAmount - requested);
}
}This test does two things:
- it checks that invalid withdrawals revert
- it checks that valid withdrawals preserve accounting
Notice the use of uint96 instead of uint256. Narrower types are often more practical for fuzzing because they reduce the search space while still covering a large range of values.
Handling assumptions with assume
Fuzzers generate many inputs that are irrelevant to the property you want to test. Use assumptions to constrain the input domain.
Common examples:
vm.assume(amount > 0)vm.assume(user != address(0))vm.assume(x < y)vm.assume(bytes(data).length <= 32)
Assumptions are powerful, but they can also hide problems if overused. If you find yourself filtering out most generated values, consider whether the property is too broad or whether the contract should be tested under a different invariant.
A good rule is to assume only what is necessary for the scenario being tested. For example, if a function should reject zero values, do not assume them away in every test; instead, write a separate property that verifies the revert behavior.
Fuzzing with multiple actors
Many Solidity bugs appear only when different addresses interact with the contract. A single-caller fuzz test can miss these issues entirely.
For example, suppose a staking contract tracks deposits per user and allows withdrawals only by the original depositor. A useful property is that one user’s actions should not affect another user’s balance.
function testFuzz_UsersDoNotCrossContaminate(
address userA,
address userB,
uint96 amountA,
uint96 amountB
) external {
vm.assume(userA != address(0));
vm.assume(userB != address(0));
vm.assume(userA != userB);
vm.assume(amountA > 0 && amountB > 0);
vm.deal(userA, amountA);
vm.deal(userB, amountB);
vm.prank(userA);
vault.deposit{value: amountA}();
vm.prank(userB);
vault.deposit{value: amountB}();
assertEq(vault.balanceOf(userA), amountA);
assertEq(vault.balanceOf(userB), amountB);
}This kind of test is useful for detecting accidental shared state, incorrect mapping keys, and authorization mistakes.
Fuzzing stateful behavior
Some contracts are not well described by a single function call. They behave like state machines, where the order of operations matters. In those cases, fuzzing should focus on sequences.
Examples include:
- escrow contracts
- auction systems
- governance modules
- vesting schedules
- multi-step initialization flows
A practical strategy is to model the expected state in the test itself. After each action, compare the contract state to the model.
For instance, if a contract supports deposit, withdraw, and pause, your test can maintain a local counter representing the expected balance and verify that the on-chain state matches after each step.
This is often more effective than testing each function in isolation because many bugs only appear after a specific sequence of calls.
Common pitfalls
1. Treating reverts as failures in every case
A fuzz test should distinguish between expected and unexpected reverts. If a function is supposed to reject invalid input, the test should assert that behavior explicitly.
2. Over-assuming inputs
If you filter out too many values, the fuzzer may never explore the most interesting cases. Keep assumptions minimal and targeted.
3. Testing implementation details instead of properties
Avoid assertions that only mirror the code structure. For example, checking that a local variable changes in a specific way is less valuable than checking a contract-wide invariant.
4. Ignoring caller context
In Solidity, msg.sender, tx.origin, msg.value, and block values matter. Fuzz tests that do not vary caller context may miss critical bugs.
5. Forgetting about arithmetic boundaries
Even with Solidity 0.8+ checked arithmetic, boundary values still matter. Test zero, one, maximum practical values, and values around thresholds.
Practical best practices
Keep properties small
A single fuzz test should validate one meaningful invariant. If you need several unrelated assertions, split them into separate tests.
Use meaningful variable names
Names like depositAmount, withdrawAmount, and expectedBalance make the intent clear. This matters even more in fuzz tests because the inputs are generated automatically.
Prefer deterministic setup
The contract setup should be stable and repeatable. If your test depends on hidden randomness, failures become harder to reproduce.
Combine fuzzing with targeted examples
Fuzzing is strongest when it complements example-based tests. Use unit tests for known scenarios and fuzz tests for broad coverage.
Add regression tests for discovered bugs
When fuzzing finds a failure, convert the minimal failing input into a deterministic regression test. This prevents the bug from returning later.
When fuzzing is not enough
Property-based fuzzing is powerful, but it has limits.
It may not be ideal for:
- complex cross-contract integrations with many external dependencies
- logic that depends heavily on off-chain data
- scenarios requiring precise timing or chain history
- bugs that only appear under very specific protocol conditions
In these cases, fuzzing should be combined with integration tests, invariant testing, and manual review. The goal is broad confidence, not blind trust.
A practical workflow
A good workflow for Solidity fuzz testing looks like this:
- identify a critical invariant
- write a narrow fuzz test around it
- constrain only the necessary inputs
- run the test repeatedly with different seeds
- inspect failures and shrink the counterexample
- turn the failure into a regression test
- expand coverage to related invariants
This workflow scales well because each new property increases confidence without requiring a large amount of boilerplate.
Summary
Property-based fuzzing helps Solidity developers catch bugs that example-based tests often miss. It is especially effective for accounting logic, access control, and stateful contracts where the number of possible input combinations is large.
The most important habit is to define clear properties. Once the property is correct, the fuzzer becomes a powerful search tool that can explore edge cases far beyond what you would write manually.
