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 TypeExample
Balance PreservationtotalSupply == sum(all_balances)
Access ControlOnly owner can call privileged functions
State MachineCannot transition from "closed" to "pending"
Arithmetic SafetyBalances 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:

ScenarioWhy Fuzz
Financial calculationsEdge cases in math can cause loss of funds
Access controlEnsure no input bypasses authorization
State machinesFind invalid state transitions
Parsing/serializationMalformed 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.