Property-Based Testing with Fuzzing
Example-based tests verify that your code works for inputs you thought of. Property-based testing verifies that your code works for inputs you didn't think of.
When you write test_transfer(100), you're testing one scenario. But what about
transfer(0)? What about transfer(u256::MAX)? What about the exact balance
amount? Property-based testing with fuzzing automatically generates hundreds
or thousands of inputs to find edge cases that manual testing would miss.
Google's OSS-Fuzz project has found over 25,000 bugs that traditional testing missed. In the smart contract space, fuzzing has detected hundreds of vulnerabilities across deployed contracts.
Thinking in Properties, Not Examples
The shift: instead of "test with this specific input," you ask "does this property hold for any input?"
What is a Property?
A property is a statement that should always be true about your code, regardless of the input. "Total supply never changes during a transfer." "A user's balance is never negative." "Only the owner can call this function."
What is an Invariant?
An invariant is a specific type of property: a condition that must hold before and after every operation. Smart contracts often have important invariants:
| Invariant Type | Example |
|---|---|
| Balance Preservation | totalSupply == sum(all_balances) |
| Access Control | Only owner can call privileged functions |
| State Machine | Cannot transition from "closed" to "pending" |
| Arithmetic Safety | Balances cannot underflow to create tokens |
Example-Based vs Property-Based Testing
Let's compare approaches using a token transfer:
Example-Based Test
use snforge_std::{
ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
stop_cheat_caller_address,
};
use starknet::{ContractAddress, contract_address_const};
use crate::simple_token::{ISimpleTokenDispatcher, ISimpleTokenDispatcherTrait};
fn owner() -> ContractAddress {
contract_address_const::<'owner'>()
}
fn deploy_token(initial_supply: u256) -> ISimpleTokenDispatcher {
let contract = declare("SimpleToken").unwrap().contract_class();
let owner = owner();
let constructor_calldata = array![
owner.into(), initial_supply.low.into(), initial_supply.high.into(),
];
let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();
ISimpleTokenDispatcher { contract_address }
}
// A simple example-based test
#[test]
fn test_transfer_updates_balances() {
let token = deploy_token(1000);
let recipient = contract_address_const::<'recipient'>();
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, 100);
stop_cheat_caller_address(token.contract_address);
assert_eq!(token.balance_of(owner()), 900);
assert_eq!(token.balance_of(recipient), 100);
}
/// Invariant: Transfer preserves total supply
/// For any valid transfer, total_supply before == total_supply after
#[test]
#[fuzzer(runs: 100, seed: 12345)]
fn test_fuzz_transfer_preserves_total_supply(amount: u64) {
// Setup: deploy with enough balance for any fuzzed amount
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF; // max u64 as u256
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let supply_before = token.total_supply();
// Transfer a fuzzed amount
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let supply_after = token.total_supply();
// INVARIANT: total supply must not change
assert_eq!(supply_before, supply_after, "Total supply changed after transfer!");
}
/// Invariant: Transfer conserves balances
/// sender_balance_before + recipient_balance_before ==
/// sender_balance_after + recipient_balance_after
#[test]
#[fuzzer(runs: 100, seed: 54321)]
fn test_fuzz_transfer_conserves_balances(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let sender_before = token.balance_of(owner());
let recipient_before = token.balance_of(recipient);
let sum_before = sender_before + recipient_before;
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let sender_after = token.balance_of(owner());
let recipient_after = token.balance_of(recipient);
let sum_after = sender_after + recipient_after;
// INVARIANT: sum of involved balances must not change
assert_eq!(sum_before, sum_after, "Balance conservation violated!");
}
/// Property: Transfer round-trip
/// If A transfers X to B, and B transfers X back to A, balances return to original
#[test]
#[fuzzer(runs: 100, seed: 11111)]
fn test_fuzz_transfer_roundtrip(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let alice = owner();
let bob = contract_address_const::<'bob'>();
let alice_initial = token.balance_of(alice);
let bob_initial = token.balance_of(bob);
// Alice -> Bob
start_cheat_caller_address(token.contract_address, alice);
token.transfer(bob, amount.into());
stop_cheat_caller_address(token.contract_address);
// Bob -> Alice
start_cheat_caller_address(token.contract_address, bob);
token.transfer(alice, amount.into());
stop_cheat_caller_address(token.contract_address);
// PROPERTY: Balances should return to original
assert_eq!(token.balance_of(alice), alice_initial, "Alice balance not restored");
assert_eq!(token.balance_of(bob), bob_initial, "Bob balance not restored");
}
This test verifies that transferring 100 tokens works. But it doesn't test
transferring 0 tokens, transferring the exact balance, transferring more than
the balance, transferring to yourself, or large amounts near u256::MAX.
Property-Based Test
Instead of testing one amount, we test a property that should hold for any amount:
use snforge_std::{
ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
stop_cheat_caller_address,
};
use starknet::{ContractAddress, contract_address_const};
use crate::simple_token::{ISimpleTokenDispatcher, ISimpleTokenDispatcherTrait};
fn owner() -> ContractAddress {
contract_address_const::<'owner'>()
}
fn deploy_token(initial_supply: u256) -> ISimpleTokenDispatcher {
let contract = declare("SimpleToken").unwrap().contract_class();
let owner = owner();
let constructor_calldata = array![
owner.into(), initial_supply.low.into(), initial_supply.high.into(),
];
let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();
ISimpleTokenDispatcher { contract_address }
}
// A simple example-based test
#[test]
fn test_transfer_updates_balances() {
let token = deploy_token(1000);
let recipient = contract_address_const::<'recipient'>();
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, 100);
stop_cheat_caller_address(token.contract_address);
assert_eq!(token.balance_of(owner()), 900);
assert_eq!(token.balance_of(recipient), 100);
}
/// Invariant: Transfer preserves total supply
/// For any valid transfer, total_supply before == total_supply after
#[test]
#[fuzzer(runs: 100, seed: 12345)]
fn test_fuzz_transfer_preserves_total_supply(amount: u64) {
// Setup: deploy with enough balance for any fuzzed amount
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF; // max u64 as u256
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let supply_before = token.total_supply();
// Transfer a fuzzed amount
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let supply_after = token.total_supply();
// INVARIANT: total supply must not change
assert_eq!(supply_before, supply_after, "Total supply changed after transfer!");
}
/// Invariant: Transfer conserves balances
/// sender_balance_before + recipient_balance_before ==
/// sender_balance_after + recipient_balance_after
#[test]
#[fuzzer(runs: 100, seed: 54321)]
fn test_fuzz_transfer_conserves_balances(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let sender_before = token.balance_of(owner());
let recipient_before = token.balance_of(recipient);
let sum_before = sender_before + recipient_before;
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let sender_after = token.balance_of(owner());
let recipient_after = token.balance_of(recipient);
let sum_after = sender_after + recipient_after;
// INVARIANT: sum of involved balances must not change
assert_eq!(sum_before, sum_after, "Balance conservation violated!");
}
/// Property: Transfer round-trip
/// If A transfers X to B, and B transfers X back to A, balances return to original
#[test]
#[fuzzer(runs: 100, seed: 11111)]
fn test_fuzz_transfer_roundtrip(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let alice = owner();
let bob = contract_address_const::<'bob'>();
let alice_initial = token.balance_of(alice);
let bob_initial = token.balance_of(bob);
// Alice -> Bob
start_cheat_caller_address(token.contract_address, alice);
token.transfer(bob, amount.into());
stop_cheat_caller_address(token.contract_address);
// Bob -> Alice
start_cheat_caller_address(token.contract_address, bob);
token.transfer(alice, amount.into());
stop_cheat_caller_address(token.contract_address);
// PROPERTY: Balances should return to original
assert_eq!(token.balance_of(alice), alice_initial, "Alice balance not restored");
assert_eq!(token.balance_of(bob), bob_initial, "Bob balance not restored");
}
Listing 18-7: A fuzz test verifying the total supply invariant
The #[fuzzer(runs: 100, seed: 12345)] attribute tells Starknet Foundry to run
this test 100 times with different random amount values, using seed 12345
for reproducibility. If any of those 100 runs violates the invariant, the test
fails and reports the failing input.
Writing Effective Fuzz Tests
Identify Your Invariants
Before writing fuzz tests, list what must always be true.
For a token contract: total supply is constant (transfers don't create or destroy tokens), the sum of all balances equals total supply, balance of any account is non-negative, and only the minter can increase total supply.
For an auction: highest bid only increases, you cannot bid after the auction ends, and the winner is the highest bidder.
Design for Fuzzability
Structure tests so the fuzzer can explore interesting cases:
use snforge_std::{
ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
stop_cheat_caller_address,
};
use starknet::{ContractAddress, contract_address_const};
use crate::simple_token::{ISimpleTokenDispatcher, ISimpleTokenDispatcherTrait};
fn owner() -> ContractAddress {
contract_address_const::<'owner'>()
}
fn deploy_token(initial_supply: u256) -> ISimpleTokenDispatcher {
let contract = declare("SimpleToken").unwrap().contract_class();
let owner = owner();
let constructor_calldata = array![
owner.into(), initial_supply.low.into(), initial_supply.high.into(),
];
let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();
ISimpleTokenDispatcher { contract_address }
}
// A simple example-based test
#[test]
fn test_transfer_updates_balances() {
let token = deploy_token(1000);
let recipient = contract_address_const::<'recipient'>();
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, 100);
stop_cheat_caller_address(token.contract_address);
assert_eq!(token.balance_of(owner()), 900);
assert_eq!(token.balance_of(recipient), 100);
}
/// Invariant: Transfer preserves total supply
/// For any valid transfer, total_supply before == total_supply after
#[test]
#[fuzzer(runs: 100, seed: 12345)]
fn test_fuzz_transfer_preserves_total_supply(amount: u64) {
// Setup: deploy with enough balance for any fuzzed amount
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF; // max u64 as u256
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let supply_before = token.total_supply();
// Transfer a fuzzed amount
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let supply_after = token.total_supply();
// INVARIANT: total supply must not change
assert_eq!(supply_before, supply_after, "Total supply changed after transfer!");
}
/// Invariant: Transfer conserves balances
/// sender_balance_before + recipient_balance_before ==
/// sender_balance_after + recipient_balance_after
#[test]
#[fuzzer(runs: 100, seed: 54321)]
fn test_fuzz_transfer_conserves_balances(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let sender_before = token.balance_of(owner());
let recipient_before = token.balance_of(recipient);
let sum_before = sender_before + recipient_before;
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let sender_after = token.balance_of(owner());
let recipient_after = token.balance_of(recipient);
let sum_after = sender_after + recipient_after;
// INVARIANT: sum of involved balances must not change
assert_eq!(sum_before, sum_after, "Balance conservation violated!");
}
/// Property: Transfer round-trip
/// If A transfers X to B, and B transfers X back to A, balances return to original
#[test]
#[fuzzer(runs: 100, seed: 11111)]
fn test_fuzz_transfer_roundtrip(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let alice = owner();
let bob = contract_address_const::<'bob'>();
let alice_initial = token.balance_of(alice);
let bob_initial = token.balance_of(bob);
// Alice -> Bob
start_cheat_caller_address(token.contract_address, alice);
token.transfer(bob, amount.into());
stop_cheat_caller_address(token.contract_address);
// Bob -> Alice
start_cheat_caller_address(token.contract_address, bob);
token.transfer(alice, amount.into());
stop_cheat_caller_address(token.contract_address);
// PROPERTY: Balances should return to original
assert_eq!(token.balance_of(alice), alice_initial, "Alice balance not restored");
assert_eq!(token.balance_of(bob), bob_initial, "Bob balance not restored");
}
Listing 18-8: Testing balance conservation across transfers
We deploy with maximum u64 supply so any fuzzed u64 amount is valid. Then we
capture state before and after, and check that the sum of balances stayed the
same.
Test Round-Trip Properties
Round-trip properties verify that operations can be "undone":
use snforge_std::{
ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
stop_cheat_caller_address,
};
use starknet::{ContractAddress, contract_address_const};
use crate::simple_token::{ISimpleTokenDispatcher, ISimpleTokenDispatcherTrait};
fn owner() -> ContractAddress {
contract_address_const::<'owner'>()
}
fn deploy_token(initial_supply: u256) -> ISimpleTokenDispatcher {
let contract = declare("SimpleToken").unwrap().contract_class();
let owner = owner();
let constructor_calldata = array![
owner.into(), initial_supply.low.into(), initial_supply.high.into(),
];
let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();
ISimpleTokenDispatcher { contract_address }
}
// A simple example-based test
#[test]
fn test_transfer_updates_balances() {
let token = deploy_token(1000);
let recipient = contract_address_const::<'recipient'>();
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, 100);
stop_cheat_caller_address(token.contract_address);
assert_eq!(token.balance_of(owner()), 900);
assert_eq!(token.balance_of(recipient), 100);
}
/// Invariant: Transfer preserves total supply
/// For any valid transfer, total_supply before == total_supply after
#[test]
#[fuzzer(runs: 100, seed: 12345)]
fn test_fuzz_transfer_preserves_total_supply(amount: u64) {
// Setup: deploy with enough balance for any fuzzed amount
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF; // max u64 as u256
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let supply_before = token.total_supply();
// Transfer a fuzzed amount
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let supply_after = token.total_supply();
// INVARIANT: total supply must not change
assert_eq!(supply_before, supply_after, "Total supply changed after transfer!");
}
/// Invariant: Transfer conserves balances
/// sender_balance_before + recipient_balance_before ==
/// sender_balance_after + recipient_balance_after
#[test]
#[fuzzer(runs: 100, seed: 54321)]
fn test_fuzz_transfer_conserves_balances(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let recipient = contract_address_const::<'recipient'>();
let sender_before = token.balance_of(owner());
let recipient_before = token.balance_of(recipient);
let sum_before = sender_before + recipient_before;
start_cheat_caller_address(token.contract_address, owner());
token.transfer(recipient, amount.into());
stop_cheat_caller_address(token.contract_address);
let sender_after = token.balance_of(owner());
let recipient_after = token.balance_of(recipient);
let sum_after = sender_after + recipient_after;
// INVARIANT: sum of involved balances must not change
assert_eq!(sum_before, sum_after, "Balance conservation violated!");
}
/// Property: Transfer round-trip
/// If A transfers X to B, and B transfers X back to A, balances return to original
#[test]
#[fuzzer(runs: 100, seed: 11111)]
fn test_fuzz_transfer_roundtrip(amount: u64) {
let initial_supply: u256 = 0xFFFFFFFFFFFFFFFF;
let token = deploy_token(initial_supply);
let alice = owner();
let bob = contract_address_const::<'bob'>();
let alice_initial = token.balance_of(alice);
let bob_initial = token.balance_of(bob);
// Alice -> Bob
start_cheat_caller_address(token.contract_address, alice);
token.transfer(bob, amount.into());
stop_cheat_caller_address(token.contract_address);
// Bob -> Alice
start_cheat_caller_address(token.contract_address, bob);
token.transfer(alice, amount.into());
stop_cheat_caller_address(token.contract_address);
// PROPERTY: Balances should return to original
assert_eq!(token.balance_of(alice), alice_initial, "Alice balance not restored");
assert_eq!(token.balance_of(bob), bob_initial, "Bob balance not restored");
}
Listing 18-9: Testing the transfer round-trip property
Common Property Patterns
Invariants (Always True)
// After ANY operation, this should hold
assert!(contract.total_supply() == expected_total);
Symmetry/Commutativity
// Order shouldn't matter
let result_ab = calculate(a, b);
let result_ba = calculate(b, a);
assert_eq!(result_ab, result_ba);
Idempotence
// Doing it twice is same as doing it once
contract.pause();
contract.pause(); // Should not fail or change state
assert!(contract.is_paused());
No Invalid State Transitions
// From "completed" state, cannot go back to "pending"
#[test]
#[should_panic]
fn test_cannot_transition_completed_to_pending(random_input: felt252) {
// Setup completed state
// Attempt transition - should fail
}
Configuring the Fuzzer
Configure fuzzing in your Scarb.toml:
[tool.snforge]
fuzzer_runs = 256 # Number of iterations per fuzz test
fuzzer_seed = 12345 # Seed for reproducibility
Or per-test with the attribute:
#[test]
#[fuzzer(runs: 1000, seed: 42)]
fn test_with_custom_config(x: u128) { /* ... */ }
Choosing Fuzzer Runs
During development, 50-100 runs give you fast iteration. In CI, 256-500 runs provide good coverage. Before audits, run 1000+ for thorough testing.
When to Fuzz
Fuzzing pays off most for:
| Scenario | Why Fuzz |
|---|---|
| Financial calculations | Edge cases in math can cause loss of funds |
| Access control | Ensure no input bypasses authorization |
| State machines | Find invalid state transitions |
| Parsing/serialization | Malformed input handling |
Fuzzing may be overkill for simple getters with no logic, functions with no parameters, and already well-tested pure functions.
Limitations
Starknet Foundry's fuzzer is random, not coverage-guided. It generates random inputs rather than learning which inputs explore new code paths. This means it may miss specific edge cases that require precise inputs. More runs generally find more bugs, but with diminishing returns. Use fuzzing to complement thoughtful example tests, not replace them.
Summary
Property-based testing asks "does this property hold for any input?" instead of "does this pass for the inputs I thought of?" The workflow is straightforward: identify invariants (what must always be true?), write fuzz tests to check them, and use more runs in CI than during development.
Combined with unit and integration tests, fuzzing catches edge cases that manual testing misses. For contracts handling real value, it's worth the setup.
For detailed fuzzer options, see the Starknet Foundry fuzz testing documentation.